alice-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.
Files changed (62) hide show
  1. alice_cli/.kiro/settings/lsp.json +198 -0
  2. alice_cli/__init__.py +1 -0
  3. alice_cli/auth.py +64 -0
  4. alice_cli/cli.py +98 -0
  5. alice_cli/commands/__init__.py +1 -0
  6. alice_cli/commands/appraise.py +213 -0
  7. alice_cli/commands/auth.py +197 -0
  8. alice_cli/commands/batch.py +364 -0
  9. alice_cli/commands/chat.py +229 -0
  10. alice_cli/commands/compare.py +310 -0
  11. alice_cli/commands/compose.py +266 -0
  12. alice_cli/commands/config_cmd.py +60 -0
  13. alice_cli/commands/diagnose.py +325 -0
  14. alice_cli/commands/dialog.py +240 -0
  15. alice_cli/commands/get_key.py +51 -0
  16. alice_cli/commands/get_secret.py +87 -0
  17. alice_cli/commands/invoke.py +124 -0
  18. alice_cli/commands/list_aliases.py +24 -0
  19. alice_cli/commands/list_models.py +49 -0
  20. alice_cli/commands/list_secrets.py +77 -0
  21. alice_cli/commands/recall.py +229 -0
  22. alice_cli/commands/run.py +44 -0
  23. alice_cli/commands/status.py +46 -0
  24. alice_cli/commands/summarize.py +173 -0
  25. alice_cli/compose_engine.py +125 -0
  26. alice_cli/config.py +90 -0
  27. alice_cli/console.py +71 -0
  28. alice_cli/errors.py +40 -0
  29. alice_cli/formatting.py +56 -0
  30. alice_cli/locker.py +338 -0
  31. alice_cli/logo.py +29 -0
  32. alice_cli/models.py +252 -0
  33. alice_cli/personality.py +174 -0
  34. alice_cli/pricing.py +33 -0
  35. alice_cli/py.typed +1 -0
  36. alice_cli/save.py +173 -0
  37. alice_cli/secrets.py +89 -0
  38. alice_cli/session_record.py +84 -0
  39. alice_cli/store.py +53 -0
  40. alice_cli/tui/__init__.py +1 -0
  41. alice_cli/tui/app.py +102 -0
  42. alice_cli/tui/screens/__init__.py +1 -0
  43. alice_cli/tui/screens/compose.py +365 -0
  44. alice_cli/tui/screens/get_key.py +88 -0
  45. alice_cli/tui/screens/get_secret.py +111 -0
  46. alice_cli/tui/screens/home.py +92 -0
  47. alice_cli/tui/screens/invoke.py +99 -0
  48. alice_cli/tui/screens/quit.py +112 -0
  49. alice_cli/tui/screens/status.py +119 -0
  50. alice_cli/tui/theme.py +12 -0
  51. alice_cli/tui/theme.tcss +219 -0
  52. alice_cli/tui/widgets/__init__.py +1 -0
  53. alice_cli/tui/widgets/banner.py +76 -0
  54. alice_cli/tui/widgets/clock.py +41 -0
  55. alice_cli/tui/widgets/header_bar.py +26 -0
  56. alice_cli/tui/widgets/output.py +11 -0
  57. alice_cli/tui/widgets/typewriter.py +43 -0
  58. alice_cli/validators.py +36 -0
  59. alice_cli-0.1.0.dist-info/METADATA +309 -0
  60. alice_cli-0.1.0.dist-info/RECORD +62 -0
  61. alice_cli-0.1.0.dist-info/WHEEL +4 -0
  62. alice_cli-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,198 @@
