mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
feat(skills): add Fireflies API skill and CLI (#49)
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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 <API key>`.
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
@@ -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 <key>` 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.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Webhook Security
|
||||
|
||||
Webhooks V2 events are `meeting.transcribed`, `meeting.summarized`, and `meeting.bot_joined`.
|
||||
Fireflies sends `X-Hub-Signature: sha256=<hex>`, 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.
|
||||
@@ -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
|
||||
```
|
||||
Executable
+251
@@ -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()
|
||||
@@ -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()
|
||||
+20
-25
@@ -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.
|
||||
|
||||
+28
-139
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user