Profile
Back to NewsBack
GitHub Trending 19 min
Reader Mode
c0tton-fluff/caido-mcp-server: MCP server for Caido proxy integration. Enables AI assistants like Claude Code to browse, analyse, and interact with HTTP traffic.

c0tton-fluff/caido-mcp-server: MCP server for Caido proxy integration. Enables AI assistants like Claude Code to browse, analyse, and interact with HTTP traffic.

Caido

caido-mcp-server

MCP server and CLI for Caido web proxy - browse, replay, and analyze HTTP traffic from AI assistants or your terminal.

Go License Release MCP CI


What It Does

Two ways to interact with your Caido proxy:

  • MCP Server - expose 67 tools and 6 read-only resources to AI assistants (Claude Code, Cursor, etc.) via the Model Context Protocol
  • CLI - standalone terminal client for pentesters who prefer the command line
Both share the same auth token, the same Go SDK, and the same codebase.

Features

| Category | Capabilities | |----------|-------------| | Proxy History | Search requests with HTTPQL, get full request/response details, diff two responses | | Replay | Send HTTP requests, get response inline (status, headers, body). Per-session cookie jar auto-persists Set-Cookie between calls | | Automate | Access fuzzing sessions, results, and payloads. Start/pause/resume/cancel tasks | | Findings | Create, list, delete, and export security findings | | Sitemap | Browse discovered endpoints | | Scopes | Full lifecycle: create, rename, delete target scope definitions; check if a host/URL is in scope | | Projects | Full lifecycle: create, rename, select, delete projects | | Workflows | List, run, and toggle automation workflows | | Tamper | List, create, update, toggle, and delete Match & Replace rules in all four GUI modes (update raw, update value, add, remove); dry-run a rule against a raw request before committing it | | Intercept | Check status, pause/resume, list/forward/drop intercepted requests | | Environments | Create, select, delete variable environments (tokens, keys) | | Filters | Create, list, and delete saved HTTPQL filter presets | | Hosted Files | List payload files served by Caido | | Tasks | List and cancel running background tasks | | Plugins | List installed plugin packages | | Instance | Get Caido version and platform info |

