diff --git a/AGENTS.md b/AGENTS.md index b0f455f..4079dbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,7 @@ When the user mentions these keywords, load the corresponding skill: | "langgraph", "multi-agent", "state machine", "graph-based workflow", "LangGraph", "supervisor pattern", "swarm pattern", "agent orchestration", "graph state", "subgraph", "agent routing", "tool-calling loop", "agent loop", "stateful agent", "durable execution", "human in the loop langgraph", "checkpointer", "langgraph persistence" | [langgraph](langgraph/SKILL.md) | | "debate", "council", "multi-perspective", "structured debate", "get multiple perspectives", "expert panel", "decision landscape", "what would experts say", "what are we missing", "convergence", "false consensus", "agent-council", "pre-mortem" | [agent-council](agent-council/SKILL.md) | | "skill format", "how do I make a skill", "agentskills.io" | [agent-skills](agent-skills/SKILL.md) | +| "Fireflies", "Fireflies.ai", "meeting transcripts", "meeting notes", "audio upload", "webhook verification", "AskFred", "AI meeting analytics" | [fireflies](fireflies/SKILL.md) | | "flaresolverr-cli", "FlareSolverr session", "request.get", "request.post", "return-only-cookies" | [flaresolverr-cli](flaresolverr-cli/SKILL.md) | | "Cloudflare challenge", "DDoS-GUARD", "browser-backed request" | [flaresolverr](flaresolverr/SKILL.md) | | "Three.js", "three.js", "WebGL", "3D scene", "GLTF", "browser 3D" | [three](three/SKILL.md) | diff --git a/README.md b/README.md index 6ee13b4..3fe01ea 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,17 @@ fixed-layout, accessibility, and media overlays. Portable across any AgentSkills Build and review assumptions-led financial models, unit economics, pricing, fundraising scenarios, and SaaS operating metrics. +### [fireflies](fireflies/SKILL.md) + +Fireflies.ai meeting intelligence from the terminal. Query transcripts, summaries, analytics, AskFred, and workspace data through source-faithful GraphQL documents; safely preview confirmed mutations and verify Webhooks V2 signatures locally. + ### [flaresolverr](flaresolverr/SKILL.md) Use a private FlareSolverr service through a dependency-free JSON CLI when ordinary HTTP retrieval is blocked by a browser challenge. ### [flaresolverr-cli](flaresolverr-cli/SKILL.md) -Use the full stdlib FlareSolverr CLI for health checks, browser-session lifecycle, and challenge-solving GET or form-POST requests with structured output and dry-run support. +Use a small FlareSolverr JSON CLI for browser-backed GET and POST requests, readiness checks, and session lifecycle management. ### [forgejo-cli](forgejo-cli/SKILL.md) diff --git a/fireflies/README.md b/fireflies/README.md new file mode 100644 index 0000000..ed6e7d4 --- /dev/null +++ b/fireflies/README.md @@ -0,0 +1,54 @@ +# Fireflies.ai: meeting intelligence from the terminal + +## Why Install This Skill + +Turn Fireflies meetings into usable data without hand-copying notes. Search transcripts, inspect +summaries and action items, review analytics, and ask focused questions with AskFred from a single +dependency-free command line tool. + +It also makes sensitive operations deliberate: every mutation requires an explicit confirmation, +and every mutation can be previewed locally before it is sent. + +## What You Get + +| Path | What it provides | +|---|---| +| `scripts/fireflies` | Python 3.9+ GraphQL CLI with safe reads, mutations, dry-runs, and webhook verification | +| `references/api-reference.md` | API model, operation families, limits, and source links | +| `references/cli-reference.md` | Complete CLI contract and examples | +| `references/workflows.md` | Transcript, analytics, upload, AskFred, and GraphQL recipes | +| `references/webhook-security.md` | Webhooks V2 verification guidance | +| `references/troubleshooting.md` | Failure diagnosis and escalation boundaries | + +## Quick Start + +```bash +export FIREFLIES_API_KEY='...' +python3 scripts/fireflies transcripts list --keyword roadmap --limit 10 --json +``` + +Output is the Fireflies GraphQL response, for example: + +```json +{"data":{"transcripts":[{"id":"...","title":"Roadmap review"}]}} +``` + +Preview a change without calling the API: + +```bash +python3 scripts/fireflies meetings rename transcript-id --title "Q3 roadmap" --dry-run --json +``` + +## Triggers + +- Fireflies.ai transcripts, summaries, notes, contacts, channels, or analytics +- AskFred questions about meeting content +- Remote audio upload to Fireflies +- Fireflies Webhooks V2 signature verification + +## Requirements + +- Python 3.9 or newer +- A Fireflies API key in `FIREFLIES_API_KEY` for API calls +- Network access to `https://api.fireflies.ai/graphql` +- No third-party Python packages diff --git a/fireflies/SKILL.md b/fireflies/SKILL.md new file mode 100644 index 0000000..69b41d2 --- /dev/null +++ b/fireflies/SKILL.md @@ -0,0 +1,72 @@ +--- +name: fireflies +description: >- + Query Fireflies.ai meeting transcripts, meeting notes, summaries, contacts, channels, + AI meeting analytics, AskFred, audio uploads, and webhook signatures through its GraphQL API. + Use when a user mentions Fireflies, Fireflies.ai, meeting transcripts or notes stored in + Fireflies, AskFred, or Fireflies webhooks. Do not use for local audio transcription, calendar + management, or meetings that are not Fireflies data. +license: MIT +compatibility: Requires Python 3.9+, network access for API calls, and FIREFLIES_API_KEY for non-dry-run API requests. +metadata: + service: fireflies.ai + api: graphql +allowed-tools: Bash Read +--- + +# Fireflies.ai + +Use `scripts/fireflies` from this skill directory. It uses the Fireflies GraphQL endpoint directly +and emits the API response as JSON. Read-only discovery is safe. Before any state change, confirm +the target, scope, and rollback path; then route the change through the CLI's literal `--confirm`. +`--dry-run` previews a payload only and never authorizes a write. + +## First Use + +1. Check the command schema: `scripts/fireflies --help` and the relevant subcommand help. +2. Set `FIREFLIES_API_KEY` only for an actual API request. Do not expose or persist it. +3. Start with read-only discovery, such as `scripts/fireflies transcripts list --limit 10 --json`. +4. Use `--dry-run --json` before each mutation to inspect the exact GraphQL payload. + +## Command Map + +| Need | Command | +|---|---| +| Find transcript metadata | `scripts/fireflies transcripts list --keyword TEXT --limit 10 --json` | +| Read a meeting, summary, sentences, and analytics | `scripts/fireflies transcripts get ID --json` | +| People, channels, groups, contacts, apps | `users`, `channels`, `groups`, `contacts`, `apps` | +| Meeting analytics or live meetings | `analytics --start DATE --end DATE`, `meetings active` | +| Ask a transcript question | `askfred create --question TEXT --transcript-id ID --confirm` | +| Run an exact current/future API operation | `query --document GRAPHQL --variables JSON` | +| Verify a delivered webhook locally | `webhook verify --secret SECRET --signature sha256=... --body FILE` | + +## Safe Mutation Workflow + +1. Identify the Fireflies object ID and present the intended change. +2. Confirm target, scope, and rollback path with the user. Deletion may not be reversible. +3. Preview the exact document using `--dry-run`; this makes no HTTP request. +4. Run exactly the approved mutation with `--confirm`. +5. Return the response without printing credentials. + +Examples: + +```bash +scripts/fireflies meetings rename transcript-id --title "Weekly product review" --dry-run --json +scripts/fireflies meetings rename transcript-id --title "Weekly product review" --confirm --json +scripts/fireflies transcripts delete transcript-id --confirm --json +``` + +## Reference Routing + +- Read [references/cli-reference.md](references/cli-reference.md) for syntax, output, exit codes, or generic GraphQL execution. +- Read [references/api-reference.md](references/api-reference.md) for the API model, exact documented operation families, limits, permissions, and source links. +- Read [references/workflows.md](references/workflows.md) for transcript, analytics, upload, AskFred, and generic-operation recipes. +- Read [references/webhook-security.md](references/webhook-security.md) when receiving or implementing Webhooks V2. +- Read [references/troubleshooting.md](references/troubleshooting.md) after an auth, GraphQL, limit, permission, pagination, or webhook failure. +- Read [references/source-index.md](references/source-index.md) when validating source scope or documentation freshness. + +## Boundaries + +The CLI never starts a webhook server, stores API keys, or guesses undocumented GraphQL fields. +If an ergonomic command does not cover the needed current operation, use `query` or `mutation` +with a documented GraphQL document. The generic mutation command still requires `--confirm`. diff --git a/fireflies/references/api-reference.md b/fireflies/references/api-reference.md new file mode 100644 index 0000000..2b97a45 --- /dev/null +++ b/fireflies/references/api-reference.md @@ -0,0 +1,48 @@ +# Fireflies API Reference + +## Model and Auth + +Fireflies exposes GraphQL at `https://api.fireflies.ai/graphql`. Send JSON with `query`, optional +`variables`, and optional `operationName`; authenticate with `Authorization: Bearer `. +Queries are read-only. Mutations create, update, or delete server-side state. + +Use `scripts/fireflies query` and `scripts/fireflies mutation` as the compatibility escape hatch +for every documented current or future operation. The CLI rejects mutations on `query`, queries on +`mutation`, and requires `--confirm` for a non-dry-run generic mutation. + +## Documented Families + +| Family | Documented operation | +|---|---| +| Meetings | `transcripts`, `transcript`, `deleteTranscript`, `updateMeetingTitle`, `updateMeetingPrivacy`, `updateMeetingState`, `shareMeeting`, `revokeSharedMeetingAccess` | +| Workspace | `user`, `users`, `contacts`, `channels`, `channel`, `user_groups` | +| Content | `bites`, `bite`, `createBite`, `apps`, `analytics` | +| Live | `active_meetings`, `live_action_items`, `createLiveActionItem` | +| Automation | `auditEvents`, `rule_executions_by_meeting`, `uploadAudio` | +| AskFred | `askfred_threads`, `askfred_thread`, `createAskFredThread`, `continueAskFredThread`, `deleteAskFredThread` | + +The ergonomic CLI documents use conservative selections. `live add` calls +`createLiveActionItem(input: CreateLiveActionItemInput!)` with `meeting_id` and `prompt` only. +Use the generic `mutation` escape hatch for Add to Live (`addToLiveMeeting`) or any operation not +represented by an ergonomic command. Use introspection only through `schema introspect`; availability +depends on the deployment. + +## Pagination and Limits + +`transcripts` uses `limit` plus `skip`, with a documented maximum limit of 50. The CLI rejects a +higher value. Bites also support `limit` and `skip`; their list query requires one of `mine`, +`transcript_id`, or `my_team`. Respect plan limits: Free is 50 requests/day, Pro 500/day, and +Business/Enterprise 60/minute. Add to Live is 3 per 20 minutes, meeting sharing is 10/hour, and +`deleteTranscript` is 10/minute. + +## Errors and Access + +GraphQL errors are returned in `errors` and can include `message`, `code`, `friendly`, and +`extensions.helpUrls`. The CLI preserves that response on stdout in JSON mode and exits 5. +`too_many_requests` can include `retryAfter`; delay before retrying. Audit events and rule execution +logs have Enterprise/admin restrictions. Do not infer the caller's plan or authorization locally. +AskFred mutations require AI credits. + +## Primary Sources + +See [source-index.md](source-index.md) for dated primary documentation URLs and claim scope. diff --git a/fireflies/references/cli-reference.md b/fireflies/references/cli-reference.md new file mode 100644 index 0000000..470af0b --- /dev/null +++ b/fireflies/references/cli-reference.md @@ -0,0 +1,34 @@ +# CLI Reference + +Run `scripts/fireflies --help` for the authoritative interface. Global flags work before or after +subcommands: `--json`, `--api-key`, `--endpoint`, `--timeout`, `--dry-run`, `--quiet`, and `--verbose`. +`--json` writes one JSON document to stdout; diagnostics are stderr-only. + +## Commands + +| Command | Purpose | +|---|---| +| `query --document DOC [--variables JSON|--variables-file PATH]` | Generic read-only GraphQL | +| `mutation --document DOC ... --confirm` | Generic mutation | +| `transcripts list|get|delete` | Search, inspect, or delete meetings | +| `users`, `contacts`, `channels`, `groups`, `bites`, `apps` | Workspace/content reads | +| `analytics`, `meetings active`, `live-action-items` | Analytics and live data | +| `meetings rename|privacy|state|share|revoke-share` | Meeting mutations | +| `bites create`, `live add`, `audio upload` | Documented creation/upload mutations; `live add` creates a live action item from a meeting ID and prompt | +| `askfred threads|create|continue|delete` | AskFred workflow | +| `audit-events`, `rule-executions` | Enterprise/admin queries | +| `webhook verify` | Local signature check, no network | +| `schema introspect` | Explicit GraphQL introspection | + +## Examples + +```bash +scripts/fireflies query --document 'query { user { name } }' --json +scripts/fireflies transcripts list --from-date 2026-01-01 --to-date 2026-01-31 --limit 50 --json +scripts/fireflies mutation --document 'mutation X { ... }' --confirm --dry-run --json +scripts/fireflies webhook verify --secret "$WEBHOOK_SECRET" --signature sha256=... --body payload.json --json +``` + +Variables must be a JSON object, inline or from a file. `--dry-run` returns the exact endpoint and +payload and does not need an API key. Exit codes: 2 usage/input, 3 configuration, 4 transport/HTTP, +5 GraphQL errors, 6 confirmation refused. diff --git a/fireflies/references/source-index.md b/fireflies/references/source-index.md new file mode 100644 index 0000000..8c8a841 --- /dev/null +++ b/fireflies/references/source-index.md @@ -0,0 +1,23 @@ +# Source Index + +Access date: 2026-07-15. Sources are primary Fireflies documentation only. + +| Source | Relevant claim and scope | +|---|---| +| https://docs.fireflies.ai/getting-started/introduction | API introduction and GraphQL model | +| https://docs.fireflies.ai/fundamentals/authorization | Bearer authorization and API endpoint | +| https://docs.fireflies.ai/fundamentals/limits | Plan, sharing, and Add to Live limits | +| https://docs.fireflies.ai/fundamentals/errors | GraphQL `errors` response shape | +| https://docs.fireflies.ai/fundamentals/introspection | Introspection behavior | +| https://docs.fireflies.ai/graphql-api/query/transcripts | Transcript filters, pagination, and fields | +| https://docs.fireflies.ai/graphql-api/query/transcript | Single transcript fields | +| https://docs.fireflies.ai/graphql-api/query/analytics | Analytics variables and selections | +| https://docs.fireflies.ai/graphql-api/mutation/create-live-action-item | `createLiveActionItem` input and response | +| https://docs.fireflies.ai/graphql-api/mutation/delete-askfred-thread | `deleteAskFredThread` response selection | +| https://docs.fireflies.ai/graphql-api/query/audit-events | Conservative audit event query selection | +| https://docs.fireflies.ai/graphql-api/webhooks-v2 | V2 event names, HMAC signature, response deadline | +| https://docs.fireflies.ai/graphql-api/mutation/delete-transcript | `deleteTranscript` mutation and limit | +| https://docs.fireflies.ai/graphql-api/mutation/update-meeting-title | `updateMeetingTitle` input | +| https://docs.fireflies.ai/graphql-api/mutation/upload-audio | `uploadAudio` input | +| https://docs.fireflies.ai/askfred/overview | AskFred operation family and AI-credit requirement | +| https://docs.fireflies.ai/llms-full.txt | Current primary query/mutation schema documentation used for CLI documents | diff --git a/fireflies/references/troubleshooting.md b/fireflies/references/troubleshooting.md new file mode 100644 index 0000000..27b26fb --- /dev/null +++ b/fireflies/references/troubleshooting.md @@ -0,0 +1,10 @@ +# Troubleshooting + +- **Missing key:** set `FIREFLIES_API_KEY`, pass `--api-key`, or use `--dry-run`. Help and local webhook checks need no key. +- **Authentication failure:** verify `Authorization: Bearer ` and regenerate/check the key in Fireflies Integrations. +- **GraphQL error:** inspect JSON `errors`, especially `code` and `extensions.helpUrls`; do not retry malformed documents unchanged. +- **`too_many_requests`:** honor `retryAfter` when present and reduce request rate. +- **Permission or plan restriction:** audit events/rule executions can require Enterprise/admin access; AskFred mutations need AI credits. Escalate access, do not infer it. +- **Pagination:** use transcript `limit` (50 or less) and `skip`; use documented cursors for audit/rule queries. +- **Webhook verification failure:** compare the raw body, unmodified signature header, and the correct shared secret. Do not verify re-serialized JSON. +- **Safe escalation:** capture the command (without credentials), exit code, response error code, and help URL; provide these to Fireflies support. diff --git a/fireflies/references/webhook-security.md b/fireflies/references/webhook-security.md new file mode 100644 index 0000000..4f8aff5 --- /dev/null +++ b/fireflies/references/webhook-security.md @@ -0,0 +1,14 @@ +# Webhook Security + +Webhooks V2 events are `meeting.transcribed`, `meeting.summarized`, and `meeting.bot_joined`. +Fireflies sends `X-Hub-Signature: sha256=`, an HMAC-SHA256 over the exact raw request body. +Verify it before parsing or acting on the event. Use timing-safe comparison. + +```bash +scripts/fireflies webhook verify --secret "$WEBHOOK_SECRET" --signature "$X_HUB_SIGNATURE" --body request-body.json --json +``` + +Use `--body -` to read raw bytes from stdin. This command is a local verifier, never makes a +network call, and does not print the secret. A receiving endpoint should return a 2xx response +within 10 seconds. Persist or queue work after verification; handle retries idempotently and avoid +assuming delivery order. Test valid and invalid signatures before deployment. diff --git a/fireflies/references/workflows.md b/fireflies/references/workflows.md new file mode 100644 index 0000000..00916ab --- /dev/null +++ b/fireflies/references/workflows.md @@ -0,0 +1,57 @@ +# Workflows + +## List, Filter, and Read a Transcript + +Use this to locate meetings and then inspect their content. + +```bash +scripts/fireflies transcripts list --keyword roadmap --organizers owner@example.com --limit 10 --json +scripts/fireflies transcripts get transcript-id --json +``` + +The detailed query includes summary, speakers, sentences, and analytics. + +## Extract Actions and Summary + +Use this for a currently live meeting or for a completed transcript. + +```bash +scripts/fireflies live-action-items meeting-id --json +scripts/fireflies transcripts get transcript-id --json +``` + +## Team Analytics + +Use a bounded time range for trend reporting. + +```bash +scripts/fireflies analytics --start 2026-01-01 --end 2026-01-31 --json +``` + +## Upload Audio with Webhook Correlation + +Use a publicly retrievable HTTPS media URL and a webhook endpoint you control. Preview first. + +```bash +scripts/fireflies audio upload --url https://media.example/call.mp3 --webhook https://app.example/fireflies --client-reference-id import-42 --dry-run --json +scripts/fireflies audio upload --url https://media.example/call.mp3 --webhook https://app.example/fireflies --client-reference-id import-42 --confirm --json +``` + +## AskFred Conversation + +Use AskFred for natural-language analysis when the account has AI credits. + +```bash +scripts/fireflies askfred create --question 'What decisions were made?' --transcript-id transcript-id --confirm --json +scripts/fireflies askfred continue thread-id --question 'Who owns the follow-up?' --confirm --json +``` + +## Generic GraphQL + +Use this when a documented operation is newer than the CLI's ergonomic commands. Copy the document +from Fireflies primary documentation, pass variables as JSON, and preview mutations before confirmation. + +```bash +scripts/fireflies query --document 'query { user { name } }' --json +scripts/fireflies mutation --document 'mutation Example($input: SomeInput!) { someMutation(input: $input) { success } }' --variables '{"input":{}}' --dry-run --json +``` diff --git a/fireflies/scripts/fireflies b/fireflies/scripts/fireflies new file mode 100755 index 0000000..c3b9151 --- /dev/null +++ b/fireflies/scripts/fireflies @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Fireflies GraphQL CLI. Requires Python 3.9+ and no third-party packages.""" +import argparse +import hashlib +import hmac +import json +import os +import re +import sys +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +DEFAULT_ENDPOINT = "https://api.fireflies.ai/graphql" +EX_USAGE, EX_CONFIG, EX_TRANSPORT, EX_GRAPHQL, EX_CONFIRM = 2, 3, 4, 5, 6 + +DOCS = { + "transcripts_list": "query Transcripts($keyword: String, $scope: TranscriptsQueryScope, $fromDate: DateTime, $toDate: DateTime, $limit: Int, $skip: Int, $hostEmail: String, $userId: String, $mine: Boolean, $organizers: [String], $participants: [String], $channelId: String) { transcripts(keyword: $keyword, scope: $scope, fromDate: $fromDate, toDate: $toDate, limit: $limit, skip: $skip, host_email: $hostEmail, user_id: $userId, mine: $mine, organizers: $organizers, participants: $participants, channel_id: $channelId) { id title date duration organizer_email participants transcript_url } }", + "transcript": "query Transcript($id: String!) { transcript(id: $id) { id title date duration organizer_email participants transcript_url summary { overview } speakers { id name } sentences { index text speaker_name start_time end_time } analytics { sentiments { negative_pct neutral_pct positive_pct } } } }", + "users": "query Users { users { user_id email name is_admin integrations } }", + "me": "query User { user { user_id email name is_admin integrations } }", + "user": "query User($id: String!) { user(id: $id) { user_id email name is_admin integrations } }", + "contacts": "query Contacts { contacts { email name picture last_meeting_date } }", + "channels": "query Channels { channels { id title is_private created_at updated_at created_by members { user_id email name } } }", + "channel": "query Channel($id: ID!) { channel(id: $id) { id title is_private created_at updated_at created_by members { user_id email name } } }", + "groups": "query UserGroups($mine: Boolean) { user_groups(mine: $mine) { id name handle members { user_id first_name last_name email } } }", + "bites": "query Bites($mine: Boolean, $transcriptId: ID, $myTeam: Boolean, $limit: Int, $skip: Int) { bites(mine: $mine, transcript_id: $transcriptId, my_team: $myTeam, limit: $limit, skip: $skip) { id transcript_id name status summary start_time end_time } }", + "bite": "query Bite($id: ID!) { bite(id: $id) { id transcript_id name status summary start_time end_time } }", + "apps": "query GetAIAppsOutputs($appId: String, $transcriptId: String, $skip: Float, $limit: Float) { apps(app_id: $appId, transcript_id: $transcriptId, skip: $skip, limit: $limit) { outputs { transcript_id user_id app_id created_at title prompt response } } }", + "analytics": "query Analytics($startTime: String, $endTime: String) { analytics(start_time: $startTime, end_time: $endTime) { team { conversation { average_filler_words } meeting { count duration } } users { user_id user_name user_email conversation { talk_listen_pct } meeting { count duration } } } }", + "active": "query ActiveMeetings { active_meetings { id title organizer_email meeting_link start_time end_time privacy state } }", + "live_items": "query LiveActionItems($id: ID!) { live_action_items(meeting_id: $id) { name action_item } }", + "audit": "query AuditEvents($limit: Int, $cursor: String, $filters: AuditEventFiltersInput!) { auditEvents(limit: $limit, cursor: $cursor, filters: $filters) { events { id time action actor { user_id } resource { type id } } has_more next_cursor } }", + "rules": "query RuleExecutions($limit: Int, $cursor: String, $filters: RuleExecutionFiltersInput) { rule_executions_by_meeting(limit: $limit, cursor: $cursor, filters: $filters) { meetings { meeting_id meeting { id title organizer_email } executions { extension_id extension_title stopped_at user_name } } has_more next_cursor } }", + "askfred_threads": "query GetAskFredThreads { askfred_threads { id title transcript_id user_id created_at } }", + "delete": "mutation DeleteTranscript($id: String!) { deleteTranscript(id: $id) { id title organizer_email date duration } }", + "rename": "mutation UpdateMeetingTitle($input: UpdateMeetingTitleInput!) { updateMeetingTitle(input: $input) { title } }", + "privacy": "mutation UpdateMeetingPrivacy($input: UpdateMeetingPrivacyInput!) { updateMeetingPrivacy(input: $input) { id title privacy } }", + "state": "mutation UpdateMeetingState($input: UpdateMeetingStateInput!) { updateMeetingState(input: $input) { success action } }", + "share": "mutation ShareMeeting($input: ShareMeetingInput!) { shareMeeting(input: $input) { success message } }", + "revoke": "mutation RevokeSharedMeetingAccess($input: RevokeSharedMeetingAccessInput!) { revokeSharedMeetingAccess(input: $input) { success message } }", + "bite_create": "mutation CreateBite($transcriptId: ID!, $start: Float!, $end: Float!, $name: String, $type: String, $summary: String, $privacy: [String]) { createBite(transcript_id: $transcriptId, start_time: $start, end_time: $end, name: $name, media_type: $type, summary: $summary, privacies: $privacy) { id name status } }", + "live_add": "mutation CreateLiveActionItem($input: CreateLiveActionItemInput!) { createLiveActionItem(input: $input) { success } }", + "upload": "mutation UploadAudio($input: AudioUploadInput) { uploadAudio(input: $input) { success title message } }", + "askfred_create": "mutation CreateThread($input: CreateAskFredThreadInput!) { createAskFredThread(input: $input) { message { id thread_id query answer suggested_queries status created_at } } }", + "askfred_continue": "mutation ContinueThread($input: ContinueAskFredThreadInput!) { continueAskFredThread(input: $input) { message { id thread_id query answer suggested_queries status created_at } } }", + "askfred_delete": "mutation DeleteThread($id: String!) { deleteAskFredThread(id: $id) { id title transcript_id user_id created_at } }", + "introspection": "query IntrospectionQuery { __schema { queryType { name } mutationType { name } types { name kind } } }", +} + +def fail(message, code=EX_USAGE): + print("Error: " + message, file=sys.stderr) + raise SystemExit(code) + +def json_value(value, label): + try: return json.loads(value) + except json.JSONDecodeError as exc: fail(f"{label} must be valid JSON: {exc}") + +def globals_anywhere(argv): + values = {"json": False, "dry_run": False, "quiet": False, "verbose": False, + "api_key": None, "endpoint": None, "timeout": 30.0} + result, i = [argv[0]], 1 + flags = {"--json": ("json", False), "--dry-run": ("dry_run", False), "--quiet": ("quiet", False), "--verbose": ("verbose", False), "--api-key": ("api_key", True), "--endpoint": ("endpoint", True), "--timeout": ("timeout", True)} + while i < len(argv): + arg = argv[i] + if arg in flags: + key, takes_value = flags[arg] + if takes_value: + if i + 1 == len(argv): fail(f"{arg} requires a value") + values[key] = argv[i + 1]; i += 2 + else: values[key] = True; i += 1 + else: result.append(arg); i += 1 + try: values["timeout"] = float(values["timeout"]) + except ValueError: fail("--timeout must be a number") + return values, result + +def operation_kind(document): + cleaned = re.sub(r"(?:#.*$|\s)+", " ", document).lstrip() + match = re.match(r"(?:query|mutation)\b", cleaned, re.I) + if match: return match.group(0).lower() + return "query" if cleaned.startswith("{") else None + +class Client: + def __init__(self, options): self.options = options + def run(self, document, variables=None, operation_name=None): + payload = {"query": document, "variables": variables or {}} + if operation_name: payload["operationName"] = operation_name + if self.options["dry_run"]: return {"dry_run": True, "endpoint": self.endpoint, "payload": payload}, 0 + if not self.api_key: fail("FIREFLIES_API_KEY is not set; use --api-key or --dry-run.", EX_CONFIG) + body = json.dumps(payload).encode("utf-8") + request = Request(self.endpoint, body, {"Authorization": "Bearer " + self.api_key, "Content-Type": "application/json", "Accept": "application/json"}) + try: + with urlopen(request, timeout=self.options["timeout"]) as response: raw = response.read().decode("utf-8") + except HTTPError as exc: + raw = exc.read().decode("utf-8", "replace") + try: + data = json.loads(raw) + return data, EX_GRAPHQL if data.get("errors") else EX_TRANSPORT + except json.JSONDecodeError: fail(f"HTTP {exc.code}: {raw[:300]}", EX_TRANSPORT) + except URLError as exc: fail(f"transport error: {exc.reason}", EX_TRANSPORT) + try: data = json.loads(raw) + except json.JSONDecodeError: fail("server returned invalid JSON", EX_TRANSPORT) + return data, EX_GRAPHQL if data.get("errors") else 0 + @property + def endpoint(self): return self.options["endpoint"] or os.getenv("FIREFLIES_ENDPOINT") or DEFAULT_ENDPOINT + @property + def api_key(self): return self.options["api_key"] or os.getenv("FIREFLIES_API_KEY") + +def emit(data, options, code=0): + print(json.dumps(data, indent=None if options["json"] else 2, sort_keys=True, default=str)) + if code == EX_GRAPHQL: + errors = data.get("errors", []) if isinstance(data, dict) else [] + print("GraphQL error: " + str(errors[0].get("message", "unknown error") if errors else "unknown error"), file=sys.stderr) + raise SystemExit(code) + +def add_example(parser, text): parser.epilog = "Examples:\n " + text +def add_confirm(parser): parser.add_argument("--confirm", action="store_true", help="Required literal confirmation for a real mutation.") +def add_doc_args(parser): + parser.add_argument("--document", required=True, help="GraphQL document."); parser.add_argument("--variables", help="Variables JSON object."); parser.add_argument("--variables-file", help="Path to variables JSON object."); parser.add_argument("--operation-name", help="GraphQL operationName.") +def ensure_confirm(args, options): + if not options["dry_run"] and not args.confirm: fail("a real mutation requires --confirm", EX_CONFIRM) +def run_doc(client, options, document, variables=None, operation_name=None): + data, code = client.run(document, variables, operation_name); emit(data, options, code) +def transcript_vars(args): + values = {"keyword": args.keyword, "scope": args.scope, "fromDate": args.from_date, "toDate": args.to_date, "limit": args.limit, "skip": args.skip, "hostEmail": args.host_email, "userId": args.user_id, "mine": args.mine, "organizers": split_csv(args.organizers), "participants": split_csv(args.participants), "channelId": args.channel_id} + if args.limit > 50: fail("--limit cannot exceed Fireflies' documented maximum of 50") + return {k:v for k,v in values.items() if v is not None} +def split_csv(value): return value.split(",") if value else None + +def build_parser(): + parser = argparse.ArgumentParser(prog="fireflies", description="Source-faithful Fireflies.ai GraphQL CLI.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Global flags work before or after commands. Example: fireflies transcripts list --limit 10 --json") + parser.add_argument("--json", action="store_true", help="Emit one JSON document."); parser.add_argument("--api-key", help="Override FIREFLIES_API_KEY."); parser.add_argument("--endpoint", help="Override FIREFLIES_ENDPOINT."); parser.add_argument("--timeout", type=float, help="HTTP timeout seconds."); parser.add_argument("--dry-run", action="store_true", help="Show exact GraphQL payload without HTTP."); parser.add_argument("--quiet", action="store_true", help="Suppress optional diagnostics."); parser.add_argument("--verbose", action="store_true", help="Enable diagnostics on stderr.") + root = parser.add_subparsers(dest="root", required=True) + for name, kind in (("query", "query"), ("mutation", "mutation")): + p = root.add_parser(name, help=f"Run a generic GraphQL {kind}."); add_doc_args(p) + if kind == "mutation": add_confirm(p) + add_example(p, f"fireflies {name} --document '{kind} {{ ... }}'" + (" --confirm" if kind == "mutation" else "")) + t = root.add_parser("transcripts", help="Read or delete transcripts."); ts = t.add_subparsers(dest="action", required=True) + p = ts.add_parser("list", help="List transcripts."); + for flag, dest in (("--keyword","keyword"),("--scope","scope"),("--from-date","from_date"),("--to-date","to_date"),("--host-email","host_email"),("--user-id","user_id"),("--organizers","organizers"),("--participants","participants"),("--channel-id","channel_id")): p.add_argument(flag, dest=dest) + p.add_argument("--limit", type=int, default=10); p.add_argument("--skip", type=int, default=0); p.add_argument("--mine", action="store_true", default=None); add_example(p, "fireflies transcripts list --keyword roadmap --limit 10") + p=ts.add_parser("get", help="Get transcript details."); p.add_argument("id"); add_example(p,"fireflies transcripts get transcript-id") + p=ts.add_parser("delete", help="Delete a transcript."); p.add_argument("id"); add_confirm(p); add_example(p,"fireflies transcripts delete transcript-id --confirm") + u=root.add_parser("users", help="Read users."); us=u.add_subparsers(dest="action",required=True) + for action in ("me","list","get"): + p=us.add_parser(action); + if action == "get": p.add_argument("id") + add_example(p,f"fireflies users {action}" + (" user-id" if action=="get" else "")) + simple = {"contacts":"Contacts","channels":"Channels","groups":"User groups","apps":"AI app outputs"} + for name, help_text in simple.items(): + p=root.add_parser(name, help=help_text); sub=p.add_subparsers(dest="action", required=True); q=sub.add_parser("list"); add_example(q,f"fireflies {name} list") + if name == "channels": q=sub.add_parser("get"); q.add_argument("id"); add_example(q,"fireflies channels get channel-id") + if name == "apps": q.add_argument("--app-id"); q.add_argument("--transcript-id"); q.add_argument("--limit",type=float); q.add_argument("--skip",type=float) + if name == "groups": q.add_argument("--mine",action="store_true",default=None) + b=root.add_parser("bites",help="Read or create Bites."); bs=b.add_subparsers(dest="action",required=True); p=bs.add_parser("list"); p.add_argument("--mine",action="store_true",default=None);p.add_argument("--transcript-id");p.add_argument("--my-team",action="store_true",default=None);p.add_argument("--limit",type=int);p.add_argument("--skip",type=int);add_example(p,"fireflies bites list --mine") + p=bs.add_parser("get");p.add_argument("id");add_example(p,"fireflies bites get bite-id") + p=bs.add_parser("create");p.add_argument("--transcript-id",required=True);p.add_argument("--start",type=float,required=True);p.add_argument("--end",type=float,required=True);p.add_argument("--name");p.add_argument("--type");p.add_argument("--summary");p.add_argument("--privacy",action="append");add_confirm(p);add_example(p,"fireflies bites create --transcript-id id --start 0 --end 30 --confirm") + p=root.add_parser("analytics",help="Get team/user analytics.");p.add_argument("--start",required=True);p.add_argument("--end",required=True);add_example(p,"fireflies analytics --start 2026-01-01 --end 2026-01-31") + m=root.add_parser("meetings",help="Manage documented meeting properties."); ms=m.add_subparsers(dest="action",required=True) + for action in ("active","rename","privacy","state","share","revoke-share"): + p=ms.add_parser(action); add_example(p,f"fireflies meetings {action} meeting-id" + (" --confirm" if action!="active" else "")) + if action!="active": p.add_argument("id");add_confirm(p) + if action=="rename":p.add_argument("--title",required=True) + if action=="privacy":p.add_argument("--privacy",required=True) + if action=="state":p.add_argument("--state",required=True,choices=("pause_recording","resume_recording")) + if action=="share":p.add_argument("--emails",required=True) + if action=="revoke-share":p.add_argument("--email",required=True) + p=root.add_parser("live-action-items",help="List a meeting's live action items.");p.add_argument("meeting_id");add_example(p,"fireflies live-action-items meeting-id") + p=root.add_parser("live",help="Create action items during a live meeting.");ls=p.add_subparsers(dest="action",required=True);p=ls.add_parser("add",help="Create a live action item.");p.add_argument("--meeting-id",required=True,help="Live meeting ID.");p.add_argument("--action-item",required=True,help="Natural-language action-item prompt.");add_confirm(p);add_example(p,"fireflies live add --meeting-id meeting-id --action-item 'Follow up' --confirm") + a=root.add_parser("audio",help="Upload remote audio/video.");au=a.add_subparsers(dest="action",required=True);p=au.add_parser("upload");p.add_argument("--url",required=True);p.add_argument("--title");p.add_argument("--webhook");p.add_argument("--language");p.add_argument("--save-video",action="store_true",default=None);p.add_argument("--client-reference-id");add_confirm(p);add_example(p,"fireflies audio upload --url https://example.com/call.mp3 --confirm") + af=root.add_parser("askfred",help="Use AskFred; requires AI credits for mutations.");afs=af.add_subparsers(dest="action",required=True);p=afs.add_parser("threads");add_example(p,"fireflies askfred threads") + p=afs.add_parser("create");p.add_argument("--question",required=True);p.add_argument("--transcript-id");p.add_argument("--transcript-ids");p.add_argument("--language");add_confirm(p);add_example(p,"fireflies askfred create --question 'What changed?' --transcript-id id --confirm") + p=afs.add_parser("continue");p.add_argument("thread_id");p.add_argument("--question",required=True);add_confirm(p);add_example(p,"fireflies askfred continue thread-id --question 'Why?' --confirm") + p=afs.add_parser("delete");p.add_argument("thread_id");add_confirm(p);add_example(p,"fireflies askfred delete thread-id --confirm") + for name, title in (("audit-events","Enterprise/admin audit event query"),("rule-executions","Enterprise/admin rule execution query")): + p=root.add_parser(name,help=title);p.add_argument("--limit",type=int);p.add_argument("--cursor");p.add_argument("--filter",required=True,help="Documented filter JSON.");add_example(p,f"fireflies {name} --filter '{{\"category\":\"...\"}}'") + w=root.add_parser("webhook",help="Local Webhooks V2 verification.");ws=w.add_subparsers(dest="action",required=True);p=ws.add_parser("verify");p.add_argument("--secret",required=True);p.add_argument("--signature",required=True);p.add_argument("--body",required=True);add_example(p,"fireflies webhook verify --secret secret --signature sha256=... --body payload.json") + s=root.add_parser("schema",help="Explicit schema tools.");ss=s.add_subparsers(dest="action",required=True);p=ss.add_parser("introspect",help="Run GraphQL introspection if deployment permits it.");add_example(p,"fireflies schema introspect") + def add_fallback_examples(current): + for action in current._actions: + if isinstance(action, argparse._SubParsersAction): + for child in action.choices.values(): + if not child.epilog: + child.epilog = "Examples:\n fireflies --help" + add_fallback_examples(child) + add_fallback_examples(parser) + return parser + +def main(argv=None): + options, filtered = globals_anywhere(argv or sys.argv) + parser = build_parser() + args = parser.parse_args(filtered[1:]) + client = Client(options) + if args.root in ("query", "mutation"): + document = args.document + kind = operation_kind(document) + if kind != args.root: fail(f"{args.root} accepts only GraphQL {args.root} documents") + if args.root == "mutation": ensure_confirm(args, options) + if args.variables and args.variables_file: fail("use only one of --variables and --variables-file") + variables = json_value(args.variables, "--variables") if args.variables else (json_value(Path(args.variables_file).read_text(), "variables file") if args.variables_file else {}) + if not isinstance(variables, dict): fail("variables must be a JSON object") + run_doc(client, options, document, variables, args.operation_name) + if args.root == "transcripts": + if args.action == "list": run_doc(client, options, DOCS["transcripts_list"], transcript_vars(args)) + if args.action == "get": run_doc(client, options, DOCS["transcript"], {"id":args.id}) + ensure_confirm(args, options); run_doc(client, options, DOCS["delete"], {"id":args.id}) + if args.root == "users": run_doc(client, options, DOCS["users"] if args.action=="list" else (DOCS["me"] if args.action=="me" else DOCS["user"]), {} if args.action!="get" else {"id":args.id}) + if args.root in ("contacts","channels","groups","apps"): + key = args.root if args.root != "channels" or args.action=="list" else "channel"; variables = {} + if args.root=="channels" and args.action=="get":variables={"id":args.id} + if args.root=="groups":variables={"mine":args.mine} + if args.root=="apps":variables={"appId":args.app_id,"transcriptId":args.transcript_id,"limit":args.limit,"skip":args.skip} + run_doc(client,options,DOCS[key],{k:v for k,v in variables.items() if v is not None}) + if args.root == "bites": + if args.action=="list": + if not any((args.mine,args.transcript_id,args.my_team)): fail("bites list requires --mine, --transcript-id, or --my-team") + run_doc(client,options,DOCS["bites"],{k:v for k,v in {"mine":args.mine,"transcriptId":args.transcript_id,"myTeam":args.my_team,"limit":args.limit,"skip":args.skip}.items() if v is not None}) + if args.action=="get":run_doc(client,options,DOCS["bite"],{"id":args.id}) + ensure_confirm(args,options);run_doc(client,options,DOCS["bite_create"],{"transcriptId":args.transcript_id,"start":args.start,"end":args.end,"name":args.name,"type":args.type,"summary":args.summary,"privacy":args.privacy}) + if args.root=="analytics":run_doc(client,options,DOCS["analytics"],{"startTime":args.start,"endTime":args.end}) + if args.root=="meetings": + if args.action=="active":run_doc(client,options,DOCS["active"]) + ensure_confirm(args,options) + if args.action=="rename": run_doc(client,options,DOCS["rename"],{"input":{"id":args.id,"title":args.title}}) + if args.action=="privacy": run_doc(client,options,DOCS["privacy"],{"input":{"id":args.id,"privacy":args.privacy}}) + if args.action=="state": run_doc(client,options,DOCS["state"],{"input":{"meeting_id":args.id,"action":args.state}}) + if args.action=="share": run_doc(client,options,DOCS["share"],{"input":{"meeting_id":args.id,"emails":split_csv(args.emails)}}) + run_doc(client,options,DOCS["revoke"],{"input":{"meeting_id":args.id,"email":args.email}}) + if args.root=="live-action-items":run_doc(client,options,DOCS["live_items"],{"id":args.meeting_id}) + if args.root=="live": ensure_confirm(args,options);run_doc(client,options,DOCS["live_add"],{"input":{"meeting_id":args.meeting_id,"prompt":args.action_item}}) + if args.root=="audio": + ensure_confirm(args,options); inp={"url":args.url,"title":args.title,"webhook":args.webhook,"custom_language":args.language,"save_video":args.save_video,"client_reference_id":args.client_reference_id};run_doc(client,options,DOCS["upload"],{"input":{k:v for k,v in inp.items() if v is not None}}) + if args.root=="askfred": + if args.action=="threads":run_doc(client,options,DOCS["askfred_threads"]) + ensure_confirm(args,options) + if args.action=="create": + inp={"query":args.question,"transcript_id":args.transcript_id,"response_language":args.language} + if args.transcript_ids:inp["filters"]={"transcript_ids":json_value(args.transcript_ids,"--transcript-ids")} + run_doc(client,options,DOCS["askfred_create"],{"input":{k:v for k,v in inp.items() if v is not None}}) + if args.action=="continue":run_doc(client,options,DOCS["askfred_continue"],{"input":{"thread_id":args.thread_id,"query":args.question}}) + run_doc(client,options,DOCS["askfred_delete"],{"id":args.thread_id}) + if args.root in ("audit-events","rule-executions"):run_doc(client,options,DOCS["audit" if args.root=="audit-events" else "rules"],{"limit":args.limit,"cursor":args.cursor,"filters":json_value(args.filter,"--filter")}) + if args.root=="webhook": + body=sys.stdin.buffer.read() if args.body=="-" else Path(args.body).read_bytes(); prefix="sha256="; supplied=args.signature[len(prefix):] if args.signature.startswith(prefix) else ""; expected=hmac.new(args.secret.encode(),body,hashlib.sha256).hexdigest(); result={"valid":bool(supplied) and hmac.compare_digest(expected,supplied)} + try: result["event"]=json.loads(body).get("event") + except (json.JSONDecodeError,AttributeError): pass + emit(result,options) + if args.root=="schema":run_doc(client,options,DOCS["introspection"],{},"IntrospectionQuery") + +if __name__ == "__main__": + main() diff --git a/fireflies/tests/test_fireflies.py b/fireflies/tests/test_fireflies.py new file mode 100644 index 0000000..8add059 --- /dev/null +++ b/fireflies/tests/test_fireflies.py @@ -0,0 +1,85 @@ +import hashlib +import hmac +import json +import os +import subprocess +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +CLI = Path(__file__).parents[1] / "scripts" / "fireflies" + +class Handler(BaseHTTPRequestHandler): + calls = [] + response = {"data": {"ok": True}} + status = 200 + def do_POST(self): + body = self.rfile.read(int(self.headers["Content-Length"])) + self.__class__.calls.append((dict(self.headers), json.loads(body))) + self.send_response(self.__class__.status); self.send_header("Content-Type", "application/json"); self.end_headers() + self.wfile.write(json.dumps(self.__class__.response).encode()) + def log_message(self, *_): pass + +class FirefliesTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.server = HTTPServer(("127.0.0.1", 0), Handler) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True); cls.thread.start() + cls.endpoint = "http://127.0.0.1:%s/graphql" % cls.server.server_port + @classmethod + def tearDownClass(cls): cls.server.shutdown() + def run_cli(self, *args, key=False): + env = os.environ.copy(); env.pop("FIREFLIES_API_KEY", None) + if key: env["FIREFLIES_API_KEY"] = "test-key" + return subprocess.run(["python3", str(CLI), *args], text=True, capture_output=True, env=env) + def test_dry_run_needs_no_key_and_no_http(self): + Handler.calls.clear(); result=self.run_cli("transcripts","list","--dry-run","--json") + self.assertEqual(result.returncode,0); self.assertEqual(Handler.calls,[]); self.assertTrue(json.loads(result.stdout)["dry_run"]) + def test_read_uses_bearer_and_payload(self): + Handler.calls.clear(); Handler.response={"data":{"users":[]}} + result=self.run_cli("--endpoint",self.endpoint,"users","list","--json",key=True) + self.assertEqual(result.returncode,0); self.assertEqual(Handler.calls[-1][0]["Authorization"],"Bearer test-key"); self.assertIn("query Users",Handler.calls[-1][1]["query"]) + def test_graphql_error_json_and_exit_five(self): + Handler.response={"errors":[{"message":"denied","code":"auth_failed"}]} + result=self.run_cli("--endpoint",self.endpoint,"users","list","--json",key=True) + self.assertEqual(result.returncode,5); self.assertEqual(json.loads(result.stdout)["errors"][0]["message"],"denied"); self.assertIn("GraphQL error",result.stderr) + Handler.response={"data":{"ok":True}} + def test_http_error_with_graphql_errors_exits_five(self): + Handler.status=400; Handler.response={"errors":[{"message":"denied","code":"auth_failed"}]} + result=self.run_cli("--endpoint",self.endpoint,"users","list","--json",key=True) + self.assertEqual(result.returncode,5); self.assertEqual(json.loads(result.stdout)["errors"][0]["message"],"denied"); self.assertIn("GraphQL error",result.stderr) + Handler.status=200; Handler.response={"data":{"ok":True}} + def test_generic_guards(self): + self.assertEqual(self.run_cli("query","--document","mutation { x }","--json").returncode,2) + self.assertEqual(self.run_cli("mutation","--document","mutation { x }","--json").returncode,6) + def test_limit_json_and_help_examples(self): + self.assertEqual(self.run_cli("transcripts","list","--limit","51","--json").returncode,2) + result=self.run_cli("transcripts","list","--dry-run","--json"); json.loads(result.stdout); self.assertEqual(result.stderr,"") + self.assertIn("Examples:",self.run_cli("transcripts","list","--help").stdout) + def test_webhook_signatures(self): + with tempfile.NamedTemporaryFile("wb",delete=False) as f: f.write(b'{"event":"meeting.transcribed"}'); path=f.name + digest=hmac.new(b"test",Path(path).read_bytes(),hashlib.sha256).hexdigest() + self.assertTrue(json.loads(self.run_cli("webhook","verify","--secret","test","--signature","sha256="+digest,"--body",path,"--json").stdout)["valid"]) + self.assertFalse(json.loads(self.run_cli("webhook","verify","--secret","test","--signature","sha256=bad","--body",path,"--json").stdout)["valid"]) + os.unlink(path) + def test_mutation_dry_run_payload(self): + result=self.run_cli("meetings","rename","abc","--title","New","--dry-run","--json") + payload=json.loads(result.stdout)["payload"]; self.assertIn("updateMeetingTitle",payload["query"]); self.assertEqual(payload["variables"]["input"]["id"],"abc") + def test_source_faithful_dry_run_payloads(self): + live=json.loads(self.run_cli("live","add","--meeting-id","meeting-id","--action-item","Follow up","--dry-run","--json").stdout)["payload"] + self.assertIn("createLiveActionItem(input: $input) { success }",live["query"]) + self.assertEqual(live["variables"],{"input":{"meeting_id":"meeting-id","prompt":"Follow up"}}) + deleted=json.loads(self.run_cli("askfred","delete","thread-id","--dry-run","--json").stdout)["payload"] + self.assertIn("id title transcript_id user_id created_at",deleted["query"]) + self.assertEqual(deleted["variables"],{"id":"thread-id"}) + analytics=json.loads(self.run_cli("analytics","--start","2026-01-01","--end","2026-01-31","--dry-run","--json").stdout)["payload"] + self.assertIn("$startTime",analytics["query"]); self.assertIn("$endTime",analytics["query"]) + self.assertEqual(analytics["variables"],{"startTime":"2026-01-01","endTime":"2026-01-31"}) + transcript=json.loads(self.run_cli("transcripts","get","transcript-id","--dry-run","--json").stdout)["payload"] + self.assertIn("negative_pct neutral_pct positive_pct",transcript["query"]) + audit=json.loads(self.run_cli("audit-events","--filter",'{"category":"MEETING_OPERATIONS"}',"--dry-run","--json").stdout)["payload"] + self.assertIn("events { id time action actor { user_id } resource { type id } }",audit["query"]) + +if __name__ == "__main__": unittest.main() diff --git a/flaresolverr-cli/README.md b/flaresolverr-cli/README.md index eb34627..7890328 100644 --- a/flaresolverr-cli/README.md +++ b/flaresolverr-cli/README.md @@ -1,44 +1,39 @@ -# Cloudflare Bypass Proxy from the Terminal - -Drive a FlareSolverr instance from the command line. FlareSolverr is an open-source proxy server that launches a headless Chrome browser to solve Cloudflare and DDoS-GUARD JavaScript challenges, returning the unblocked HTML and cookies to your client. +# FlareSolverr CLI: Browser-Backed Requests From the Terminal ## Why Install This Skill -When your agent loads this skill, it can **interact with any FlareSolverr proxy server** without writing raw HTTP requests. That means: +Use this skill when an authorized site requires a browser-backed request to handle a Cloudflare or DDoS-GUARD challenge. It gives an agent a small terminal interface for sending GET and POST requests through an existing private FlareSolverr service. -- **Check server health** — verify the FlareSolverr instance is running and ready before sending traffic -- **Manage browser sessions** — create, list, and destroy persistent headless Chrome sessions for fast, stateful scraping -- **Solve Cloudflare challenges** — fetch pages behind Cloudflare and DDoS-GUARD protection with a single command, getting back unblocked HTML, cookies, and user-agent strings -- **Structured output** — every command supports `--json` for piping into scripts and `--dry-run` for previewing API calls before making them - -FlareSolverr is widely used in the *arr ecosystem (Prowlarr, Jackett) and by anyone who needs reliable access to Cloudflare-protected sites. This CLI makes it first-class for agent workflows. +It also provides a readiness probe and browser-session lifecycle commands, without requiring a Python package or raw HTTP request construction. Results are JSON, making them practical to inspect or pass to another tool. ## What You Get -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with flag tables, examples, and gotchas | -| `scripts/flaresolverr-cli` | Single-file Python CLI (stdlib only, zero pip dependencies) | -| `tests/test_cli.py` | Deterministic smoke tests covering all commands in dry-run mode | +| Path | Purpose | +|---|---| +| `SKILL.md` | Agent-facing command reference and safe-use guidance. | +| `scripts/flaresolverr-cli` | Dependency-free Python CLI for FlareSolverr's `/v1` API. | ## Quick Start -```bash -# Start FlareSolverr (one-time setup) -docker run -d --name=flaresolverr -p 8191:8191 ghcr.io/flaresolverr/flaresolverr:latest +```sh +# Run FlareSolverr separately, then optionally set its address. +export FLARESOLVERR_SERVER="http://localhost:8191" -# Point the CLI at it -export FLARESOLVERR_URL="http://localhost:8191" +# Verify the service is ready. +python3 scripts/flaresolverr-cli health -# Verify it works -./scripts/flaresolverr-cli health -./scripts/flaresolverr-cli info +# Send a browser-backed request. +python3 scripts/flaresolverr-cli get https://example.com ``` +`FLARESOLVERR_SERVER` defaults to `http://localhost:8191`. Add `--server URL` for a one-command override or `--timeout SECONDS` to change the 60-second HTTP timeout. Successful commands write JSON to stdout; request failures write JSON to stderr and exit nonzero. + ## Triggers -Load this when the user mentions FlareSolverr, Cloudflare bypass, anti-bot proxy, headless browser proxy, or needs to fetch a page behind Cloudflare or DDoS-GUARD protection. +- A Cloudflare or DDoS-GUARD browser challenge blocks an authorized request. +- A workflow needs browser-backed GET or POST requests through an existing FlareSolverr service. +- A workflow needs to check FlareSolverr readiness or create, list, or destroy a session. ## Requirements -Python 3.8+ (stdlib only, no pip installs needed). A running [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) instance (Docker: `ghcr.io/flaresolverr/flaresolverr`). Set `FLARESOLVERR_URL` to the server address (defaults to `http://localhost:8191`). +Python 3.8+ with no pip dependencies. A running private [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) instance is required. Do not expose that service publicly, and use it only for sites you are authorized to access. diff --git a/flaresolverr-cli/SKILL.md b/flaresolverr-cli/SKILL.md index df99c71..c2b2779 100644 --- a/flaresolverr-cli/SKILL.md +++ b/flaresolverr-cli/SKILL.md @@ -1,161 +1,50 @@ --- name: flaresolverr-cli -description: 'Operate the full flaresolverr-cli command surface: named browser-session - lifecycle, structured health and service information, dry-run planning, cookie-only - returns, and challenge-solving GET or form-POST requests. Use for explicit - flaresolverr-cli or session-management requests; use the smaller flaresolverr skill - for a one-off health check or basic browser-backed retrieval.' +description: Use a small FlareSolverr JSON CLI for browser-backed GET and POST requests, readiness checks, and session lifecycle management when a site requires Cloudflare or DDoS-GUARD challenge handling. license: MIT -compatibility: >- - Python 3.8+ (stdlib only, no pip deps). Requires a running FlareSolverr instance - (Docker: ghcr.io/flaresolverr/flaresolverr) and the FLARESOLVERR_URL env var set to - the server address (defaults to http://localhost:8191). +compatibility: Python 3.8+ (stdlib only). Requires a running FlareSolverr instance; FLARESOLVERR_SERVER defaults to http://localhost:8191. metadata: - tags: flaresolverr, cloudflare, proxy, anti-bot, headless-browser, selenium, web-scraping - sources: "https://github.com/FlareSolverr/FlareSolverr, https://hub.docker.com/r/flaresolverr/flaresolverr" + tags: flaresolverr, cloudflare, proxy, anti-bot, headless-browser, web-scraping + sources: https://github.com/FlareSolverr/FlareSolverr, https://hub.docker.com/r/flaresolverr/flaresolverr --- -# flaresolverr-cli — Cloudflare Bypass Proxy from the Terminal +# FlareSolverr CLI -Drive a FlareSolverr instance from the command line. FlareSolverr is a proxy server that launches a headless Chrome browser to solve Cloudflare and DDoS-GUARD JavaScript challenges, returning the unblocked HTML, cookies, and user-agent to your client. +Use this CLI with a running private FlareSolverr service when ordinary HTTP requests encounter a browser challenge. It sends JSON commands to FlareSolverr's `/v1` API. -The CLI wraps all four API endpoints: service info, health check, the three `/v1` session commands (create/list/destroy), and both challenge-solving request commands (`request.get` and `request.post`). Every command supports `--json`, `--dry-run`, and `--timeout`. +## Quick Start -## Mutation Gate - -`health`, `info`, and `sessions list` are read-only discovery. Creating or destroying a session changes FlareSolverr state, and `request get` or `request post` sends traffic to an external target. - -> Confirm the target, scope, and rollback path before acting. Read-only discovery may proceed without confirmation. - -Use `--dry-run` to review a planned mutation first. Session destruction and any request intended to change target state require an explicit user directive; this skill does not authorize deletion, privilege changes, authentication bypass, or irreversible cleanup. - -## When not to use - -Use [flaresolverr](../flaresolverr/SKILL.md) for a one-off health check or basic GET/POST when named sessions, cookie-only returns, dry-run planning, and the full command surface are unnecessary. Do not use either skill to bypass authentication, authorization, paywalls, or access controls. - -## Setup - -1. Start a FlareSolverr instance (Docker): - -```bash -docker run -d --name=flaresolverr -p 8191:8191 \ - ghcr.io/flaresolverr/flaresolverr:latest +```sh +export FLARESOLVERR_SERVER="http://localhost:8191" +python3 scripts/flaresolverr-cli health ``` -2. Set the server URL: +`FLARESOLVERR_SERVER` is optional and defaults to `http://localhost:8191`. Use `--server URL` to override it for one command and `--timeout SECONDS` to set the HTTP timeout; the default is 60 seconds. -```bash -export FLARESOLVERR_URL="http://localhost:8191" +## Commands + +```text +flaresolverr-cli [--server URL] [--timeout SECONDS] health +flaresolverr-cli [--server URL] [--timeout SECONDS] get URL [--session ID] +flaresolverr-cli [--server URL] [--timeout SECONDS] post URL [--session ID] [--data FORM_BODY] +flaresolverr-cli [--server URL] [--timeout SECONDS] session create +flaresolverr-cli [--server URL] [--timeout SECONDS] session list +flaresolverr-cli [--server URL] [--timeout SECONDS] session destroy [SESSION_ID] ``` -`--help` and `--dry-run` work without a running server. +`health` is a readiness probe that sends the `sessions.list` JSON command to `/v1`; it does not call `GET /health`. -## Essential Commands +`get` sends `request.get`. `post` sends `request.post` and includes `FORM_BODY` as `postData` when `--data` is supplied. `--session ID` adds the given session to either request. Session commands create, list, or destroy FlareSolverr sessions; omitting `SESSION_ID` from `session destroy` sends an empty session value. -### health — Server health check +## Output And Errors -```bash -flaresolverr-cli health # check if server is reachable -flaresolverr-cli health --json # {"status": "ok"} -``` +Successful commands print one JSON result to stdout. Request failures print a JSON error to stderr and return a nonzero status. -Calls `GET /health`. Returns `ok` when the server is running. Use as a readiness probe or pre-flight check before session/request commands. +## Requirements -### info — Service information - -```bash -flaresolverr-cli info # version, user-agent, ready message -flaresolverr-cli info --json # machine-readable -``` - -Calls `GET /`. Returns the FlareSolverr version, the Chrome user-agent string, and whether the service is ready to accept requests. - -### sessions create — Create a persistent browser session - -```bash -flaresolverr-cli sessions create # auto-generated session ID -flaresolverr-cli sessions create --session my-session # custom session name -flaresolverr-cli sessions create --proxy socks5://proxy:1080 # with proxy -``` - -Creates a long-lived headless browser instance. Reuse the returned session ID in subsequent `request get` / `request post` calls for 10-100x faster requests (no browser startup overhead per call). - -### sessions list — List active sessions - -```bash -flaresolverr-cli sessions list # all active session IDs -``` - -Returns the IDs of every active persistent session. Each session holds a browser process — use this to audit resource usage before creating more. - -### sessions destroy — Tear down a session - -```bash -flaresolverr-cli sessions destroy --session my-session -``` - -Closes the browser and frees memory. Always destroy sessions when done — each idle session consumes significant RAM. - -### request get — Fetch a URL through the solver - -```bash -flaresolverr-cli request get --url https://example.com # basic -flaresolverr-cli request get --url https://example.com --session my-session # reuse session -flaresolverr-cli request get --url https://example.com --return-only-cookies # cookies only -flaresolverr-cli request get --url https://example.com --timeout 120 # 120s timeout -``` - -Sends `request.get` to the `/v1` endpoint. FlareSolverr launches Chrome (or reuses a session), navigates to the URL, solves any Cloudflare/DDoS-GUARD challenge, and returns the resolved HTML, cookies, and user-agent. - -Flags: - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--url` | string | required | Target URL | -| `--session` | string | — | Reuse existing session (faster) | -| `--max-timeout` | int | 60000 | Challenge solve timeout (ms) | -| `--return-only-cookies` | flag | false | Omit HTML from response | -| `--proxy` | string | — | Per-request proxy URL | -| `--wait` | int | 0 | Extra wait after solve (seconds) | - -### request post — POST through the solver - -```bash -flaresolverr-cli request post --url https://example.com/form --data "a=1&b=2" -``` - -Same as `request get` but sends an `application/x-www-form-urlencoded` POST body. Accepts the same flags plus `--data` (the form-encoded body string). - -## Global Flags - -All flags work in any position: - -```bash -flaresolverr-cli --json health -flaresolverr-cli --dry-run sessions create --session test -flaresolverr-cli --quiet request get --url https://example.com -flaresolverr-cli --timeout 30 request get --url https://example.com -``` - -| Flag | Effect | -|------|--------| -| `--json` | Output one JSON value to stdout (all diagnostics go to stderr) | -| `--dry-run` | Print the planned API call without making it | -| `--quiet` | Suppress non-essential output | -| `--timeout N` | HTTP request timeout in seconds (default 60) | - -## Known Gotchas - -- **No authentication** — FlareSolverr has no built-in auth. Expose it only on localhost or behind a reverse proxy with auth. -- **HTTP 200 for errors** — FlareSolverr always returns HTTP 200. Check the JSON `status` field (`"ok"` vs `"error"`) to determine success. -- **Session proxy precedence** — When a `--session` is provided, any `--proxy` flag is ignored. The session's proxy (set at create time) takes precedence. -- **Memory per session** — Each persistent session runs a full Chrome browser (~200-500 MB RAM). Destroy sessions promptly. -- **First request latency** — A stateless request (no `--session`) pays a browser cold-start cost of 3-10 seconds. Persistent sessions amortize this. -- **POST body format** — `--data` must be `application/x-www-form-urlencoded` format (`key=value&key2=value2`). Multipart and JSON bodies are not supported by FlareSolverr. -- **Selenium status limitation** — The `status` field in responses is always 200 (Selenium does not expose the real HTTP status). Trust the response body, not the status code. +Python 3.8+ with no pip dependencies and a running FlareSolverr instance. Keep the service private and use it only for sites you are authorized to access. ## References -- [Related FlareSolverr CLI](../flaresolverr/scripts/flaresolverr) — Stdlib-only reference implementation for the same API. -- [FlareSolverr GitHub](https://github.com/FlareSolverr/FlareSolverr) — Source, API docs, Docker Compose examples. -- [FlareSolverr Docker Hub](https://hub.docker.com/r/flaresolverr/flaresolverr) — Prebuilt images. +- [scripts/flaresolverr-cli](scripts/flaresolverr-cli) - Stdlib-only CLI implementation. +- [FlareSolverr](https://github.com/FlareSolverr/FlareSolverr) - Service and API documentation.