ffx-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ffx/__init__.py +1 -0
- ffx/__main__.py +4 -0
- ffx/api_client.py +261 -0
- ffx/commands/__init__.py +29 -0
- ffx/commands/action_items.py +74 -0
- ffx/commands/auth.py +20 -0
- ffx/commands/brief.py +45 -0
- ffx/commands/export.py +129 -0
- ffx/commands/get.py +64 -0
- ffx/commands/list_cmd.py +68 -0
- ffx/commands/search.py +62 -0
- ffx/commands/speaker.py +84 -0
- ffx/commands/summary.py +78 -0
- ffx/commands/topics.py +82 -0
- ffx/commands/transcript.py +91 -0
- ffx/commands/week.py +121 -0
- ffx/config.py +46 -0
- ffx/models.py +107 -0
- ffx/output.py +106 -0
- ffx_cli-0.1.0.dist-info/METADATA +9 -0
- ffx_cli-0.1.0.dist-info/RECORD +23 -0
- ffx_cli-0.1.0.dist-info/WHEEL +4 -0
- ffx_cli-0.1.0.dist-info/entry_points.txt +2 -0
ffx/commands/list_cmd.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ffx.config import Config
|
|
7
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
8
|
+
from ffx.output import json_envelope, json_error, transcripts_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_cmd(
|
|
12
|
+
days: Optional[int] = typer.Option(None, "--days", help="Filter to last N days"),
|
|
13
|
+
limit: int = typer.Option(10, "--limit", help="Max results"),
|
|
14
|
+
participant: Optional[list[str]] = typer.Option(None, "--participant", help="Filter by attendee email (repeatable)"),
|
|
15
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""List your Fireflies meetings."""
|
|
18
|
+
json_mode = not table
|
|
19
|
+
config = Config()
|
|
20
|
+
if not config.api_key:
|
|
21
|
+
if json_mode:
|
|
22
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
23
|
+
else:
|
|
24
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
25
|
+
raise typer.Exit(2)
|
|
26
|
+
|
|
27
|
+
from_date = None
|
|
28
|
+
if days:
|
|
29
|
+
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
client = FirefliesClient(config.api_key)
|
|
33
|
+
transcripts = client.list_transcripts(limit=limit, from_date=from_date, participants=participant or None)
|
|
34
|
+
except AuthError:
|
|
35
|
+
if json_mode:
|
|
36
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
37
|
+
else:
|
|
38
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
39
|
+
raise typer.Exit(2)
|
|
40
|
+
except ApiError as e:
|
|
41
|
+
if json_mode:
|
|
42
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
43
|
+
else:
|
|
44
|
+
typer.echo(f"API error: {e}", err=True)
|
|
45
|
+
raise typer.Exit(2)
|
|
46
|
+
|
|
47
|
+
if not transcripts:
|
|
48
|
+
if json_mode:
|
|
49
|
+
typer.echo(json_envelope([], {"limit": limit}))
|
|
50
|
+
else:
|
|
51
|
+
typer.echo("No meetings found.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if json_mode:
|
|
55
|
+
results = [
|
|
56
|
+
{
|
|
57
|
+
"id": t.id,
|
|
58
|
+
"title": t.title,
|
|
59
|
+
"date": t.display_date,
|
|
60
|
+
"duration_seconds": t.duration,
|
|
61
|
+
"organizer_email": t.organizer_email,
|
|
62
|
+
"participants": t.participants,
|
|
63
|
+
}
|
|
64
|
+
for t in transcripts
|
|
65
|
+
]
|
|
66
|
+
typer.echo(json_envelope(results, {"limit": limit}))
|
|
67
|
+
else:
|
|
68
|
+
transcripts_table(transcripts)
|
ffx/commands/search.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ffx.config import Config
|
|
7
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
8
|
+
from ffx.output import json_envelope, json_error, transcripts_table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def search(
|
|
12
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
13
|
+
days: Optional[int] = typer.Option(None, "--days"),
|
|
14
|
+
limit: int = typer.Option(20, "--limit"),
|
|
15
|
+
participant: Optional[list[str]] = typer.Option(None, "--participant", help="Filter by attendee email (repeatable)"),
|
|
16
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Search meetings by keyword."""
|
|
19
|
+
json_mode = not table
|
|
20
|
+
config = Config()
|
|
21
|
+
if not config.api_key:
|
|
22
|
+
if json_mode:
|
|
23
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
24
|
+
else:
|
|
25
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
26
|
+
raise typer.Exit(2)
|
|
27
|
+
|
|
28
|
+
from_date = None
|
|
29
|
+
if days:
|
|
30
|
+
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
client = FirefliesClient(config.api_key)
|
|
34
|
+
transcripts = client.search_transcripts(query, limit=limit, from_date=from_date, participants=participant or None)
|
|
35
|
+
except AuthError:
|
|
36
|
+
if json_mode:
|
|
37
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
38
|
+
else:
|
|
39
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
40
|
+
raise typer.Exit(2)
|
|
41
|
+
except ApiError as e:
|
|
42
|
+
if json_mode:
|
|
43
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
44
|
+
else:
|
|
45
|
+
typer.echo(f"API error: {e}", err=True)
|
|
46
|
+
raise typer.Exit(2)
|
|
47
|
+
|
|
48
|
+
if not transcripts:
|
|
49
|
+
if json_mode:
|
|
50
|
+
typer.echo(json_envelope([], {"query": query}))
|
|
51
|
+
else:
|
|
52
|
+
typer.echo(f"No results found for '{query}'.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if json_mode:
|
|
56
|
+
results = [
|
|
57
|
+
{"id": t.id, "title": t.title, "date": t.display_date, "duration_seconds": t.duration}
|
|
58
|
+
for t in transcripts
|
|
59
|
+
]
|
|
60
|
+
typer.echo(json_envelope(results, {"query": query, "limit": limit}))
|
|
61
|
+
else:
|
|
62
|
+
transcripts_table(transcripts)
|
ffx/commands/speaker.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from ffx.config import Config
|
|
6
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
7
|
+
from ffx.output import json_error, console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def speaker(
|
|
11
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
12
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
13
|
+
) -> None:
|
|
14
|
+
"""Show speaker analytics for a meeting."""
|
|
15
|
+
json_mode = not table
|
|
16
|
+
config = Config()
|
|
17
|
+
if not config.api_key:
|
|
18
|
+
if json_mode:
|
|
19
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
20
|
+
else:
|
|
21
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
22
|
+
raise typer.Exit(2)
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
client = FirefliesClient(config.api_key)
|
|
26
|
+
t = client.get_transcript(transcript_id)
|
|
27
|
+
except AuthError:
|
|
28
|
+
if json_mode:
|
|
29
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
30
|
+
else:
|
|
31
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
32
|
+
raise typer.Exit(2)
|
|
33
|
+
except ApiError as e:
|
|
34
|
+
if json_mode:
|
|
35
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
36
|
+
else:
|
|
37
|
+
typer.echo(f"API error: {e}", err=True)
|
|
38
|
+
raise typer.Exit(2)
|
|
39
|
+
|
|
40
|
+
if t is None:
|
|
41
|
+
if json_mode:
|
|
42
|
+
typer.echo(json_error("Transcript not found", "NOT_FOUND"))
|
|
43
|
+
else:
|
|
44
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
45
|
+
raise typer.Exit(3)
|
|
46
|
+
|
|
47
|
+
if not t.speakers:
|
|
48
|
+
if json_mode:
|
|
49
|
+
typer.echo(json.dumps({"id": t.id, "title": t.title, "speakers": []}, indent=2))
|
|
50
|
+
else:
|
|
51
|
+
typer.echo("No speaker analytics available for this meeting.")
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
if json_mode:
|
|
55
|
+
typer.echo(json.dumps({
|
|
56
|
+
"id": t.id,
|
|
57
|
+
"title": t.title,
|
|
58
|
+
"speakers": [
|
|
59
|
+
{
|
|
60
|
+
"name": s.name,
|
|
61
|
+
"duration_seconds": s.duration,
|
|
62
|
+
"duration_pct": round(s.duration_pct, 1),
|
|
63
|
+
"word_count": s.word_count,
|
|
64
|
+
"words_per_minute": s.words_per_minute,
|
|
65
|
+
"filler_words": s.filler_words,
|
|
66
|
+
"questions": s.questions,
|
|
67
|
+
"longest_monologue_seconds": s.longest_monologue,
|
|
68
|
+
"monologues_count": s.monologues_count,
|
|
69
|
+
}
|
|
70
|
+
for s in t.speakers
|
|
71
|
+
],
|
|
72
|
+
}, indent=2))
|
|
73
|
+
else:
|
|
74
|
+
console.print(f"\n[bold]{t.title}[/bold] [dim]{t.display_date}[/dim]\n")
|
|
75
|
+
for s in sorted(t.speakers, key=lambda x: x.duration_pct, reverse=True):
|
|
76
|
+
pct = f"{s.duration_pct:.0f}%"
|
|
77
|
+
bar_len = max(0, min(20, int(s.duration_pct / 5)))
|
|
78
|
+
bar = "\u2588" * bar_len + "\u2591" * (20 - bar_len)
|
|
79
|
+
console.print(f" [bold]{s.name}[/bold]")
|
|
80
|
+
console.print(f" {bar} {pct}")
|
|
81
|
+
console.print(f" {s.word_count} words | {s.words_per_minute:.0f} wpm | {s.filler_words} fillers | {s.questions} questions")
|
|
82
|
+
if s.longest_monologue:
|
|
83
|
+
console.print(f" Longest monologue: {s.longest_monologue:.0f}s")
|
|
84
|
+
console.print()
|
ffx/commands/summary.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import typer
|
|
4
|
+
from ffx.config import Config
|
|
5
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
6
|
+
from ffx.output import json_error
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def summary(
|
|
10
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
11
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
12
|
+
) -> None:
|
|
13
|
+
"""Show the AI-generated summary for a meeting."""
|
|
14
|
+
json_mode = not table
|
|
15
|
+
config = Config()
|
|
16
|
+
if not config.api_key:
|
|
17
|
+
if json_mode:
|
|
18
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
19
|
+
else:
|
|
20
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
21
|
+
raise typer.Exit(2)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
client = FirefliesClient(config.api_key)
|
|
25
|
+
t = client.get_transcript(transcript_id)
|
|
26
|
+
except AuthError:
|
|
27
|
+
if json_mode:
|
|
28
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
29
|
+
else:
|
|
30
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
31
|
+
raise typer.Exit(2)
|
|
32
|
+
except ApiError as e:
|
|
33
|
+
if json_mode:
|
|
34
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
35
|
+
else:
|
|
36
|
+
typer.echo(f"API error: {e}", err=True)
|
|
37
|
+
raise typer.Exit(2)
|
|
38
|
+
|
|
39
|
+
if t is None:
|
|
40
|
+
if json_mode:
|
|
41
|
+
typer.echo(json_error("Transcript not found", "NOT_FOUND"))
|
|
42
|
+
else:
|
|
43
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
44
|
+
raise typer.Exit(3)
|
|
45
|
+
|
|
46
|
+
s = t.summary
|
|
47
|
+
has_content = s and (s.overview or s.gist or s.keywords or s.action_items_list or s.topics_list)
|
|
48
|
+
if not has_content:
|
|
49
|
+
if json_mode:
|
|
50
|
+
typer.echo(json.dumps({"id": t.id, "title": t.title, "summary": None}, indent=2))
|
|
51
|
+
else:
|
|
52
|
+
typer.echo("No summary available for this meeting.")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if json_mode:
|
|
56
|
+
typer.echo(json.dumps({
|
|
57
|
+
"id": t.id,
|
|
58
|
+
"title": t.title,
|
|
59
|
+
"overview": t.summary.overview,
|
|
60
|
+
"gist": t.summary.gist,
|
|
61
|
+
"keywords": t.summary.keywords,
|
|
62
|
+
"topics_discussed": t.summary.topics_list,
|
|
63
|
+
"action_items": t.summary.action_items_list,
|
|
64
|
+
}, indent=2))
|
|
65
|
+
else:
|
|
66
|
+
typer.echo(f"\n{t.title}\n")
|
|
67
|
+
if t.summary.overview:
|
|
68
|
+
typer.echo(f"Overview:\n{t.summary.overview}\n")
|
|
69
|
+
if t.summary.gist:
|
|
70
|
+
typer.echo(f"Gist: {t.summary.gist}\n")
|
|
71
|
+
if t.summary.keywords:
|
|
72
|
+
typer.echo(f"Keywords: {', '.join(t.summary.keywords)}\n")
|
|
73
|
+
if t.summary.topics_list:
|
|
74
|
+
typer.echo(f"Topics: {', '.join(t.summary.topics_list)}\n")
|
|
75
|
+
if t.summary.action_items_list:
|
|
76
|
+
typer.echo("Action items:")
|
|
77
|
+
for item in t.summary.action_items_list:
|
|
78
|
+
typer.echo(f" - {item}")
|
ffx/commands/topics.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ffx.config import Config
|
|
7
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
8
|
+
from ffx.output import json_envelope, json_error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def topics(
|
|
12
|
+
transcript_id: Optional[str] = typer.Argument(None, help="Transcript ID (omit for recent meetings)"),
|
|
13
|
+
days: Optional[int] = typer.Option(7, "--days", help="Last N days (when no ID given)"),
|
|
14
|
+
limit: int = typer.Option(20, "--limit"),
|
|
15
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
16
|
+
) -> None:
|
|
17
|
+
"""List topics discussed in meetings."""
|
|
18
|
+
json_mode = not table
|
|
19
|
+
config = Config()
|
|
20
|
+
if not config.api_key:
|
|
21
|
+
if json_mode:
|
|
22
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
23
|
+
else:
|
|
24
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
25
|
+
raise typer.Exit(2)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
client = FirefliesClient(config.api_key)
|
|
29
|
+
|
|
30
|
+
if transcript_id:
|
|
31
|
+
t = client.get_transcript(transcript_id)
|
|
32
|
+
if t is None:
|
|
33
|
+
if json_mode:
|
|
34
|
+
typer.echo(json_error("Transcript not found", "NOT_FOUND"))
|
|
35
|
+
else:
|
|
36
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
37
|
+
raise typer.Exit(3)
|
|
38
|
+
transcripts = [t]
|
|
39
|
+
else:
|
|
40
|
+
from_date = None
|
|
41
|
+
if days:
|
|
42
|
+
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
43
|
+
transcripts = client.list_transcripts(limit=limit, from_date=from_date)
|
|
44
|
+
|
|
45
|
+
except AuthError:
|
|
46
|
+
if json_mode:
|
|
47
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
48
|
+
else:
|
|
49
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
50
|
+
raise typer.Exit(2)
|
|
51
|
+
except ApiError as e:
|
|
52
|
+
if json_mode:
|
|
53
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
54
|
+
else:
|
|
55
|
+
typer.echo(f"API error: {e}", err=True)
|
|
56
|
+
raise typer.Exit(2)
|
|
57
|
+
|
|
58
|
+
all_topics: dict[str, list[str]] = {}
|
|
59
|
+
for t in transcripts:
|
|
60
|
+
topic_list = t.summary.topics_list if t.summary else []
|
|
61
|
+
if not topic_list and t.summary and t.summary.keywords:
|
|
62
|
+
topic_list = t.summary.keywords
|
|
63
|
+
for topic in topic_list:
|
|
64
|
+
all_topics.setdefault(topic, []).append(t.title)
|
|
65
|
+
|
|
66
|
+
if not all_topics:
|
|
67
|
+
if json_mode:
|
|
68
|
+
typer.echo(json_envelope([], {"days": days}))
|
|
69
|
+
else:
|
|
70
|
+
typer.echo("No topics found.")
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
if json_mode:
|
|
74
|
+
results = [
|
|
75
|
+
{"topic": topic, "count": len(meetings), "meetings": meetings}
|
|
76
|
+
for topic, meetings in sorted(all_topics.items(), key=lambda x: -len(x[1]))
|
|
77
|
+
]
|
|
78
|
+
typer.echo(json_envelope(results, {"days": days}))
|
|
79
|
+
else:
|
|
80
|
+
for topic, meetings in sorted(all_topics.items(), key=lambda x: -len(x[1])):
|
|
81
|
+
count = f" ({len(meetings)})" if len(meetings) > 1 else ""
|
|
82
|
+
typer.echo(f" {topic}{count}")
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from ffx.config import Config
|
|
6
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
7
|
+
from ffx.output import json_error, console
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def transcript(
|
|
11
|
+
transcript_id: str = typer.Argument(..., help="Transcript ID"),
|
|
12
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
13
|
+
timestamps: bool = typer.Option(True, "--timestamps/--no-timestamps", help="Show timestamps"),
|
|
14
|
+
speakers: bool = typer.Option(True, "--speakers/--no-speakers", help="Show speaker names"),
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Show the full transcript text with speaker names and timestamps."""
|
|
17
|
+
json_mode = not table
|
|
18
|
+
config = Config()
|
|
19
|
+
if not config.api_key:
|
|
20
|
+
if json_mode:
|
|
21
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
22
|
+
else:
|
|
23
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
24
|
+
raise typer.Exit(2)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
client = FirefliesClient(config.api_key)
|
|
28
|
+
t = client.get_transcript_with_sentences(transcript_id)
|
|
29
|
+
except AuthError:
|
|
30
|
+
if json_mode:
|
|
31
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
32
|
+
else:
|
|
33
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
34
|
+
raise typer.Exit(2)
|
|
35
|
+
except ApiError as e:
|
|
36
|
+
if json_mode:
|
|
37
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
38
|
+
else:
|
|
39
|
+
typer.echo(f"API error: {e}", err=True)
|
|
40
|
+
raise typer.Exit(2)
|
|
41
|
+
|
|
42
|
+
if t is None:
|
|
43
|
+
if json_mode:
|
|
44
|
+
typer.echo(json_error("Transcript not found", "NOT_FOUND"))
|
|
45
|
+
else:
|
|
46
|
+
typer.echo(f"Transcript '{transcript_id}' not found.", err=True)
|
|
47
|
+
raise typer.Exit(3)
|
|
48
|
+
|
|
49
|
+
if not t.sentences:
|
|
50
|
+
if json_mode:
|
|
51
|
+
typer.echo(json.dumps({"id": t.id, "title": t.title, "sentences": []}, indent=2))
|
|
52
|
+
else:
|
|
53
|
+
typer.echo("No transcript text available for this meeting.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if json_mode:
|
|
57
|
+
typer.echo(json.dumps({
|
|
58
|
+
"id": t.id,
|
|
59
|
+
"title": t.title,
|
|
60
|
+
"date": t.display_date,
|
|
61
|
+
"sentences": [
|
|
62
|
+
{
|
|
63
|
+
"index": s.index,
|
|
64
|
+
"speaker": s.speaker_name,
|
|
65
|
+
"text": s.text,
|
|
66
|
+
"start_time": s.start_time,
|
|
67
|
+
"end_time": s.end_time,
|
|
68
|
+
}
|
|
69
|
+
for s in t.sentences
|
|
70
|
+
],
|
|
71
|
+
}, indent=2))
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
console.print(f"\n[bold]{t.title}[/bold] [dim]{t.display_date}[/dim]\n")
|
|
75
|
+
|
|
76
|
+
last_speaker = None
|
|
77
|
+
for s in t.sentences:
|
|
78
|
+
parts = []
|
|
79
|
+
if timestamps:
|
|
80
|
+
parts.append(f"[dim]{s.timestamp}[/dim]")
|
|
81
|
+
if speakers and s.speaker_name != last_speaker:
|
|
82
|
+
parts.append(f"[bold]{s.speaker_name}:[/bold]")
|
|
83
|
+
last_speaker = s.speaker_name
|
|
84
|
+
elif speakers:
|
|
85
|
+
parts.append(" " * (len(s.speaker_name) + 1))
|
|
86
|
+
|
|
87
|
+
prefix = " ".join(parts)
|
|
88
|
+
if prefix:
|
|
89
|
+
console.print(f"{prefix} {s.text}")
|
|
90
|
+
else:
|
|
91
|
+
console.print(s.text)
|
ffx/commands/week.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from ffx.config import Config
|
|
7
|
+
from ffx.api_client import FirefliesClient, AuthError, ApiError
|
|
8
|
+
from ffx.output import json_envelope, json_error, console
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _meeting_summary(transcripts, period_label: str, json_mode: bool) -> None:
|
|
12
|
+
if not transcripts:
|
|
13
|
+
if json_mode:
|
|
14
|
+
typer.echo(json_envelope([], {"period": period_label}))
|
|
15
|
+
else:
|
|
16
|
+
typer.echo(f"No meetings found for {period_label}.")
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
total_duration = sum(t.duration or 0 for t in transcripts)
|
|
20
|
+
total_hours = total_duration / 3600
|
|
21
|
+
|
|
22
|
+
all_action_items = []
|
|
23
|
+
all_topics = {}
|
|
24
|
+
speaker_time: dict[str, float] = {}
|
|
25
|
+
|
|
26
|
+
for t in transcripts:
|
|
27
|
+
all_action_items.extend(t.action_items)
|
|
28
|
+
if t.summary:
|
|
29
|
+
for topic in t.summary.topics_list:
|
|
30
|
+
all_topics[topic] = all_topics.get(topic, 0) + 1
|
|
31
|
+
for kw in t.summary.keywords:
|
|
32
|
+
all_topics[kw] = all_topics.get(kw, 0) + 1
|
|
33
|
+
for s in t.speakers:
|
|
34
|
+
speaker_time[s.name] = speaker_time.get(s.name, 0) + s.duration
|
|
35
|
+
|
|
36
|
+
if json_mode:
|
|
37
|
+
typer.echo(json.dumps({
|
|
38
|
+
"period": period_label,
|
|
39
|
+
"meeting_count": len(transcripts),
|
|
40
|
+
"total_hours": round(total_hours, 1),
|
|
41
|
+
"meetings": [
|
|
42
|
+
{"id": t.id, "title": t.title, "date": t.display_date, "duration_seconds": t.duration}
|
|
43
|
+
for t in transcripts
|
|
44
|
+
],
|
|
45
|
+
"action_items_count": len(all_action_items),
|
|
46
|
+
"top_topics": sorted(all_topics.items(), key=lambda x: -x[1])[:10],
|
|
47
|
+
"speaker_time": {k: round(v, 1) for k, v in sorted(speaker_time.items(), key=lambda x: -x[1])},
|
|
48
|
+
}, indent=2))
|
|
49
|
+
else:
|
|
50
|
+
console.print(f"\n[bold]{period_label}[/bold]\n")
|
|
51
|
+
console.print(f" {len(transcripts)} meetings | {total_hours:.1f} hours | {len(all_action_items)} action items\n")
|
|
52
|
+
|
|
53
|
+
console.print(" [bold]Meetings:[/bold]")
|
|
54
|
+
for t in transcripts:
|
|
55
|
+
dur = f"{(t.duration or 0) // 60}m"
|
|
56
|
+
console.print(f" {t.display_date} {t.title} ({dur})")
|
|
57
|
+
console.print()
|
|
58
|
+
|
|
59
|
+
if all_topics:
|
|
60
|
+
top = sorted(all_topics.items(), key=lambda x: -x[1])[:8]
|
|
61
|
+
topics_str = ", ".join(f"{t}" for t, c in top)
|
|
62
|
+
console.print(f" [bold]Top topics:[/bold] {topics_str}\n")
|
|
63
|
+
|
|
64
|
+
if speaker_time:
|
|
65
|
+
console.print(" [bold]Time by speaker:[/bold]")
|
|
66
|
+
total = sum(speaker_time.values())
|
|
67
|
+
for name, secs in sorted(speaker_time.items(), key=lambda x: -x[1]):
|
|
68
|
+
pct = (secs / total * 100) if total > 0 else 0
|
|
69
|
+
console.print(f" {name}: {secs / 60:.0f}m ({pct:.0f}%)")
|
|
70
|
+
console.print()
|
|
71
|
+
|
|
72
|
+
if all_action_items:
|
|
73
|
+
console.print(f" [bold]Action items ({len(all_action_items)}):[/bold]")
|
|
74
|
+
for i, item in enumerate(all_action_items[:10], 1):
|
|
75
|
+
typer.echo(f" {i}. {item.text}")
|
|
76
|
+
if len(all_action_items) > 10:
|
|
77
|
+
typer.echo(f" ... and {len(all_action_items) - 10} more")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def week(
|
|
81
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Show a summary of this week's meetings."""
|
|
84
|
+
_run_period(7, "This Week", not table)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def month(
|
|
88
|
+
table: bool = typer.Option(False, "--table", help="Rich table output"),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Show a summary of this month's meetings."""
|
|
91
|
+
_run_period(30, "This Month", not table)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_period(days: int, label: str, json_mode: bool) -> None:
|
|
95
|
+
config = Config()
|
|
96
|
+
if not config.api_key:
|
|
97
|
+
if json_mode:
|
|
98
|
+
typer.echo(json_error("API key not configured", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
99
|
+
else:
|
|
100
|
+
typer.echo("API key not set. Run: ffx auth", err=True)
|
|
101
|
+
raise typer.Exit(2)
|
|
102
|
+
|
|
103
|
+
from_date = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
client = FirefliesClient(config.api_key)
|
|
107
|
+
transcripts = client.list_transcripts(limit=100, from_date=from_date)
|
|
108
|
+
except AuthError:
|
|
109
|
+
if json_mode:
|
|
110
|
+
typer.echo(json_error("API key may have expired", "UNAUTHENTICATED", "Run: ffx auth"))
|
|
111
|
+
else:
|
|
112
|
+
typer.echo("API key may have expired. Run: ffx auth", err=True)
|
|
113
|
+
raise typer.Exit(2)
|
|
114
|
+
except ApiError as e:
|
|
115
|
+
if json_mode:
|
|
116
|
+
typer.echo(json_error(str(e), "API_ERROR"))
|
|
117
|
+
else:
|
|
118
|
+
typer.echo(f"API error: {e}", err=True)
|
|
119
|
+
raise typer.Exit(2)
|
|
120
|
+
|
|
121
|
+
_meeting_summary(transcripts, label, json_mode)
|
ffx/config.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _ffx_home() -> Path:
|
|
9
|
+
home = os.environ.get("FFX_HOME")
|
|
10
|
+
if home:
|
|
11
|
+
return Path(home)
|
|
12
|
+
return Path.home() / ".ffx"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Config:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._home = _ffx_home()
|
|
18
|
+
self._home.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
self._path = self._home / "config.yaml"
|
|
20
|
+
|
|
21
|
+
def _load(self) -> dict:
|
|
22
|
+
if not self._path.exists():
|
|
23
|
+
return {}
|
|
24
|
+
with open(self._path) as f:
|
|
25
|
+
return yaml.safe_load(f) or {}
|
|
26
|
+
|
|
27
|
+
def _save(self, data: dict) -> None:
|
|
28
|
+
with open(self._path, "w") as f:
|
|
29
|
+
yaml.safe_dump(data, f)
|
|
30
|
+
self._path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
31
|
+
|
|
32
|
+
def get(self, key: str):
|
|
33
|
+
return self._load().get(key)
|
|
34
|
+
|
|
35
|
+
def set(self, key: str, value) -> None:
|
|
36
|
+
data = self._load()
|
|
37
|
+
data[key] = value
|
|
38
|
+
self._save(data)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def api_key(self) -> str | None:
|
|
42
|
+
return os.environ.get("FIREFLIES_API_KEY") or self.get("api_key")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def home(self) -> Path:
|
|
46
|
+
return self._home
|