1
+ {
2
+ "languages": {
3
+ "rust": {
4
+ "name": "rust-analyzer",
5
+ "command": "rust-analyzer",
6
+ "args": [],
7
+ "file_extensions": [
8
+ "rs"
9
+ ],
10
+ "project_patterns": [
11
+ "Cargo.toml"
12
+ ],
13
+ "exclude_patterns": [
14
+ "**/target/**"
15
+ ],
16
+ "multi_workspace": false,
17
+ "initialization_options": {
18
+ "cargo": {
19
+ "buildScripts": {
20
+ "enable": true
21
+ }
22
+ },
23
+ "diagnostics": {
24
+ "enable": true,
25
+ "enableExperimental": true
26
+ },
27
+ "workspace": {
28
+ "symbol": {
29
+ "search": {
30
+ "scope": "workspace"
31
+ }
32
+ }
33
+ }
34
+ },
35
+ "request_timeout_secs": 60
36
+ },
37
+ "ruby": {
38
+ "name": "solargraph",
39
+ "command": "solargraph",
40
+ "args": [
41
+ "stdio"
42
+ ],
43
+ "file_extensions": [
44
+ "rb"
45
+ ],
46
+ "project_patterns": [
47
+ "Gemfile",
48
+ "Rakefile"
49
+ ],
50
+ "exclude_patterns": [
51
+ "**/vendor/**",
52
+ "**/tmp/**"
53
+ ],
54
+ "multi_workspace": false,
55
+ "initialization_options": {},
56
+ "request_timeout_secs": 60
57
+ },
58
+ "cpp": {
59
+ "name": "clangd",
60
+ "command": "clangd",
61
+ "args": [
62
+ "--background-index"
63
+ ],
64
+ "file_extensions": [
65
+ "cpp",
66
+ "cc",
67
+ "cxx",
68
+ "c",
69
+ "h",
70
+ "hpp",
71
+ "hxx"
72
+ ],
73
+ "project_patterns": [
74
+ "CMakeLists.txt",
75
+ "compile_commands.json",
76
+ "Makefile"
77
+ ],
78
+ "exclude_patterns": [
79
+ "**/build/**",
80
+ "**/cmake-build-**/**"
81
+ ],
82
+ "multi_workspace": false,
83
+ "initialization_options": {},
84
+ "request_timeout_secs": 60
85
+ },
86
+ "python": {
87
+ "name": "pyright",
88
+ "command": "pyright-langserver",
89
+ "args": [
90
+ "--stdio"
91
+ ],
92
+ "file_extensions": [
93
+ "py"
94
+ ],
95
+ "project_patterns": [
96
+ "pyproject.toml",
97
+ "setup.py",
98
+ "requirements.txt",
99
+ "pyrightconfig.json"
100
+ ],
101
+ "exclude_patterns": [
102
+ "**/__pycache__/**",
103
+ "**/venv/**",
104
+ "**/.venv/**",
105
+ "**/.pytest_cache/**"
106
+ ],
107
+ "multi_workspace": false,
108
+ "initialization_options": {},
109
+ "request_timeout_secs": 60
110
+ },
111
+ "go": {
112
+ "name": "gopls",
113
+ "command": "gopls",
114
+ "args": [],
115
+ "file_extensions": [
116
+ "go"
117
+ ],
118
+ "project_patterns": [
119
+ "go.mod",
120
+ "go.sum"
121
+ ],
122
+ "exclude_patterns": [
123
+ "**/vendor/**"
124
+ ],
125
+ "multi_workspace": false,
126
+ "initialization_options": {
127
+ "usePlaceholders": true,
128
+ "completeUnimported": true
129
+ },
130
+ "request_timeout_secs": 60
131
+ },
132
+ "java": {
133
+ "name": "jdtls",
134
+ "command": "jdtls",
135
+ "args": [],
136
+ "file_extensions": [
137
+ "java"
138
+ ],
139
+ "project_patterns": [
140
+ "pom.xml",
141
+ "build.gradle",
142
+ "build.gradle.kts",
143
+ ".project"
144
+ ],
145
+ "exclude_patterns": [
146
+ "**/target/**",
147
+ "**/build/**",
148
+ "**/.gradle/**"
149
+ ],
150
+ "multi_workspace": false,
151
+ "initialization_options": {
152
+ "settings": {
153
+ "java": {
154
+ "compile": {
155
+ "nullAnalysis": {
156
+ "mode": "automatic"
157
+ }
158
+ },
159
+ "configuration": {
160
+ "annotationProcessing": {
161
+ "enabled": true
162
+ }
163
+ }
164
+ }
165
+ }
166
+ },
167
+ "request_timeout_secs": 60
168
+ },
169
+ "typescript": {
170
+ "name": "typescript-language-server",
171
+ "command": "typescript-language-server",
172
+ "args": [
173
+ "--stdio"
174
+ ],
175
+ "file_extensions": [
176
+ "ts",
177
+ "js",
178
+ "tsx",
179
+ "jsx"
180
+ ],
181
+ "project_patterns": [
182
+ "package.json",
183
+ "tsconfig.json"
184
+ ],
185
+ "exclude_patterns": [
186
+ "**/node_modules/**",
187
+ "**/dist/**"
188
+ ],
189
+ "multi_workspace": false,
190
+ "initialization_options": {
191
+ "preferences": {
192
+ "disableSuggestions": false
193
+ }
194
+ },
195
+ "request_timeout_secs": 60
196
+ }
197
+ }
198
+ }
alice_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
alice_cli/auth.py ADDED
@@ -0,0 +1,64 @@
1
+ """JHED detection from STS/SSO caller identity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import boto3
6
+ from botocore.exceptions import BotoCoreError, ClientError
7
+
8
+ from alice_cli import personality
9
+ from alice_cli.errors import AuthError
10
+ from alice_cli.models import JHEDResult
11
+
12
+
13
+ def detect_identity_from_key(access_key: str | None) -> str:
14
+ """Extract a display name from the Bedrock access key alias.
15
+
16
+ The ``BEDROCK_ACCESS_KEY`` (``service_credential_alias``) follows the
17
+ pattern ``BedrockAPIKey-<JHED>-at-<ACCOUNT>``. We extract the JHED
18
+ portion when the pattern matches, otherwise return the raw value.
19
+
20
+ Returns ``"unknown"`` when the access key is empty / absent.
21
+ """
22
+ if not access_key:
23
+ return "unknown"
24
+
25
+ # Pattern: BedrockAPIKey-<jhed>-at-<account_id>
26
+ if access_key.startswith("BedrockAPIKey-") and "-at-" in access_key:
27
+ return access_key.removeprefix("BedrockAPIKey-").split("-at-", maxsplit=1)[0]
28
+
29
+ return access_key
30
+
31
+
32
+ def detect_jhed(profile: str | None, region: str) -> JHEDResult:
33
+ """Call STS get-caller-identity and extract the JHED from the ARN session name.
34
+
35
+ Args:
36
+ profile: AWS CLI profile name, or None if not provided.
37
+ region: AWS region to use for the STS call.
38
+
39
+ Returns:
40
+ JHEDResult with jhed, session_name, and full ARN.
41
+
42
+ Raises:
43
+ AuthError: If no profile is provided, STS call fails, or JHED cannot be extracted.
44
+ """
45
+ if profile is None:
46
+ raise AuthError(personality.error_no_profile())
47
+
48
+ try:
49
+ session = boto3.Session(profile_name=profile, region_name=region)
50
+ sts = session.client("sts")
51
+ identity = sts.get_caller_identity()
52
+ except (ClientError, BotoCoreError):
53
+ raise AuthError(personality.error_sso_expired(profile))
54
+
55
+ arn: str = identity["Arn"]
56
+ # ARN format: arn:aws:sts::ACCOUNT:assumed-role/ROLE/session_name
57
+ session_name = arn.rsplit("/", maxsplit=1)[-1]
58
+
59
+ if "@" not in session_name:
60
+ raise AuthError(personality.error_jhed_extraction(session_name))
61
+
62
+ jhed = session_name.split("@", maxsplit=1)[0]
63
+
64
+ return JHEDResult(jhed=jhed, session_name=session_name, arn=arn)
alice_cli/cli.py ADDED
@@ -0,0 +1,98 @@
1
+ """Top-level Click group and global options for the alice CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from alice_cli import __version__
8
+ from alice_cli.config import resolve_config
9
+ from alice_cli.errors import AliceCLIError
10
+ from alice_cli.models import CLIConfig
11
+
12
+
13
+ def launch_tui(config: CLIConfig) -> None:
14
+ """Launch the interactive TUI mode.
15
+
16
+ Lazily imports ``textual`` so CLI-only users never pay for the dependency.
17
+ Raises :class:`AliceCLIError` if the ``textual`` extra is not installed.
18
+ """
19
+ try:
20
+ from alice_cli.tui.app import AliceTUIApp
21
+ except ImportError:
22
+ raise AliceCLIError(
23
+ "TUI mode needs the 'textual' package.\n"
24
+ " Install it with: poetry install --extras tui"
25
+ )
26
+ app = AliceTUIApp(config)
27
+ app.run()
28
+
29
+
30
+ @click.group(invoke_without_command=True)
31
+ @click.option("--profile", envvar="AWS_PROFILE", default=None, help="AWS CLI profile")
32
+ @click.option("--region", default=None, help="AWS region (default: us-east-1)")
33
+ @click.option("--namespace", default=None, help="Resource namespace (default: drcc)")
34
+ @click.option("--environment", default=None, help="Deployment environment (default: ai)")
35
+ @click.option("--api-key", envvar="BEDROCK_SECRET_KEY", default=None, help="Bedrock API key (skips SSO/Secrets Manager; or set BEDROCK_SECRET_KEY)")
36
+ @click.option("--quiet", is_flag=True, help="Suppress banner and status messages")
37
+ @click.option("--tui", is_flag=True, help="Launch interactive TUI mode")
38
+ @click.version_option(version=__version__)
39
+ @click.pass_context
40
+ def cli(
41
+ ctx: click.Context,
42
+ profile: str | None,
43
+ region: str | None,
44
+ namespace: str | None,
45
+ environment: str | None,
46
+ api_key: str | None,
47
+ quiet: bool,
48
+ tui: bool,
49
+ ) -> None:
50
+ """ALiCE — Your AI research companion at Johns Hopkins."""
51
+ ctx.ensure_object(dict)
52
+ ctx.obj["config"] = resolve_config(
53
+ profile, region, namespace, environment, quiet, api_key
54
+ )
55
+ if tui:
56
+ launch_tui(ctx.obj["config"])
57
+ ctx.exit()
58
+
59
+
60
+ from alice_cli.commands.appraise import appraise_cmd # noqa: E402
61
+ from alice_cli.commands.auth import auth # noqa: E402
62
+ from alice_cli.commands.batch import batch_cmd # noqa: E402
63
+ from alice_cli.commands.chat import chat_cmd # noqa: E402
64
+ from alice_cli.commands.compare import compare_cmd # noqa: E402
65
+ from alice_cli.commands.compose import compose # noqa: E402
66
+ from alice_cli.commands.config_cmd import config_cmd # noqa: E402
67
+ from alice_cli.commands.diagnose import diagnose_cmd # noqa: E402
68
+ from alice_cli.commands.dialog import dialog_cmd # noqa: E402
69
+ from alice_cli.commands.get_key import get_key # noqa: E402
70
+ from alice_cli.commands.get_secret import get_secret # noqa: E402
71
+ from alice_cli.commands.invoke import invoke_cmd # noqa: E402
72
+ from alice_cli.commands.list_aliases import list_aliases # noqa: E402
73
+ from alice_cli.commands.list_models import list_models # noqa: E402
74
+ from alice_cli.commands.list_secrets import list_secrets # noqa: E402
75
+ from alice_cli.commands.recall import recall_cmd # noqa: E402
76
+ from alice_cli.commands.run import run_cmd # noqa: E402
77
+ from alice_cli.commands.status import status # noqa: E402
78
+ from alice_cli.commands.summarize import summarize_cmd # noqa: E402
79
+
80
+ cli.add_command(appraise_cmd)
81
+ cli.add_command(auth)
82
+ cli.add_command(batch_cmd)
83
+ cli.add_command(chat_cmd)
84
+ cli.add_command(compare_cmd)
85
+ cli.add_command(compose)
86
+ cli.add_command(config_cmd)
87
+ cli.add_command(diagnose_cmd)
88
+ cli.add_command(dialog_cmd)
89
+ cli.add_command(get_key)
90
+ cli.add_command(get_secret)
91
+ cli.add_command(invoke_cmd)
92
+ cli.add_command(list_aliases)
93
+ cli.add_command(list_models)
94
+ cli.add_command(list_secrets)
95
+ cli.add_command(recall_cmd)
96
+ cli.add_command(run_cmd)
97
+ cli.add_command(status)
98
+ cli.add_command(summarize_cmd)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,213 @@
1
+ """alice appraise — Token usage and cost reporting from the Cloud Locker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ import click
8
+ from rich.table import Table
9
+
10
+ from alice_cli import personality
11
+ from alice_cli.auth import detect_identity_from_key, detect_jhed
12
+ from alice_cli.commands.recall import filter_entries
13
+ from alice_cli.console import (
14
+ err_console,
15
+ print_banner,
16
+ print_status,
17
+ print_success,
18
+ spinner,
19
+ )
20
+ from alice_cli.locker import CloudLocker
21
+ from alice_cli.models import IndexEntry, model_help_text
22
+ from alice_cli.pricing import estimate_cost, format_cost
23
+
24
+
25
+ @click.command("appraise")
26
+ @click.option("--since", default=None, help="Include sessions on or after this date (YYYY-MM-DD)")
27
+ @click.option("--model", default=None, help="Filter by model alias or ID (substring match)")
28
+ @click.option(
29
+ "--detailed",
30
+ is_flag=True,
31
+ default=False,
32
+ help="Show per-session token counts instead of per-model aggregates",
33
+ )
34
+ @click.pass_context
35
+ def appraise_cmd(
36
+ ctx: click.Context,
37
+ since: str | None,
38
+ model: str | None,
39
+ detailed: bool,
40
+ ) -> None:
41
+ """Show token usage and approximate costs for past sessions.
42
+
43
+ \b
44
+ Examples:
45
+ alice appraise
46
+ alice appraise --since 2025-01-01
47
+ alice appraise --model sonnet
48
+ alice appraise --detailed
49
+ alice appraise --since 2025-06-01 --model haiku --detailed
50
+ """
51
+ config = ctx.obj["config"]
52
+
53
+ print_banner(personality.BANNER, config.quiet)
54
+
55
+ # ── Authenticate ──────────────────────────────────────────────────
56
+ jhed: str
57
+
58
+ if config.bedrock_secret_key and not config.profile:
59
+ jhed = detect_identity_from_key(config.bedrock_access_key)
60
+ print_status(personality.status_using_api_key(jhed), config.quiet)
61
+ else:
62
+ with spinner(personality.status_detecting_identity(), config.quiet):
63
+ result = detect_jhed(config.profile, config.region)
64
+ jhed = result.jhed
65
+ print_status(personality.status_identity_detected(jhed), config.quiet)
66
+
67
+ locker = CloudLocker(config=config, jhed=jhed, bucket="jh-drcc-alice-memory")
68
+
69
+ # ── Load index and filter ─────────────────────────────────────────
70
+ with spinner("Loading session index…", config.quiet):
71
+ index = locker.load_index()
72
+
73
+ # Reuse recall's filter_entries for --since and --model filtering.
74
+ # We pass a very large limit so nothing is truncated.
75
+ filtered = filter_entries(
76
+ index.entries,
77
+ since=since,
78
+ model=model,
79
+ limit=0, # 0 means no limit in filter_entries
80
+ )
81
+
82
+ # Keep only entries that have token usage data
83
+ entries_with_tokens = [e for e in filtered if e.tokens is not None]
84
+
85
+ # If we matched sessions but none have tokens, try backfilling from
86
+ # the full session records stored in S3 / local cache.
87
+ if filtered and not entries_with_tokens:
88
+ with spinner("Backfilling token data from session records…", config.quiet):
89
+ filtered, filled = locker.backfill_tokens(filtered)
90
+ if filled:
91
+ err_console.print(f" [dim]Backfilled token data for {filled} session(s).[/dim]")
92
+ entries_with_tokens = [e for e in filtered if e.tokens is not None]
93
+
94
+ if not entries_with_tokens:
95
+ if filtered:
96
+ err_console.print(
97
+ f" [dim]Found {len(filtered)} session(s) but none have token usage recorded.[/dim]"
98
+ )
99
+ else:
100
+ err_console.print(" [dim]No matching sessions found.[/dim]")
101
+ return
102
+
103
+ if detailed:
104
+ _display_detailed(entries_with_tokens, config.quiet)
105
+ else:
106
+ _display_aggregated(entries_with_tokens, config.quiet)
107
+
108
+
109
+ def _display_aggregated(entries: list[IndexEntry], quiet: bool) -> None:
110
+ """Display per-model aggregate token usage with a summary row."""
111
+ # Aggregate by model
112
+ model_stats: dict[str, list[int]] = defaultdict(lambda: [0, 0, 0])
113
+
114
+ for entry in entries:
115
+ assert entry.tokens is not None
116
+ stats = model_stats[entry.model]
117
+ stats[0] += entry.tokens.input
118
+ stats[1] += entry.tokens.output
119
+ stats[2] += entry.tokens.total
120
+
121
+ table = Table(title="Token Usage by Model", show_lines=False)
122
+ table.add_column("Model", style="green")
123
+ table.add_column("Input Tokens", style="cyan", justify="right")
124
+ table.add_column("Output Tokens", style="cyan", justify="right")
125
+ table.add_column("Total Tokens", style="cyan", justify="right")
126
+ table.add_column("Est. Cost", style="yellow", justify="right")
127
+
128
+ total_input = 0
129
+ total_output = 0
130
+ total_total = 0
131
+ total_cost = 0.0
132
+
133
+ for model_name, (inp, out, tot) in sorted(model_stats.items()):
134
+ cost = estimate_cost(model_name, inp, out)
135
+ total_input += inp
136
+ total_output += out
137
+ total_total += tot
138
+ total_cost += cost
139
+ table.add_row(
140
+ model_name,
141
+ f"{inp:,}",
142
+ f"{out:,}",
143
+ f"{tot:,}",
144
+ format_cost(cost),
145
+ )
146
+
147
+ # Summary row
148
+ table.add_section()
149
+ table.add_row(
150
+ "[bold]Total[/bold]",
151
+ f"[bold]{total_input:,}[/bold]",
152
+ f"[bold]{total_output:,}[/bold]",
153
+ f"[bold]{total_total:,}[/bold]",
154
+ f"[bold]{format_cost(total_cost)}[/bold]",
155
+ )
156
+
157
+ err_console.print(table)
158
+ print_success(
159
+ f"{len(entries)} session(s), {total_total:,} tokens, {format_cost(total_cost)}",
160
+ quiet,
161
+ )
162
+
163
+
164
+ def _display_detailed(entries: list[IndexEntry], quiet: bool) -> None:
165
+ """Display per-session token usage with a summary row."""
166
+ table = Table(title="Token Usage by Session", show_lines=False)
167
+ table.add_column("Timestamp", style="cyan", no_wrap=True)
168
+ table.add_column("Type", style="magenta")
169
+ table.add_column("Model", style="green")
170
+ table.add_column("Input Tokens", style="cyan", justify="right")
171
+ table.add_column("Output Tokens", style="cyan", justify="right")
172
+ table.add_column("Total Tokens", style="cyan", justify="right")
173
+ table.add_column("Est. Cost", style="yellow", justify="right")
174
+
175
+ total_input = 0
176
+ total_output = 0
177
+ total_total = 0
178
+ total_cost = 0.0
179
+
180
+ for entry in entries:
181
+ assert entry.tokens is not None
182
+ cost = estimate_cost(entry.model, entry.tokens.input, entry.tokens.output)
183
+ total_input += entry.tokens.input
184
+ total_output += entry.tokens.output
185
+ total_total += entry.tokens.total
186
+ total_cost += cost
187
+ table.add_row(
188
+ entry.timestamp[:19],
189
+ entry.type,
190
+ entry.model,
191
+ f"{entry.tokens.input:,}",
192
+ f"{entry.tokens.output:,}",
193
+ f"{entry.tokens.total:,}",
194
+ format_cost(cost),
195
+ )
196
+
197
+ # Summary row
198
+ table.add_section()
199
+ table.add_row(
200
+ "[bold]Total[/bold]",
201
+ "",
202
+ "",
203
+ f"[bold]{total_input:,}[/bold]",
204
+ f"[bold]{total_output:,}[/bold]",
205
+ f"[bold]{total_total:,}[/bold]",
206
+ f"[bold]{format_cost(total_cost)}[/bold]",
207
+ )
208
+
209
+ err_console.print(table)
210
+ print_success(
211
+ f"{len(entries)} session(s), {total_total:,} tokens, {format_cost(total_cost)}",
212
+ quiet,
213
+ )