Built-in security and performance:

  • Credential redaction - Authorization, Cookie, and API key headers are redacted in tool output by default (including raw request/response dumps and the caido://requests/{id} resource); opt out with CAIDO_ALLOW_SENSITIVE_HEADERS (see Revealing sensitive headers)
  • Tool annotations - every tool declares readOnlyHint/destructiveHint/idempotentHint/openWorldHint so MCP clients can distinguish read-only, destructive, and external-network tools
  • Session cookie jar - RFC 6265 jar per replay session; Set-Cookie from a response is auto-attached to the next send_request against the same session
  • Response fingerprinting - auto-detects content kind (json/html/xml/text/binary) so agents know what they're dealing with
  • Adaptive body limits - JSON gets 4KB, HTML 3KB, binary 200B (override with explicit bodyLimit)
  • Response diff - repeated identical responses in the same session collapse to a one-line summary, saving tokens
  • Input validation - length limits on all string inputs to prevent context flooding
  • Token auto-refresh - expired OAuth tokens refresh mid-session automatically
  • Session reuse - single replay session per server lifetime, no sprawl

Session cookie jar

The caido_send_request tool maintains an in-memory http.CookieJar per replay session. Cookies set via Set-Cookie in any response are stored and auto-injected into subsequent requests targeting the same RFC 6265 domain/path. Pass useCookieJar: false to a single call to disable injection (useful for session-fixation testing or to verify auth gates). Use caido_clear_session_cookies to wipe a session jar between test runs and caido_get_session_cookies to introspect what is stored (cookie values are not returned, only metadata).

The output of caido_send_request includes a cookieJar block with injectedCookies (names sent on this call) and storedCookies (names captured from Set-Cookie), so the LLM can verify the chain stayed authenticated.

Response fingerprinting

Every caido_send_request / caido_batch_send response includes a compact fingerprint so an agent can reason about a response without the full body:

  • title - HTML </code>, if present</li> <li><code>redirect</code> - Location target on a 3xx</li> <li><code>cookieNames</code> - names set via <code>Set-Cookie</code> (values are never included)</li> <li><code>wordCount</code> - body word count, for size/diff comparison</li> <li><code>notableHeaders</code> - non-standard response headers (<code>Server</code>, <code>X-Powered-By</code>, custom <code>X-*</code>). App and flag signal often hides here - check these on every response, including 4xx/5xx.</li></ul> The fingerprint stays populated even when <code>includeBody: false</code>. <h3>Revealing sensitive headers</h3> <p>By default, sensitive headers (<code>Authorization</code>, <code>Cookie</code>, <code>Set-Cookie</code>, <code>Proxy-Authorization</code>, <code>X-Api-Key</code>, <code>X-Auth-Token</code>, <code>X-CSRF-Token</code>, <code>X-XSRF-Token</code>) are replaced with <code>[REDACTED]</code> in tool output to avoid leaking credentials into the model context. On an authorized engagement where you need the real values — to analyze or replay a captured authenticated request, or to produce a working <code>caido_export_curl</code> PoC — set <code>CAIDO_ALLOW_SENSITIVE_HEADERS</code> to a truthy value (<code>1</code>, <code>true</code>):</p> <pre><code class="json">{ "mcpServers": { "caido": { "command": "caido-mcp-server", "args": ["serve"], "env": { "CAIDO_URL": "http://127.0.0.1:8080", "CAIDO_ALLOW_SENSITIVE_HEADERS": "true" } } } }</code></pre> <p>When enabled, real credential values flow through tool output to the model; leave it unset to keep redaction. This toggle does not affect the session cookie jar, which only ever reports cookie names and metadata, never values.</p> <hr> <h2>MCP Server</h2> <h3>Install</h3> <pre><code class="bash">curl -fsSL https://raw.githubusercontent.com/c0tton-fluff/caido-mcp-server/main/install.sh | bash</code></pre> <p>Or download a pre-built binary from <a href="https://github.com/c0tton-fluff/caido-mcp-server/releases" target="_blank" rel="noopener">Releases</a> (macOS, Linux, Windows - amd64/arm64).</p> <p>Or install with the Go toolchain (Go 1.25+):</p> <pre><code class="bash">go install github.com/c0tton-fluff/caido-mcp-server/v4/cmd/caido-mcp-server@latest</code></pre> <p>The binary lands in <code>$(go env GOPATH)/bin</code> (add it to your <code>PATH</code>). The installed binary reports its module version via <code>caido-mcp-server --version</code>.</p> <p><details> <summary>Build from source</summary></p> <pre><code class="bash">git clone https://github.com/c0tton-fluff/caido-mcp-server.git cd caido-mcp-server go build -ldflags "-X github.com/c0tton-fluff/caido-mcp-server/v4/internal/buildinfo.version=$(git describe --tags)" -o caido-mcp-server ./cmd/caido-mcp-server</code></pre> <p></details></p> <h3>Quick Start</h3> <p><strong>Option A: Static access token (recommended)</strong></p> <p>This server talks to the <strong>local Caido app's <a href="https://docs.caido.io/app/concepts/graphql#authentication" target="_blank" rel="noopener">GraphQL API</a></strong>, which authenticates with the <strong>access token from your Caido login session</strong> — <em>not</em> a Caido Cloud <a href="https://docs.caido.io/dashboard/concepts/pat" target="_blank" rel="noopener">Personal Access Token</a>. A Cloud PAT (prefixed <code>caido_</code>) is for the cloud/dashboard API and will <strong>not</strong> authenticate against your local instance.</p> <p>Grab the access token from the Caido GUI: open developer tools (<code>CTRL</code>+<code>SHIFT</code>+<code>I</code>) and run this in the Console tab:</p> <pre><code class="javascript">JSON.parse(localStorage.CAIDO_AUTHENTICATION).accessToken</code></pre> <p>Pass it via the <code>CAIDO_ACCESS_TOKEN</code> environment variable. No login command needed.</p> <pre><code class="json">{ "mcpServers": { "caido": { "command": "caido-mcp-server", "args": ["serve"], "env": { "CAIDO_URL": "http://127.0.0.1:8080", "CAIDO_ACCESS_TOKEN": "your-caido-access-token" } } } }</code></pre> <blockquote><strong>Note:</strong> this token expires after ~7 days; for a long-lived setup use <strong>Option B</strong> (OAuth), which refreshes automatically. The older <code>CAIDO_PAT</code> variable is still accepted as a deprecated alias for <code>CAIDO_ACCESS_TOKEN</code>.</blockquote> <p><strong>Option B: OAuth device flow</strong></p> <pre><code class="bash">CAIDO_URL=http://localhost:8080 caido-mcp-server login</code></pre> <p>This opens your browser for OAuth authentication and saves the token to <code>~/.caido-mcp/token.json</code>. Then configure your MCP client:</p> <pre><code class="json">{ "mcpServers": { "caido": { "command": "caido-mcp-server", "args": ["serve"], "env": { "CAIDO_URL": "http://127.0.0.1:8080" } } } }</code></pre> <p><strong>3. Use it</strong></p> <pre><code class="">"List all POST requests to /api" "Send this request with a modified user ID" "Create a finding for this IDOR" "Show fuzzing results from Automate session 1" "What's in scope?"</code></pre> <h3>MCP Tools (66)</h3> <p>| Tool | Description | |------|-------------| | <code>caido_list_requests</code> | List requests with HTTPQL filter and pagination | | <code>caido_get_request</code> | Get request details (metadata, headers, body). 2KB body limit default | | <code>caido_diff_responses</code> | Structural diff of two responses by Caido request ID: status/size change flags and a compact body/header summary (never dumps full bodies) | | <code>caido_send_request</code> | Send HTTP request via Replay, returns response inline. Polls up to 10s. Auto-injects session cookies and persists <code>Set-Cookie</code> (toggle with <code>useCookieJar</code>) | | <code>caido_batch_send</code> | Send multiple requests in parallel (BAC sweeps, parameter fuzzing, endpoint sweeps). Max 50 per batch | | <code>caido_edit_request</code> | Modify and resend an existing request. Preserves auth/cookies while changing method, path, headers, or body | | <code>caido_export_curl</code> | Convert a request to an executable curl command for PoC reports | | <code>caido_create_replay_session</code> | Create a named replay session, optionally seed with a request | | <code>caido_list_replay_sessions</code> | List replay sessions | | <code>caido_delete_replay_sessions</code> | Bulk delete replay sessions by ID | | <code>caido_move_replay_session</code> | Move a session to a different collection | | <code>caido_get_replay_entry</code> | Get replay entry with response. 2KB body limit default | | <code>caido_clear_session_cookies</code> | Wipe the in-memory cookie jar for a replay session | | <code>caido_get_session_cookies</code> | List metadata for cookies stored in a session jar matching a URL (values not returned) | | <code>caido_list_replay_collections</code> | List replay session collections | | <code>caido_create_replay_collection</code> | Create a named replay collection | | <code>caido_rename_replay_collection</code> | Rename a replay collection | | <code>caido_delete_replay_collection</code> | Delete a replay collection | | <code>caido_list_automate_sessions</code> | List fuzzing sessions | | <code>caido_get_automate_session</code> | Get session details with entry list | | <code>caido_get_automate_entry</code> | Get fuzz results and payloads | | <code>caido_automate_task_control</code> | Start/pause/resume/cancel fuzzing tasks | | <code>caido_list_findings</code> | List security findings | | <code>caido_create_finding</code> | Create finding linked to a request | | <code>caido_delete_findings</code> | Delete findings by IDs or reporter name | | <code>caido_export_findings</code> | Export findings for reporting | | <code>caido_get_sitemap</code> | Browse sitemap hierarchy | | <code>caido_list_scopes</code> | List target scopes | | <code>caido_is_in_scope</code> | Check whether a host or URL is in the project scope; returns the matching scope and the allow/deny rule that decided it | | <code>caido_create_scope</code> | Create new scope with allow/deny lists | | <code>caido_rename_scope</code> | Rename a scope | | <code>caido_delete_scope</code> | Delete a scope | | <code>caido_list_projects</code> | List projects, marks current | | <code>caido_select_project</code> | Switch active project | | <code>caido_create_project</code> | Create a new project | | <code>caido_rename_project</code> | Rename a project | | <code>caido_delete_project</code> | Delete a project | | <code>caido_list_workflows</code> | List automation workflows | | <code>caido_run_workflow</code> | Execute an active or convert workflow | | <code>caido_toggle_workflow</code> | Enable or disable a workflow | | <code>caido_list_tamper_rules</code> | List Match & Replace rule collections | | <code>caido_create_tamper_rule</code> | Create a tamper rule in a collection | | <code>caido_update_tamper_rule</code> | Update an existing tamper rule | | <code>caido_test_tamper_rule</code> | Dry-run a tamper rule against a raw request | | <code>caido_toggle_tamper_rule</code> | Enable or disable a tamper rule | | <code>caido_delete_tamper_rule</code> | Delete a tamper rule | | <code>caido_get_instance</code> | Get Caido version and platform info | | <code>caido_intercept_status</code> | Get intercept status (PAUSED/RUNNING) | | <code>caido_intercept_control</code> | Pause or resume intercept | | <code>caido_list_intercept_entries</code> | List queued intercept entries with HTTPQL filtering | | <code>caido_forward_intercept</code> | Forward intercepted request, optionally with modifications | | <code>caido_drop_intercept</code> | Drop intercepted request | | <code>caido_list_environments</code> | List environments and their variables | | <code>caido_select_environment</code> | Switch active environment | | <code>caido_create_environment</code> | Create a new environment | | <code>caido_delete_environment</code> | Delete an environment | | <code>caido_list_filters</code> | List saved HTTPQL filter presets | | <code>caido_create_filter</code> | Save an HTTPQL query as a named filter preset | | <code>caido_delete_filter</code> | Delete a filter preset | | <code>caido_list_hosted_files</code> | List hosted payload files | | <code>caido_list_tasks</code> | List running background tasks | | <code>caido_cancel_task</code> | Cancel a running task by ID | | <code>caido_list_plugins</code> | List installed plugin packages | | <code>caido_list_ws_streams</code> | List WebSocket streams (connections) from the WebSocket tab | | <code>caido_list_ws_messages</code> | List WebSocket frames for a stream (direction/format/decoded body) | | <code>caido_convert_body</code> | Convert a request body between JSON, form-urlencoded, XML, and multipart | | <code>caido_race_window_send</code> | Fire raw HTTP/1.1 requests with synchronized last-byte send for race-condition testing (bypasses Caido proxy) |</p> <h3>MCP Resources (6)</h3> <p>Read-only data exposed via the MCP resources protocol. Agents can read these without consuming tool calls.</p> <p>| URI | Description | |-----|-------------| | <code>caido://requests/{id}</code> | Full HTTP request and response for a given request ID | | <code>caido://replay-sessions/{id}</code> | Replay session details with entry list | | <code>caido://sitemap</code> | Root domains from the sitemap | | <code>caido://findings</code> | Security finding summaries (up to 100) | | <code>caido://scopes</code> | All target scopes with their allow/deny rules | | <code>caido://project</code> | Current project, instance version, and connection status |</p> <p><details> <summary>Parameter reference</summary></p> <h4>caido_list_requests</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>httpql</code> | string | HTTPQL filter query | | <code>limit</code> | int | Max requests (default 20, max 100) | | <code>after</code> | string | Pagination cursor |</p> <h4>caido_get_request</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>ids</code> | string[] | Request IDs (required) | | <code>include</code> | string[] | <code>requestHeaders</code>, <code>requestBody</code>, <code>responseHeaders</code>, <code>responseBody</code> | | <code>bodyOffset</code> | int | Byte offset | | <code>bodyLimit</code> | int | Byte limit (default 2000) |</p> <h4>caido_send_request</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>raw</code> | string | Full HTTP request (required) | | <code>host</code> | string | Target host (overrides Host header) | | <code>port</code> | int | Target port | | <code>tls</code> | bool | Use HTTPS (default true) | | <code>sessionId</code> | string | Replay session (auto-managed if omitted) | | <code>bodyLimit</code> | int | Response body byte limit (default 2000) | | <code>bodyOffset</code> | int | Response body byte offset (default 0) | | <code>useCookieJar</code> | bool | Auto-inject session cookies and persist <code>Set-Cookie</code> (default true); set false to disable for this call only | | <code>includeBody</code> | bool | Include response body text (default true); the fingerprint is always populated | | <code>marker</code> | string | String to search for in the response body; when set, <code>output.reflected</code> reports whether it was found |</p> <h4>caido_get_replay_entry</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Replay entry ID (required) | | <code>bodyOffset</code> | int | Byte offset | | <code>bodyLimit</code> | int | Byte limit (default 2000) |</p> <h4>caido_get_automate_entry</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Entry ID (required) | | <code>limit</code> | int | Max results | | <code>after</code> | string | Pagination cursor |</p> <h4>caido_create_finding</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>requestId</code> | string | Associated request (required) | | <code>title</code> | string | Finding title (required) | | <code>description</code> | string | Finding description |</p> <h4>caido_create_scope</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>name</code> | string | Scope name (required) | | <code>allowlist</code> | string[] | Hostnames to include, e.g. <code>example.com</code>, <code>*.example.com</code> (required) | | <code>denylist</code> | string[] | Hostnames to exclude |</p> <h4>caido_select_project</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Project ID to switch to (required) |</p> <h4>caido_intercept_control</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>action</code> | string | <code>pause</code> or <code>resume</code> (required) |</p> <h4>caido_list_intercept_entries</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>filter</code> | string | HTTPQL filter query | | <code>limit</code> | int | Max entries (default 20, max 100) | | <code>after</code> | string | Pagination cursor |</p> <h4>caido_forward_intercept</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Intercept entry ID (required) | | <code>raw</code> | string | Modified raw HTTP request (base64-encoded, optional) |</p> <h4>caido_drop_intercept</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Intercept entry ID (required) |</p> <h4>caido_automate_task_control</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>action</code> | string | <code>start</code>, <code>pause</code>, <code>resume</code>, or <code>cancel</code> (required) | | <code>session_id</code> | string | Automate session ID (required for start) | | <code>task_id</code> | string | Automate task ID (required for pause/resume/cancel) |</p> <h4>caido_delete_findings</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>ids</code> | string[] | Finding IDs to delete | | <code>reporter</code> | string | Delete all findings by this reporter |</p> <h4>caido_export_findings</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>ids</code> | string[] | Finding IDs to export | | <code>reporter</code> | string | Export all findings by this reporter |</p> <h4>caido_list_environments</h4> <p>No parameters required. Returns all environments with variables and selected/global context.</p> <h4>caido_select_environment</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Environment ID (required, empty string to deselect) |</p> <h4>caido_run_workflow</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Workflow ID (required) | | <code>type</code> | string | <code>active</code> or <code>convert</code> (required) | | <code>request_id</code> | string | Request ID (required for active workflows) | | <code>input</code> | string | Input data (required for convert workflows) |</p> <h4>caido_toggle_workflow</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Workflow ID (required) | | <code>enabled</code> | bool | Enable or disable (required) |</p> <h4>caido_list_tamper_rules</h4> <p>No parameters required. Returns all tamper rule collections with nested rules (<code>id</code>, <code>name</code>, <code>section</code>, <code>enabled</code>, <code>condition</code>, <code>sources</code>).</p> <h4>caido_create_tamper_rule</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>collection_id</code> | string | Collection ID (required) | | <code>name</code> | string | Rule name (required) | | <code>section</code> | string | Section to match (required), see below | | <code>operation</code> | object | Operation mode and parameters, see below | | <code>match</code> | string | Legacy shorthand for <code>operation.match</code> | | <code>replace</code> | string | Legacy shorthand for <code>operation.value</code> | | <code>condition</code> | string | HTTPQL filter condition | | <code>sources</code> | string[] | Traffic sources: INTERCEPT, REPLAY, AUTOMATE, IMPORT, PLUGIN, WORKFLOW, SAMPLE |</p> <p>##### Operation modes</p> <p>| <code>operation.kind</code> | Meaning | Fields | |---|---|---| | <code>updateRaw</code> | Pattern match over the raw section text | <code>match</code>, <code>match_kind</code>, <code>value</code> | | <code>updateValue</code> | Set the value of a named header or query param | <code>name</code>, <code>value</code> | | <code>add</code> | Insert a new header or query param | <code>name</code>, <code>value</code> | | <code>remove</code> | Delete a named header or query param | <code>name</code> |</p> <p><code>match_kind</code> selects how <code>match</code> is read: <code>regex</code> (default), <code>value</code> (literal substring, no escaping) or <code>full</code> (the entire section, <code>match</code> must be omitted). Use <code>workflow_id</code> instead of <code>value</code> to supply the replacement from a convert workflow.</p> <p>All four modes are available on <code>requestHeader</code>, <code>responseHeader</code> and <code>requestQuery</code>. Every other section supports exactly one mode, and asking for another returns an error naming the modes it does support: <code>requestAll</code>, <code>requestBody</code>, <code>requestFirstLine</code>, <code>requestPath</code>, <code>responseAll</code>, <code>responseBody</code>, <code>responseFirstLine</code> take <code>updateRaw</code>; <code>requestMethod</code>, <code>requestSNI</code>, <code>responseStatusCode</code> take <code>updateValue</code> (they always apply, so they accept no <code>match</code> or <code>name</code>).</p> <p>Omitting <code>operation</code> entirely falls back to the section's default mode with the legacy <code>match</code>/<code>replace</code> fields, so existing callers keep working unchanged.</p> <h4>caido_update_tamper_rule</h4> <p>Full update: pass the complete rule state, not a partial patch. Accepts the same <code>section</code>, <code>operation</code> and legacy <code>match</code>/<code>replace</code> parameters as <code>caido_create_tamper_rule</code>.</p> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Tamper rule ID (required) | | <code>name</code> | string | Rule name (required) | | <code>section</code> | string | Section to match (required) | | <code>operation</code> | object | Operation mode and parameters | | <code>match</code> | string | Legacy shorthand for <code>operation.match</code> | | <code>replace</code> | string | Legacy shorthand for <code>operation.value</code> | | <code>condition</code> | string | HTTPQL filter condition | | <code>sources</code> | string[] | Traffic sources |</p> <h4>caido_test_tamper_rule</h4> <p>Dry-run a rule against a raw HTTP request or response and return the transformed result. Nothing is persisted and no traffic is sent. Accepts the same <code>section</code>, <code>operation</code> and legacy <code>match</code>/<code>replace</code> parameters as <code>caido_create_tamper_rule</code>.</p> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>raw</code> | string | Raw HTTP request or response to transform (required) | | <code>section</code> | string | Section to match (required) | | <code>operation</code> | object | Operation mode and parameters |</p> <p>Returns <code>raw</code> (the transformed message) and <code>changed</code> (whether the rule matched anything at all), which is the quickest way to catch a rule that silently matches nothing.</p> <h4>caido_toggle_tamper_rule</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Tamper rule ID (required) | | <code>enabled</code> | bool | Enable or disable (required) |</p> <h4>caido_delete_tamper_rule</h4> <p>| Parameter | Type | Description | |-----------|------|-------------| | <code>id</code> | string | Tamper rule ID (required) |</p> <p></details></p> <hr> <h2>CLI</h2> <p>Standalone terminal client for Caido. No MCP required - use it directly from your shell.</p> <h3>Install</h3> <pre><code class="bash">curl -fsSL https://raw.githubusercontent.com/c0tton-fluff/caido-mcp-server/main/install.sh | TOOL=cli bash</code></pre> <p>Or download from <a href="https://github.com/c0tton-fluff/caido-mcp-server/releases" target="_blank" rel="noopener">Releases</a>.</p> <p>Or install with the Go toolchain (Go 1.25+):</p> <pre><code class="bash">go install github.com/c0tton-fluff/caido-mcp-server/v4/cmd/caido-cli@latest</code></pre> <p><details> <summary>Build from source</summary></p> <pre><code class="bash">git clone https://github.com/c0tton-fluff/caido-mcp-server.git cd caido-mcp-server go build -o caido-cli ./cmd/caido-cli</code></pre> <p></details></p> <h3>Usage</h3> <p>Requires authentication - run <code>caido-mcp-server login</code> first to store a token. (The CLI reads the stored login token; it does not consume the <code>CAIDO_ACCESS_TOKEN</code> env var that the MCP server uses.)</p> <pre><code class="bash"># Check connection and auth caido-cli status -u http://localhost:8080 <h1>Send structured requests</h1> caido-cli send GET https://target.com/api/users caido-cli send POST https://target.com/api/login -j '{"user":"admin","pass":"test"}' caido-cli send PUT https://target.com/api/profile -H "Authorization: Bearer tok" -j '{"role":"admin"}' <h1>Send raw HTTP requests</h1> caido-cli raw 'GET /api/users HTTP/1.1\r\nHost: target.com\r\n\r\n' caido-cli raw -f request.txt --host target.com --port 8443 echo -n 'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n' | caido-cli raw - <h1>Parallel requests via Replay (BAC sweeps, param fuzzing, endpoint sweeps)</h1> caido-cli batch sweep https://target.com/api/profile -t "owner=eyJ1...,cross=eyJ2...,noauth" caido-cli batch fuzz "https://target.com/api/search?q=test" -p q -v "test,test',1 OR 1=1" -H "Authorization: Bearer eyJ..." caido-cli batch ep -t eyJ... https://target.com/dashboard https://target.com/admin caido-cli batch file batch.json <h1>Browse proxy history</h1> caido-cli history caido-cli history -f 'req.host.eq:"target.com"' -n 20 <h1>Get full request/response details</h1> caido-cli request 12345 <h1>Encode/decode</h1> caido-cli encode base64 "hello world" caido-cli decode url "%3Cscript%3E" caido-cli encode hex "test"</code></pre> <h3>Commands</h3> <p>| Command | Description | |---------|-------------| | <code>status</code> | Check Caido instance health and auth token | | <code>send METHOD URL</code> | Send structured HTTP request via Replay API | | <code>raw</code> | Send raw HTTP request (argument, file with <code>-f</code>, or stdin with <code>-</code>) | | <code>batch MODE</code> | Parallel requests via Replay: <code>sweep</code> (N tokens), <code>fuzz</code> (N values), <code>ep</code> (N URLs), <code>file</code> (JSON spec) | | <code>history</code> | List proxy history with HTTPQL filtering | | <code>request ID</code> | Get full request/response by ID | | <code>encode TYPE VALUE</code> | Encode value (<code>url</code>, <code>base64</code>, <code>hex</code>) | | <code>decode TYPE VALUE</code> | Decode value (<code>url</code>, <code>base64</code>, <code>hex</code>) |</p> <h3>Global Flags</h3> <p>| Flag | Description | |------|-------------| | <code>-u, --url</code> | Caido instance URL (or set <code>CAIDO_URL</code>) | | <code>-b, --body-limit</code> | Response body byte limit (default 2000) |</p> <hr> <h2>Architecture</h2> <pre><code class="">caido-mcp-server/ cmd/ caido-mcp-server/ MCP server (stdio transport) caido-cli/ Standalone CLI internal/ auth/ OAuth device flow, static access token (CAIDO_ACCESS_TOKEN), token store, auto-refresh buildinfo/ Version resolution (ldflag or go-install module version) httputil/ HTTP parsing, fingerprinting, response diff, CRLF normalization replay/ Replay session management, cookie jar, response polling resources/ MCP read-only resources (requests, sessions, sitemap, findings) tools/ MCP tool definitions (one file per tool) testutil/ Mock GraphQL server, MCP test helpers, fixtures</code></pre> <p>The <code>cmd/</code> directory names match the installed binary names so <code>go install .../cmd/caido-mcp-server@latest</code> produces a correctly-named binary. Both commands share <code>internal/</code> packages. The project uses <a href="https://github.com/caido-community/sdk-go" target="_blank" rel="noopener">caido-community/sdk-go</a> for all GraphQL communication with Caido.</p> <hr> <h2>Troubleshooting</h2> <p>| Error | Fix | |-------|-----| | <code>Invalid token</code> | <code>CAIDO_ACCESS_TOKEN</code> must be the local Caido <strong>access token</strong> (not a Cloud PAT) — re-grab it from the GUI console, or run <code>caido-mcp-server login</code> again | | <code>token expired, no refresh token</code> | The static access token expires after ~7 days; re-grab it into <code>CAIDO_ACCESS_TOKEN</code>, or use <code>caido-mcp-server login</code> (OAuth auto-refreshes) | | <code>poll failed: timed out</code> | Target server slow; use <code>get_replay_entry</code> with the returned <code>entryId</code> | | <code>no authentication token found</code> | Set <code>CAIDO_ACCESS_TOKEN</code> env var or run <code>caido-mcp-server login</code> before <code>serve</code> |</p> <p>MCP server logs: <code>~/.cache/claude-cli-nodejs/*/mcp-logs-caido/</code></p> <hr> <h2>Security</h2> <p>Sensitive HTTP headers (Authorization, Cookie, Set-Cookie, API keys) are redacted everywhere output leaves the server - structured tool output, raw request/response dumps, fuzz templates, and the <code>caido://requests/{id}</code> resource all pass through a single redaction choke-point to prevent credential leakage to LLM context. On an authorized engagement you can opt out with <code>CAIDO_ALLOW_SENSITIVE_HEADERS</code> (see <a href="#revealing-sensitive-headers" target="_blank" rel="noopener">Revealing sensitive headers</a>). All string inputs are length-validated server-side, and request batch sizes are capped.</p> <p>Access tokens (via <code>CAIDO_ACCESS_TOKEN</code>) and OAuth tokens are stored with 0600 permissions and never appear in process arguments or log output.</p> <p>To report a security issue, open a GitHub issue or contact the maintainer directly.</p> <hr> <h2>Contributing</h2> <ol><li>Fork the repo</li> <li>Create a feature branch</li> <li><code>go build ./...</code> and <code>go test ./... -race</code></li> <li>Open a PR (CI runs build, test, vet, staticcheck)</li></ol> <h3>Local pre-push gate</h3> <p>A tracked <code>.githooks/pre-push</code> runs the same checks as CI's <code>lint</code> + <code>test</code> jobs (gofmt, <code>go vet</code>, <code>golangci-lint run ./...</code>, <code>go test ./... -race</code>) so failures are caught before they reach CI. Enable it once per clone:</p> <pre><code class="">git config core.hooksPath .githooks go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2</code></pre> <p>Built with <a href="https://github.com/caido-community/sdk-go" target="_blank" rel="noopener">caido-community/sdk-go</a> and <a href="https://github.com/modelcontextprotocol/go-sdk" target="_blank" rel="noopener">modelcontextprotocol/go-sdk</a>.</p> <h2>License</h2> <p><a href="LICENSE" target="_blank" rel="noopener">MIT</a></p> </article> <!-- Article Completion Section --> <div class="reader-footer mt-5 pt-5"> <div class="reader-completion-box rounded-3 p-4 text-center"> <div class="reader-completion-icon mb-3"> <i class="fas fa-check-circle"></i> </div> <p class="reader-completion-text fw-semibold mb-1" style="font-size: 1rem;">You finished the article!</p> <p class="reader-completion-subtext small mb-4">Want to go deeper or explore more stories?</p> <div class="d-flex justify-content-center gap-3 flex-wrap"> <a href="https://github.com/c0tton-fluff/caido-mcp-server" target="_blank" rel="noopener noreferrer" class="btn btn-primary-custom btn-sm rounded-pill px-4"> <i class="fas fa-external-link-alt me-2"></i>Full Article on GitHub Trending </a> <a href="https://neshdevtech.com/news" class="reader-back-btn btn btn-sm rounded-pill px-4"> <i class="fas fa-newspaper me-2"></i>More Tech News </a> </div> </div> </div> <!-- Internal Linking CTA --> </div> </div> </div> <!-- Sidebar (desktop only) --> <div class="col-lg-4 col-xl-3 d-none d-lg-block"> <div class="sticky-top" style="top: 80px;"> <!-- Mini Controller Widget --> <div class="card border-0 shadow-sm mb-4 bg-surface"> <div class="card-body p-4"> <h6 class="fw-bold mb-3"><i class="fas fa-glasses me-2 text-primary-custom"></i>Reading Mode</h6> <p class="text-secondary-custom small mb-4">Adjust typography and theme to your preference.</p> <div class="mb-3"> <label class="text-muted-custom small d-block mb-2">Typography</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-serif">Serif</button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-sans">Sans</button> </div> </div> <div class="mb-4"> <label class="text-muted-custom small d-block mb-2">Text Scale</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-dec"><i class="fas fa-minus small"></i></button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-inc"><i class="fas fa-plus small"></i></button> </div> </div> <div class="border-top border-light-custom pt-3"> <a href="https://github.com/c0tton-fluff/caido-mcp-server" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary btn-sm w-100 rounded-pill"> View Original <i class="fas fa-external-link-alt ms-1"></i> </a> </div> </div> </div> <!-- Sidebar Related News --> <div class="card border-0 shadow-sm bg-surface"> <div class="card-header bg-transparent border-0 pt-4 pb-0 px-4"> <h5 class="card-title fw-bold mb-0" style="font-size: 1.05rem;"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h5> </div> <div class="card-body p-0"> <div class="list-group list-group-flush rounded-0 bg-transparent"> <a href="https://neshdevtech.com/news/autoscaling-docker-containers-without-kubernetes-how-gubernator-scales-cpu-gpu-workloads-automatically-dV2Xa" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Autoscaling Docker Containers Without Kubernetes: How Gubernator Scales CPU & GPU Workloads Automatically </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>9 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/aws-retired-bedrock-access-gateway-what-moving-to-the-native-apis-actually-costs-AEBzy" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> AWS retired Bedrock Access Gateway: what moving to the native APIs actually costs </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>19 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/what-actually-runs-your-code-today-Dx1f7" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> What actually runs your code today </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/gpt-6-astra-looped-transformers-and-hidden-reasoning-Ol3Xf" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> GPT-6 Astra, Looped Transformers, and Hidden Reasoning </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Hacker News</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/spinifex-1190-nineteen-releases-later-mmuKA" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Spinifex 1.19.0: Nineteen Releases Later </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>2 days ago</span> </div> </a> </div> </div> </div> </div> </div> </div> <!-- Mobile: Related News horizontal scroll strip --> <div class="d-block d-lg-none mt-4"> <h6 class="fw-bold mb-3 px-1"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h6> <div class="related-scroll-strip"> <a href="https://neshdevtech.com/news/autoscaling-docker-containers-without-kubernetes-how-gubernator-scales-cpu-gpu-workloads-automatically-dV2Xa" class="related-scroll-card"> <p class="related-scroll-title">Autoscaling Docker Containers Without Kubernetes: How Gubernator Scale...</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>9 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/aws-retired-bedrock-access-gateway-what-moving-to-the-native-apis-actually-costs-AEBzy" class="related-scroll-card"> <p class="related-scroll-title">AWS retired Bedrock Access Gateway: what moving to the native APIs act...</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>19 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/what-actually-runs-your-code-today-Dx1f7" class="related-scroll-card"> <p class="related-scroll-title">What actually runs your code today</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/gpt-6-astra-looped-transformers-and-hidden-reasoning-Ol3Xf" class="related-scroll-card"> <p class="related-scroll-title">GPT-6 Astra, Looped Transformers, and Hidden Reasoning</p> <div class="related-scroll-meta"> <span>Hacker News</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/spinifex-1190-nineteen-releases-later-mmuKA" class="related-scroll-card"> <p class="related-scroll-title">Spinifex 1.19.0: Nineteen Releases Later</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>2 days ago</span> </div> </a> </div> </div> </div> </section> </main> <!-- Footer --> <!-- Footer --> <footer class="py-5 mt-5 position-relative overflow-hidden" style="background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-lg);"> <!-- Decorative subtle background gradients --> <div class="position-absolute" style="top: -200px; right: -200px; width: 400px; height: 400px; background: rgba(var(--primary-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="position-absolute" style="bottom: -200px; left: -200px; width: 400px; height: 400px; background: rgba(var(--accent-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="container position-relative z-3"> <div class="row g-4"> <!-- Column 1: Brand & About --> <div class="col-lg-4 col-md-6"> <div class="d-flex align-items-center mb-3"> <span class="brand-mark me-2" style="width: 42px; height: 42px;" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> </span> <span class="fs-4 fw-extrabold text-primary-custom" style="letter-spacing: -0.05em; font-family: 'JetBrains Mono', monospace;">NeshDevTech</span> </div> <p class="text-secondary-custom mb-4 pe-lg-4" style="font-size: 0.95rem; line-height: 1.6;">NeshDevTech is a professional technology firm passionate about creating innovative, high-performance web and mobile solutions.</p> <div class="d-flex gap-2"> </div> </div> <!-- Column 2: Navigation Links --> <div class="col-lg-2 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Quick Navigation</h6> <ul class="list-unstyled mb-0"> <li class="mb-2"><a href="https://neshdevtech.com" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Home</a></li> <li class="mb-2"><a href="https://neshdevtech.com/projects" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Projects</a></li> <li class="mb-2"><a href="https://neshdevtech.com/blog" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Tech Blog</a></li> <li class="mb-2"><a href="https://neshdevtech.com/pricing" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Get Quote</a></li> <li class="mb-2"><a href="https://neshdevtech.com/contact" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Contact Me</a></li> </ul> </div> <!-- Column 3: Tech Stack & Services --> <div class="col-lg-3 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Core Services</h6> <ul class="list-unstyled mb-0"> <li class="text-secondary-custom mb-2 small"><i class="bi bi-rocket-takeoff text-primary-custom me-2"></i>Custom Laravel Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-laptop text-primary-custom me-2"></i>SPA (React & Vue.js)</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-phone-vibrate text-primary-custom me-2"></i>Flutter Mobile Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-search text-primary-custom me-2"></i>SEO & Performance Opt</li> </ul> </div> <!-- Column 4: Contact & Availability --> <div class="col-lg-3 col-md-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Get in Touch</h6> <ul class="list-unstyled mb-3"> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-geo-alt text-primary-custom me-2 fs-6"></i>Nairobi, Kenya </li> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-envelope-at text-primary-custom me-2 fs-6"></i> <a href="mailto:info@neshdevtech.com" class="text-secondary-custom text-decoration-none footer-link">info@neshdevtech.com</a> </li> </ul> <div class="bg-primary-custom-light border border-primary-custom border-opacity-10 p-3 rounded-3" style="border-radius: 12px !important;"> <small class="d-block fw-semibold text-primary-custom mb-1"><i class="bi bi-patch-check-fill me-1"></i>Available for Projects</small> <p class="mb-0 text-secondary-custom small" style="font-size: 0.8rem; line-height: 1.4;">Need a high-performing system? Let's discuss your project.</p> </div> </div> </div> <hr style="border-color: var(--border); opacity: 0.8; margin: 2.5rem 0 2rem;"> <div class="row align-items-center"> <div class="col-md-6 text-center text-md-start mb-2 mb-md-0"> <p class="mb-0 text-secondary-custom small">© 2026 NeshDevTech. All rights reserved.</p> </div> <div class="col-md-6 text-center text-md-end"> <p class="mb-0 text-secondary-custom small">Built with <i class="bi bi-heart-fill text-danger"></i></p> </div> </div> </div> </footer> <!-- Bootstrap JS --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <!-- Custom JavaScript --> <script> // Theme Management System class ThemeManager { constructor() { this.themeToggle = document.getElementById('themeToggle'); this.themeIcon = document.getElementById('theme-icon'); this.mobileThemeToggle = document.getElementById('mobileThemeToggle'); this.mobileThemeIcon = document.getElementById('mobile-theme-icon'); this.init(); } init() { // Get current theme (already applied in head) const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; // Update icons to match current theme this.updateIcon(currentTheme); // Listen for desktop theme toggle clicks if (this.themeToggle) { this.themeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for mobile theme toggle clicks if (this.mobileThemeToggle) { this.mobileThemeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { this.setTheme(e.matches ? 'dark' : 'light'); } }); } setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme); if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } localStorage.setItem('theme', theme); this.updateThemeColor(theme); this.updateIcon(theme); } updateThemeColor(theme) { document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => { meta.setAttribute('content', theme === 'dark' ? '#1c2028' : '#e4e8f0'); }); } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; this.setTheme(newTheme); // Close mobile menu if open const navbarCollapse = document.querySelector('.navbar-collapse'); if (navbarCollapse && navbarCollapse.classList.contains('show') && window.innerWidth < 992) { setTimeout(() => { const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); }, 150); } } updateIcon(theme) { const iconClass = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; // Update desktop icon if (this.themeIcon) { this.themeIcon.className = iconClass; } // Update mobile icon if (this.mobileThemeIcon) { this.mobileThemeIcon.className = iconClass; } } } // Initialize theme manager when DOM is loaded document.addEventListener('DOMContentLoaded', () => { new ThemeManager(); // Initialize navbar live clock (function initNavClock(){ const clockEls = Array.from(document.querySelectorAll('[data-clock]')); if (!clockEls.length) return; const locale = navigator.language || 'en-US'; // Detect 12/24h by locale: use resolvedOptions hourCycle when available const dtf = new Intl.DateTimeFormat(locale, { hour: 'numeric' }); const uses12h = (dtf.resolvedOptions().hour12 === true); const fmt = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: uses12h }); const update = () => { const now = new Date(); const text = fmt.format(now); clockEls.forEach(el => { el.textContent = text; }); }; update(); setInterval(update, 1000); })(); }); // Smooth scrolling for anchor links document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); // Navbar background on scroll window.addEventListener('scroll', () => { const navbar = document.querySelector('.navbar'); if (window.scrollY > 50) { navbar.style.backdropFilter = 'blur(20px)'; navbar.style.backgroundColor = 'rgba(var(--background-rgb), 0.8)'; } else { navbar.style.backdropFilter = 'blur(10px)'; navbar.style.backgroundColor = 'var(--background)'; } }); // Add fade-in animation to elements when they come into view const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('animate-fade-in-up'); } }); }, observerOptions); // Observe elements with animation class document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.animate-on-scroll').forEach(el => { observer.observe(el); }); }); // Enhanced mobile menu functionality document.addEventListener('DOMContentLoaded', function() { const navbarToggler = document.querySelector('.navbar-toggler'); const navbarCollapse = document.querySelector('.navbar-collapse'); const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); const socialLinks = document.querySelectorAll('.navbar-nav .d-flex a'); let isToggling = false; // Prevent rapid clicking // Enhanced menu close function function closeMenu() { if (navbarCollapse && navbarCollapse.classList.contains('show')) { // Use Bootstrap's collapse API for smooth transition const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); } // Reset hamburger animation and aria-expanded if (navbarToggler) { navbarToggler.classList.remove('active'); navbarToggler.setAttribute('aria-expanded', 'false'); } } // Enhanced hamburger toggle functionality if (navbarToggler) { navbarToggler.addEventListener('click', function(e) { e.preventDefault(); // Prevent rapid clicking if (isToggling) return; isToggling = true; const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen) { closeMenu(); } else { // Open menu const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.show(); navbarToggler.setAttribute('aria-expanded', 'true'); } // Reset toggle flag after animation setTimeout(() => { isToggling = false; }, 350); }); } // Close menu when clicking outside document.addEventListener('click', function(event) { const isClickInsideNav = navbarCollapse.contains(event.target); const isToggler = navbarToggler.contains(event.target); const isMenuOpen = navbarCollapse.classList.contains('show'); if (!isClickInsideNav && !isToggler && isMenuOpen && window.innerWidth < 992) { closeMenu(); } }); // Close menu when clicking on navigation links (mobile) navLinks.forEach(function(link) { link.addEventListener('click', function(e) { const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen && window.innerWidth < 992 && !this.classList.contains('dropdown-toggle')) { // Add a small delay to let the link navigation start setTimeout(() => { closeMenu(); }, 100); } }); }); // Close menu when clicking on social links (mobile) socialLinks.forEach(function(link) { link.addEventListener('click', function() { if (window.innerWidth < 992) { setTimeout(() => { closeMenu(); }, 100); } }); }); // Escape key closes menu document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && window.innerWidth < 992) { closeMenu(); } }); // Handle window resize to close menu when switching to desktop window.addEventListener('resize', function() { if (window.innerWidth >= 992) { closeMenu(); } }); // Theme toggle functionality is handled by ThemeManager class // No separate event listener needed here // Prevent menu items from being too small on touch devices if ('ontouchstart' in window) { navLinks.forEach(function(link) { link.style.minHeight = '44px'; link.style.display = 'flex'; link.style.alignItems = 'center'; }); } // Add smooth scrolling for anchor links within pages navLinks.forEach(function(link) { const href = link.getAttribute('href'); if (href && href.startsWith('#')) { link.addEventListener('click', function(e) { e.preventDefault(); const target = document.querySelector(href); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } closeMenu(); }); } }); }); </script> <script> // Sticky navbar scroll enhancement (desktop only) (function () { const navbar = document.querySelector('.navbar'); if (!navbar) return; let ticking = false; const SCROLL_THRESHOLD = 20; // px function updateNavbar() { if (window.innerWidth >= 992) { if (window.scrollY > SCROLL_THRESHOLD) { navbar.classList.add('scrolled'); } else { navbar.classList.remove('scrolled'); } } else { // Always remove on mobile so it doesn't affect mobile look navbar.classList.remove('scrolled'); } ticking = false; } window.addEventListener('scroll', function () { if (!ticking) { requestAnimationFrame(updateNavbar); ticking = true; } }, { passive: true }); window.addEventListener('resize', updateNavbar, { passive: true }); // Run once on page load in case page is already scrolled (e.g. back-navigation) updateNavbar(); })(); </script> <script> // Lightweight toast badge notifications (function(){ function ensureContainer(){ let c = document.getElementById('toast-badge-container'); if(!c){ c = document.createElement('div'); c.id = 'toast-badge-container'; c.setAttribute('aria-live','polite'); c.setAttribute('aria-atomic','true'); document.body.appendChild(c); } return c; } function iconFor(type){ switch(type){ case 'success': return '<i class="bi bi-check-circle"></i>'; case 'warning': return '<i class="bi bi-exclamation-triangle"></i>'; case 'error': return '<i class="bi bi-x-circle"></i>'; default: return '<i class="bi bi-info-circle"></i>'; } } window.notifyBadge = function(message, opts={}){ const { type = 'success', timeout = 2500 } = opts; const container = ensureContainer(); const el = document.createElement('div'); el.className = 'toast-badge ' + type; el.setAttribute('role','status'); el.innerHTML = '<span class="icon">'+iconFor(type)+'</span><span>'+message+'</span>'; container.appendChild(el); requestAnimationFrame(()=>{ el.classList.add('show'); }); const remove = () => { el.classList.remove('show'); setTimeout(()=> el.remove(), 200); }; setTimeout(remove, timeout); return { remove }; }; // Global copy helper used by inline buttons window.copyToClipboard = function(url){ try { navigator.clipboard.writeText(url).then(()=>{ // Update the clicked button, if available let btn = document.activeElement && document.activeElement.tagName === 'BUTTON' ? document.activeElement : (window.event && window.event.target && window.event.target.closest ? window.event.target.closest('button') : null); if (btn){ const original = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check me-1"></i> Copied!'; btn.classList.remove('btn-outline-secondary'); btn.classList.add('btn-success'); setTimeout(()=>{ btn.innerHTML = original; btn.classList.remove('btn-success'); btn.classList.add('btn-outline-secondary'); }, 2000); } notifyBadge('Link copied to clipboard', { type: 'success' }); }).catch((err)=>{ console.error('Clipboard error:', err); notifyBadge('Could not copy link', { type: 'error' }); }); } catch (e) { console.error(e); notifyBadge('Copy not supported', { type: 'error' }); } }; })(); </script> <a href="https://wa.me/254768033943?text=Hi%2C+I+visited+your+website+and+would+like+to+inquire+about+your+services." class="whatsapp-btn-float" target="_blank" rel="noopener noreferrer" aria-label="Chat on WhatsApp"> <i class="fab fa-whatsapp"></i> <span class="whatsapp-tooltip">Chat with me</span> </a> <style> .whatsapp-btn-float { position: fixed; bottom: 24px; right: 24px; z-index: 1050; display: flex; align-items: center; justify-content: center; background-color: #25d366; color: #fff !important; width: 60px; height: 60px; border-radius: 50%; box-shadow: 0 4px 16px rgba(37, 211, 102, 0.45); text-decoration: none; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .whatsapp-btn-float i { font-size: 32px; transition: transform 0.3s ease; } .whatsapp-btn-float:hover { background-color: #128c7e; transform: scale(1.1); box-shadow: 0 6px 20px rgba(18, 140, 126, 0.6); } .whatsapp-btn-float:hover i { transform: rotate(10deg); } /* Pulsing ring animation */ .whatsapp-btn-float::before { content: ''; position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: #25d366; opacity: 0.7; z-index: -1; animation: whatsapp-pulse 2s infinite; } @keyframes whatsapp-pulse { 0% { transform: scale(1); opacity: 0.7; } 100% { transform: scale(1.6); opacity: 0; } } /* Tooltip style utilizing site-wide theme colors */ .whatsapp-btn-float .whatsapp-tooltip { position: absolute; right: 76px; background-color: var(--surface); color: var(--text-primary); border: 1px solid var(--border); padding: 8px 14px; border-radius: 20px; font-size: 14px; font-weight: 600; white-space: nowrap; opacity: 0; visibility: hidden; transform: translateX(10px); transition: all 0.3s ease; box-shadow: var(--shadow); pointer-events: none; } .whatsapp-btn-float:hover .whatsapp-tooltip { opacity: 1; visibility: visible; transform: translateX(0); } /* Responsive Design */ @media (max-width: 768px) { .whatsapp-btn-float { bottom: 16px; right: 16px; width: 50px; height: 50px; } .whatsapp-btn-float i { font-size: 26px; } .whatsapp-btn-float .whatsapp-tooltip { display: none; /* Hide tooltip on small screens to prevent overlap issues */ } } </style> <!-- Mobile More Menu (Offcanvas Bottom Sheet) - Auth actions only --> <style> /* ══════════════════════════════════════════════════════════════ NEUMORPHISM REVAMP (front-end only) Soft-extruded UI: same-tone surfaces with dual light/dark shadows. This block sits at the end of the body so it layers over every page's own styles without touching page markup. ══════════════════════════════════════════════════════════════ */ /* ── Buttons: extruded, press-in on click ─────────────────────── */ .btn { box-shadow: var(--shadow) !important; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important; } .btn:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .btn:active:not(.btn-link), .btn.show { transform: translateY(1px) scale(0.99) !important; box-shadow: var(--shadow-inset) !important; } .btn-primary-custom, .btn-primary, .btn-success { background: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow) !important; } .btn-primary-custom:hover, .btn-primary:hover, .btn-success:hover { background: var(--primary-dark) !important; border-color: var(--primary-dark) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-primary-custom, .btn-outline-primary, .btn-outline-success, .btn-outline-secondary, .btn-outline-danger, .btn-outline-dark, .btn-light { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow) !important; } .btn-outline-primary-custom:hover, .btn-outline-primary:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-success:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-secondary:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-danger:hover { background-color: #ef4444 !important; border-color: #ef4444 !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-dark:hover, .btn-light:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: var(--background) !important; box-shadow: var(--shadow-md) !important; } .btn-dark { background-color: var(--text-primary) !important; border-color: var(--text-primary) !important; color: var(--background) !important; box-shadow: var(--shadow) !important; } /* ── Forms: pressed-in surfaces ────────────────────────────────── */ .form-control, .form-select { background-color: var(--surface) !important; border: 1px solid transparent !important; color: var(--text-primary) !important; border-radius: 14px !important; box-shadow: var(--shadow-inset) !important; transition: box-shadow 0.3s ease, background-color 0.25s ease !important; } .form-control:focus, .form-select:focus { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow-inset), 0 0 0 4px rgba(var(--primary-rgb), 0.15) !important; } .form-control::placeholder { color: var(--text-muted) !important; } .input-group-text { background-color: var(--surface-alt) !important; border: 1px solid transparent !important; color: var(--text-secondary) !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Navbar: floating neumorphic pill (tablet & desktop) ──────── */ .navbar { background-color: rgba(var(--background-rgb), 0.9) !important; border-bottom: 1px solid transparent !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; box-shadow: var(--shadow-md) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-md) !important; } @media (min-width: 768px) { .navbar { top: 14px !important; left: 18px !important; right: 18px !important; width: auto !important; border-radius: 20px !important; padding: 0.6rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-lg) !important; } .navbar.scrolled { background-color: rgba(var(--background-rgb), 0.95) !important; border-bottom-color: transparent !important; padding: 0.5rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar.scrolled { background-color: rgba(28, 32, 40, 0.95) !important; box-shadow: var(--shadow-lg) !important; } body { padding-top: 96px !important; } .reading-progress-container { top: 88px !important; } } /* ── Tablet refinements (768px - 991.98px) ────────────────────── */ @media (min-width: 768px) and (max-width: 991.98px) { /* Hamburger dropdown panel blends with the floating pill */ .navbar-collapse { border-radius: 20px !important; border-color: transparent !important; } body { padding-bottom: 0 !important; } .whatsapp-btn-float { bottom: 24px !important; } /* Hero: tighten the inline 80px top padding that assumed a full-height bar, so tablets don't get excessive empty space */ .hero-section-bg { padding-top: 40px !important; } } /* ── Neumorphic brand icon mark ────────────────────────────────── */ .brand-mark { width: 40px; height: 40px; flex-shrink: 0; border-radius: 13px; background-color: var(--surface) !important; color: var(--primary-color) !important; display: inline-flex; align-items: center; justify-content: center; box-shadow: var(--shadow) !important; transition: box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .brand-mark svg { width: 21px; height: 21px; } a:hover .brand-mark { box-shadow: var(--shadow-inset-sm) !important; transform: translateY(1px); } .mobile-logo .brand-mark { width: 34px; height: 34px; border-radius: 11px; } .mobile-logo .brand-mark svg { width: 17px; height: 17px; } /* ── Uploaded brand logo image ─────────────────────────────────── */ .brand-img { height: 38px; width: auto; max-width: 160px; object-fit: contain; flex-shrink: 0; } .brand-img-sm { height: 28px; max-width: 120px; } .brand-img-footer { height: 44px; max-width: 180px; } /* ── Mobile bars: floating neumorphic pills (phones) ──────────── */ @media (max-width: 767.98px) { .mobile-top-bar { top: 8px !important; left: 12px !important; right: 12px !important; height: 52px !important; border-radius: 16px !important; border-bottom: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } .mobile-bottom-bar { left: 12px !important; right: 12px !important; bottom: 12px !important; height: 58px !important; border-radius: 18px !important; border-top: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } body { padding-top: 74px !important; padding-bottom: 86px !important; } .whatsapp-btn-float { bottom: 88px !important; } .reading-progress-container { top: 70px !important; } } /* ── Nav pill widgets: pressed-in ──────────────────────────────── */ .theme-toggle, .mobile-theme-toggle, .nav-clock { background-color: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Icon chips, markers & circles: extruded ───────────────────── */ .icon-wrap, .icon-wrapper, .expertise-card-icon, .timeline-marker, .mobile-more-icon-wrapper, .social-circle, .value-icon, .skill-icon-box { box-shadow: var(--shadow) !important; border-color: transparent !important; } /* ── Panels: extruded ──────────────────────────────────────────── */ .accordion-item { border-color: transparent !important; box-shadow: var(--shadow) !important; } .timeline-content { background: var(--surface) !important; border-radius: 12px !important; padding: 1rem 1.25rem !important; box-shadow: var(--shadow) !important; } .feature-item { background: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow) !important; } .feature-item:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .progress { background-color: var(--surface-alt) !important; box-shadow: var(--shadow-inset-sm) !important; } /* Card hover: consistent soft neu shadow */ .card-hover:hover, .project-card:hover, .blog-card:hover, .post-card:hover, .news-card:hover, .testimonial-card:hover, .hover-lift:hover, .stats-item:hover { box-shadow: var(--shadow-lg) !important; } </style> <script> document.addEventListener('DOMContentLoaded', function() { const readerCard = document.getElementById('reader-card'); if (!readerCard) return; // Settings Key constants const THEME_KEY = 'reader_mode_theme'; const FONT_KEY = 'reader_mode_font'; const SIZE_KEY = 'reader_mode_size'; // Available Options const themes = ['light', 'sepia', 'dark', 'oled']; const fonts = ['serif', 'sans']; const sizes = ['xs', 'sm', 'md', 'lg', 'xl']; // Default Configs const siteTheme = document.documentElement.getAttribute('data-theme') || 'light'; const defaultTheme = siteTheme === 'dark' ? 'dark' : 'light'; let currentTheme = localStorage.getItem(THEME_KEY) || defaultTheme; let currentFont = localStorage.getItem(FONT_KEY) || 'serif'; let currentSize = localStorage.getItem(SIZE_KEY) || 'md'; // Apply initial config applySettings(); // Register Action Listeners (Toolbar) document.getElementById('btn-font-serif').addEventListener('click', () => setFont('serif')); document.getElementById('btn-font-sans').addEventListener('click', () => setFont('sans')); document.getElementById('btn-size-decrease').addEventListener('click', () => adjustSize(-1)); document.getElementById('btn-size-increase').addEventListener('click', () => adjustSize(1)); document.getElementById('theme-light').addEventListener('click', () => setTheme('light')); document.getElementById('theme-sepia').addEventListener('click', () => setTheme('sepia')); document.getElementById('theme-dark').addEventListener('click', () => setTheme('dark')); document.getElementById('theme-oled').addEventListener('click', () => setTheme('oled')); // Register Action Listeners (Sidebar widgets, if present) const sidebarSerif = document.getElementById('sidebar-font-serif'); const sidebarSans = document.getElementById('sidebar-font-sans'); const sidebarSizeDec = document.getElementById('sidebar-size-dec'); const sidebarSizeInc = document.getElementById('sidebar-size-inc'); if (sidebarSerif) sidebarSerif.addEventListener('click', () => setFont('serif')); if (sidebarSans) sidebarSans.addEventListener('click', () => setFont('sans')); if (sidebarSizeDec) sidebarSizeDec.addEventListener('click', () => adjustSize(-1)); if (sidebarSizeInc) sidebarSizeInc.addEventListener('click', () => adjustSize(1)); // Action Core Functions function setTheme(theme) { currentTheme = theme; localStorage.setItem(THEME_KEY, theme); applySettings(); } function setFont(font) { currentFont = font; localStorage.setItem(FONT_KEY, font); applySettings(); } function adjustSize(delta) { let index = sizes.indexOf(currentSize); index = Math.max(0, Math.min(sizes.length - 1, index + delta)); currentSize = sizes[index]; localStorage.setItem(SIZE_KEY, currentSize); applySettings(); } function applySettings() { // 1. Reset theme classes and apply selected themes.forEach(t => readerCard.classList.remove('reader-theme-' + t)); readerCard.classList.add('reader-theme-' + currentTheme); // Update Theme circles highlight themes.forEach(t => { const circle = document.getElementById('theme-' + t); if (circle) { if (t === currentTheme) { circle.classList.add('active'); } else { circle.classList.remove('active'); } } }); // 2. Reset Font classes and apply selected fonts.forEach(f => readerCard.classList.remove('reader-font-' + f)); readerCard.classList.add('reader-font-' + currentFont); // Highlight Font buttons const serifBtns = [document.getElementById('btn-font-serif'), document.getElementById('sidebar-font-serif')]; const sansBtns = [document.getElementById('btn-font-sans'), document.getElementById('sidebar-font-sans')]; serifBtns.forEach(btn => { if (btn) { if (currentFont === 'serif') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); sansBtns.forEach(btn => { if (btn) { if (currentFont === 'sans') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); // 3. Reset Sizing classes and apply selected sizes.forEach(s => readerCard.classList.remove('reader-size-' + s)); readerCard.classList.add('reader-size-' + currentSize); } // Reading Scroll progress calculation const progressBar = document.getElementById('reading-progress-bar'); window.addEventListener('scroll', () => { const documentHeight = document.documentElement.scrollHeight - window.innerHeight; if (documentHeight > 0) { const scrollProgress = (window.pageYOffset / documentHeight) * 100; progressBar.style.width = scrollProgress + '%'; } }); }); </script> <script> // ══════════════════════════════════════════════════════════════ // PWA: Service Worker registration + install prompt // ══════════════════════════════════════════════════════════════ (function () { const PWA_VERSION = '20260805-1'; // ---- Service worker ------------------------------------ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js?v=' + PWA_VERSION).then((reg) => { // Apply updates (new deploy) on the next load without disruption reg.addEventListener('updatefound', () => { const newWorker = reg.installing; if (!newWorker) return; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { newWorker.postMessage({ type: 'SKIP_WAITING' }); } }); }); }).catch(() => {}); }); } // ---- Install prompt -------------------------------------- let deferredPrompt = null; const bannerId = 'pwa-install-banner'; const DISMISS_KEY = 'nesh-install-dismissed'; function showInstallBanner() { if (localStorage.getItem(DISMISS_KEY) || document.getElementById(bannerId)) return; const banner = document.createElement('div'); banner.id = bannerId; banner.setAttribute('role', 'dialog'); banner.setAttribute('aria-label', 'Install app'); banner.innerHTML = '<div class="pwa-install-content">' + '<span class="pwa-install-icon">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' + '</span>' + '<div class="pwa-install-text">' + '<strong>Install NeshDevTech</strong>' + '<span>Get the app on your device</span>' + '</div>' + '<button type="button" class="pwa-install-btn" id="pwa-install-btn">Install</button>' + '<button type="button" class="pwa-install-close" id="pwa-install-close" aria-label="Dismiss">×</button>' + '</div>'; document.body.appendChild(banner); document.getElementById('pwa-install-btn').addEventListener('click', () => { if (!deferredPrompt) return; deferredPrompt.prompt(); deferredPrompt.userChoice.finally(() => { deferredPrompt = null; hideInstallBanner(); }); }); document.getElementById('pwa-install-close').addEventListener('click', () => { localStorage.setItem(DISMISS_KEY, '1'); hideInstallBanner(); }); requestAnimationFrame(() => banner.classList.add('show')); } function hideInstallBanner() { const banner = document.getElementById(bannerId); if (banner) { banner.classList.remove('show'); setTimeout(() => banner.remove(), 250); } } window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; showInstallBanner(); }); window.addEventListener('appinstalled', () => { deferredPrompt = null; hideInstallBanner(); localStorage.setItem(DISMISS_KEY, '1'); }); })(); </script> <style> .pwa-install-content { display: flex; align-items: center; gap: 12px; padding: 14px 18px; } #pwa-install-banner { position: fixed; left: 50%; transform: translate(-50%, 20px); bottom: 84px; z-index: 1070; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); opacity: 0; transition: opacity .25s cubic-bezier(.16, 1, .3, 1), transform .25s cubic-bezier(.16, 1, .3, 1); width: min(420px, calc(100vw - 32px)); } #pwa-install-banner.show { opacity: 1; transform: translate(-50%, 0); } .pwa-install-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: 0 0 40px; border-radius: 12px; background: rgba(var(--primary-rgb), 0.1); border: 1px solid rgba(var(--primary-rgb), 0.2); color: var(--primary-color); } .pwa-install-icon svg { width: 20px; height: 20px; } .pwa-install-text { display: flex; flex-direction: column; flex: 1; min-width: 0; } .pwa-install-text strong { font-size: 0.92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pwa-install-text span { font-size: 0.78rem; color: var(--text-secondary); } .pwa-install-btn { background: var(--primary-color); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 8px 18px; border-radius: 9999px; cursor: pointer; white-space: nowrap; box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25); transition: all .2s ease; } .pwa-install-btn:hover { background: var(--primary-dark); transform: translateY(-1px); } .pwa-install-close { background: transparent; border: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 50%; transition: background-color .2s ease, color .2s ease; } .pwa-install-close:hover { background: var(--surface-alt); color: var(--text-primary); } @media (min-width: 992px) { #pwa-install-banner { bottom: 24px; } } </style> </div><!-- /.page-wrapper --> </body> </html>