agentfluent 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 (39) hide show
  1. agentfluent/__init__.py +8 -0
  2. agentfluent/agents/__init__.py +0 -0
  3. agentfluent/agents/extractor.py +78 -0
  4. agentfluent/agents/models.py +71 -0
  5. agentfluent/analytics/__init__.py +0 -0
  6. agentfluent/analytics/agent_metrics.py +127 -0
  7. agentfluent/analytics/pipeline.py +278 -0
  8. agentfluent/analytics/pricing.py +96 -0
  9. agentfluent/analytics/tokens.py +137 -0
  10. agentfluent/analytics/tools.py +93 -0
  11. agentfluent/cli/__init__.py +0 -0
  12. agentfluent/cli/commands/__init__.py +0 -0
  13. agentfluent/cli/commands/analyze.py +162 -0
  14. agentfluent/cli/commands/config_check.py +110 -0
  15. agentfluent/cli/commands/list_cmd.py +165 -0
  16. agentfluent/cli/exit_codes.py +18 -0
  17. agentfluent/cli/formatters/__init__.py +0 -0
  18. agentfluent/cli/formatters/helpers.py +59 -0
  19. agentfluent/cli/formatters/json_output.py +60 -0
  20. agentfluent/cli/formatters/table.py +358 -0
  21. agentfluent/cli/main.py +63 -0
  22. agentfluent/config/__init__.py +31 -0
  23. agentfluent/config/models.py +118 -0
  24. agentfluent/config/scanner.py +146 -0
  25. agentfluent/config/scoring.py +299 -0
  26. agentfluent/core/__init__.py +0 -0
  27. agentfluent/core/discovery.py +158 -0
  28. agentfluent/core/parser.py +251 -0
  29. agentfluent/core/session.py +133 -0
  30. agentfluent/diagnostics/__init__.py +51 -0
  31. agentfluent/diagnostics/correlator.py +248 -0
  32. agentfluent/diagnostics/models.py +75 -0
  33. agentfluent/diagnostics/signals.py +162 -0
  34. agentfluent/py.typed +0 -0
  35. agentfluent-0.1.0.dist-info/METADATA +52 -0
  36. agentfluent-0.1.0.dist-info/RECORD +39 -0
  37. agentfluent-0.1.0.dist-info/WHEEL +4 -0
  38. agentfluent-0.1.0.dist-info/entry_points.txt +3 -0
  39. agentfluent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,137 @@
1
+ """Token and cost analytics for session analysis.
2
+
3
+ Computes token usage totals, dollar costs, and cache efficiency from
4
+ parsed session messages. Handles mixed-model sessions with per-model
5
+ cost breakdown.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from agentfluent.analytics.pricing import compute_cost, get_pricing
13
+ from agentfluent.core.session import SessionMessage
14
+
15
+
16
+ @dataclass
17
+ class ModelTokenBreakdown:
18
+ """Token counts and cost for a single model within a session."""
19
+
20
+ model: str
21
+ input_tokens: int = 0
22
+ output_tokens: int = 0
23
+ cache_creation_input_tokens: int = 0
24
+ cache_read_input_tokens: int = 0
25
+ cost: float = 0.0
26
+
27
+ @property
28
+ def total_tokens(self) -> int:
29
+ return (
30
+ self.input_tokens
31
+ + self.output_tokens
32
+ + self.cache_creation_input_tokens
33
+ + self.cache_read_input_tokens
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class TokenMetrics:
39
+ """Aggregated token usage and cost metrics for a session."""
40
+
41
+ input_tokens: int = 0
42
+ output_tokens: int = 0
43
+ cache_creation_input_tokens: int = 0
44
+ cache_read_input_tokens: int = 0
45
+ total_cost: float = 0.0
46
+ cache_efficiency: float = 0.0
47
+ """Cache efficiency as a percentage (0-100).
48
+ Formula: cache_read / (cache_read + input + cache_creation) * 100."""
49
+
50
+ api_call_count: int = 0
51
+ """Number of deduplicated assistant messages (API calls)."""
52
+
53
+ by_model: dict[str, ModelTokenBreakdown] = field(default_factory=dict)
54
+ """Per-model token breakdown, keyed by model name."""
55
+
56
+ @property
57
+ def total_tokens(self) -> int:
58
+ return (
59
+ self.input_tokens
60
+ + self.output_tokens
61
+ + self.cache_creation_input_tokens
62
+ + self.cache_read_input_tokens
63
+ )
64
+
65
+
66
+ def compute_token_metrics(messages: list[SessionMessage]) -> TokenMetrics:
67
+ """Compute token usage totals and dollar costs from session messages.
68
+
69
+ Processes only assistant messages that have usage data. Messages should
70
+ already be deduplicated (streaming snapshots collapsed).
71
+
72
+ Handles mixed-model sessions by computing per-model breakdowns and
73
+ summing costs across models.
74
+
75
+ Args:
76
+ messages: Parsed and deduplicated session messages.
77
+
78
+ Returns:
79
+ TokenMetrics with totals, per-model breakdown, cost, and cache efficiency.
80
+ """
81
+ by_model: dict[str, ModelTokenBreakdown] = {}
82
+ api_call_count = 0
83
+
84
+ for msg in messages:
85
+ if msg.type != "assistant" or msg.usage is None:
86
+ continue
87
+
88
+ api_call_count += 1
89
+ model_name = msg.model or "unknown"
90
+ usage = msg.usage
91
+
92
+ breakdown = by_model.get(model_name)
93
+ if breakdown is None:
94
+ breakdown = ModelTokenBreakdown(model=model_name)
95
+ by_model[model_name] = breakdown
96
+
97
+ breakdown.input_tokens += usage.input_tokens
98
+ breakdown.output_tokens += usage.output_tokens
99
+ breakdown.cache_creation_input_tokens += usage.cache_creation_input_tokens
100
+ breakdown.cache_read_input_tokens += usage.cache_read_input_tokens
101
+
102
+ # Compute per-model costs
103
+ total_cost = 0.0
104
+ for breakdown in by_model.values():
105
+ pricing = get_pricing(breakdown.model)
106
+ if pricing:
107
+ breakdown.cost = compute_cost(
108
+ pricing,
109
+ breakdown.input_tokens,
110
+ breakdown.output_tokens,
111
+ breakdown.cache_creation_input_tokens,
112
+ breakdown.cache_read_input_tokens,
113
+ )
114
+ total_cost += breakdown.cost
115
+
116
+ # Aggregate totals
117
+ total_input = sum(b.input_tokens for b in by_model.values())
118
+ total_output = sum(b.output_tokens for b in by_model.values())
119
+ total_cache_creation = sum(b.cache_creation_input_tokens for b in by_model.values())
120
+ total_cache_read = sum(b.cache_read_input_tokens for b in by_model.values())
121
+
122
+ # Cache efficiency: cache_read / (cache_read + input + cache_creation)
123
+ cache_denominator = total_cache_read + total_input + total_cache_creation
124
+ cache_efficiency = (
125
+ round(total_cache_read / cache_denominator * 100, 1) if cache_denominator > 0 else 0.0
126
+ )
127
+
128
+ return TokenMetrics(
129
+ input_tokens=total_input,
130
+ output_tokens=total_output,
131
+ cache_creation_input_tokens=total_cache_creation,
132
+ cache_read_input_tokens=total_cache_read,
133
+ total_cost=total_cost,
134
+ cache_efficiency=cache_efficiency,
135
+ api_call_count=api_call_count,
136
+ by_model=by_model,
137
+ )
@@ -0,0 +1,93 @@
1
+ """Tool pattern analytics for session analysis.
2
+
3
+ Counts tool call frequency, computes unique tool count, and measures
4
+ tool concentration across a session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from agentfluent.core.session import SessionMessage
12
+
13
+
14
+ @dataclass
15
+ class ToolMetrics:
16
+ """Tool usage metrics for a session."""
17
+
18
+ tool_frequency: dict[str, int] = field(default_factory=dict)
19
+ """Tool call counts sorted by frequency (descending)."""
20
+
21
+ unique_tool_count: int = 0
22
+ """Number of distinct tools used."""
23
+
24
+ total_tool_calls: int = 0
25
+ """Total number of tool calls across all tools."""
26
+
27
+ concentration: list[ConcentrationEntry] = field(default_factory=list)
28
+ """Cumulative concentration: each entry shows how many top tools
29
+ account for what percentage of total calls."""
30
+
31
+
32
+ @dataclass
33
+ class ConcentrationEntry:
34
+ """A single point in the tool concentration curve."""
35
+
36
+ top_n: int
37
+ """Number of top tools."""
38
+
39
+ call_count: int
40
+ """Total calls from those top_n tools."""
41
+
42
+ percentage: float
43
+ """Percentage of total calls (0-100)."""
44
+
45
+
46
+ def compute_tool_metrics(messages: list[SessionMessage]) -> ToolMetrics:
47
+ """Compute tool usage metrics from parsed session messages.
48
+
49
+ Scans assistant messages for tool_use content blocks and aggregates
50
+ call counts by tool name.
51
+
52
+ Args:
53
+ messages: Parsed (and deduplicated) session messages.
54
+
55
+ Returns:
56
+ ToolMetrics with frequency, unique count, and concentration data.
57
+ """
58
+ counts: dict[str, int] = {}
59
+
60
+ for msg in messages:
61
+ if msg.type != "assistant":
62
+ continue
63
+ for block in msg.tool_use_blocks:
64
+ counts[block.name] = counts.get(block.name, 0) + 1
65
+
66
+ if not counts:
67
+ return ToolMetrics()
68
+
69
+ # Sort by frequency descending, then alphabetically for ties
70
+ sorted_tools = sorted(counts.items(), key=lambda x: (-x[1], x[0]))
71
+ sorted_freq = dict(sorted_tools)
72
+
73
+ total = sum(counts.values())
74
+
75
+ # Build concentration curve
76
+ concentration: list[ConcentrationEntry] = []
77
+ cumulative = 0
78
+ for i, (_name, count) in enumerate(sorted_tools, start=1):
79
+ cumulative += count
80
+ concentration.append(
81
+ ConcentrationEntry(
82
+ top_n=i,
83
+ call_count=cumulative,
84
+ percentage=round(cumulative / total * 100, 1),
85
+ )
86
+ )
87
+
88
+ return ToolMetrics(
89
+ tool_frequency=sorted_freq,
90
+ unique_tool_count=len(counts),
91
+ total_tool_calls=total,
92
+ concentration=concentration,
93
+ )
File without changes
File without changes
@@ -0,0 +1,162 @@
1
+ """agentfluent analyze -- compute execution analytics and diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from agentfluent.analytics.pipeline import AnalysisResult, analyze_sessions
11
+ from agentfluent.cli.exit_codes import EXIT_NO_DATA, EXIT_USER_ERROR
12
+ from agentfluent.cli.formatters.helpers import format_cost, format_tokens
13
+ from agentfluent.cli.formatters.json_output import format_json_output
14
+ from agentfluent.cli.formatters.table import format_analysis_table
15
+ from agentfluent.core.discovery import find_project
16
+ from agentfluent.diagnostics import run_diagnostics
17
+
18
+ ANALYZE_EPILOG = """\
19
+ Examples:
20
+
21
+ agentfluent analyze --project codefluent
22
+ Analyze all sessions in the codefluent project.
23
+
24
+ agentfluent analyze --project codefluent --agent pm
25
+ Analyze only PM agent invocations.
26
+
27
+ agentfluent analyze --project codefluent --latest 5 --diagnostics
28
+ Analyze the 5 most recent sessions with behavior diagnostics.
29
+
30
+ agentfluent analyze --project codefluent --format json | jq '.data.token_metrics.total_cost'
31
+ Extract total cost programmatically.
32
+ """
33
+
34
+ app = typer.Typer(help="Analyze agent sessions.")
35
+ console = Console()
36
+ err_console = Console(stderr=True)
37
+
38
+
39
+ def _print_quiet(result: AnalysisResult, project_name: str) -> None:
40
+ """Print a one-line summary."""
41
+ tm = result.token_metrics
42
+ am = result.agent_metrics
43
+ signal_count = len(result.diagnostics.signals) if result.diagnostics else 0
44
+ console.print(
45
+ f"Project {project_name}: "
46
+ f"{format_cost(tm.total_cost)} cost, "
47
+ f"{format_tokens(tm.total_tokens)} tokens, "
48
+ f"{am.total_invocations} agent invocations, "
49
+ f"{signal_count} diagnostic signals"
50
+ )
51
+
52
+
53
+ def _print_json(result: AnalysisResult, *, quiet: bool, project_name: str) -> None:
54
+ """Print JSON output. Quiet emits a minimal summary; default emits the full tree."""
55
+ if quiet:
56
+ tm = result.token_metrics
57
+ am = result.agent_metrics
58
+ signal_count = len(result.diagnostics.signals) if result.diagnostics else 0
59
+ payload: dict[str, object] = {
60
+ "project": project_name,
61
+ "session_count": result.session_count,
62
+ "total_cost": tm.total_cost,
63
+ "total_tokens": tm.total_tokens,
64
+ "total_invocations": am.total_invocations,
65
+ "diagnostic_signal_count": signal_count,
66
+ }
67
+ else:
68
+ payload = result.model_dump(mode="json")
69
+ print(format_json_output("analyze", payload))
70
+
71
+
72
+ @app.callback(invoke_without_command=True, epilog=ANALYZE_EPILOG)
73
+ def analyze(
74
+ project: str = typer.Option(
75
+ ...,
76
+ "--project",
77
+ "-p",
78
+ help="Project slug or display name.",
79
+ ),
80
+ session: Optional[str] = typer.Option( # noqa: UP007, UP045
81
+ None,
82
+ "--session",
83
+ "-s",
84
+ help="Specific session filename to analyze.",
85
+ ),
86
+ agent: Optional[str] = typer.Option( # noqa: UP007, UP045
87
+ None,
88
+ "--agent",
89
+ "-a",
90
+ help="Filter to a specific agent type (e.g., 'pm').",
91
+ ),
92
+ latest: Optional[int] = typer.Option( # noqa: UP007, UP045
93
+ None,
94
+ "--latest",
95
+ "-n",
96
+ help="Analyze only the N most recent sessions.",
97
+ ),
98
+ diagnostics: bool = typer.Option(
99
+ False,
100
+ "--diagnostics",
101
+ "-d",
102
+ help="Show detailed behavior diagnostics.",
103
+ ),
104
+ format: str = typer.Option(
105
+ "table",
106
+ "--format",
107
+ "-f",
108
+ help="Output format: table or json.",
109
+ ),
110
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output."),
111
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Show summary only."),
112
+ ) -> None:
113
+ """Analyze agent sessions for token usage, cost, and behavior diagnostics."""
114
+ if verbose and quiet:
115
+ raise typer.BadParameter("--verbose and --quiet are mutually exclusive")
116
+
117
+ project_info = find_project(project)
118
+ if project_info is None:
119
+ err_console.print(f"[red]Project not found:[/red] {project}")
120
+ err_console.print("Use [bold]agentfluent list[/bold] to see available projects.")
121
+ raise typer.Exit(code=EXIT_USER_ERROR)
122
+
123
+ session_infos = project_info.sessions
124
+ if not session_infos:
125
+ name = project_info.display_name
126
+ err_console.print(f"[yellow]No sessions found for project:[/yellow] {name}")
127
+ raise typer.Exit(code=EXIT_NO_DATA)
128
+
129
+ if session:
130
+ session_infos = [s for s in session_infos if s.filename == session]
131
+ if not session_infos:
132
+ err_console.print(f"[red]Session not found:[/red] {session}")
133
+ raise typer.Exit(code=EXIT_USER_ERROR)
134
+
135
+ if latest is not None and latest > 0:
136
+ session_infos = session_infos[:latest]
137
+
138
+ paths = [s.path for s in session_infos]
139
+
140
+ result = analyze_sessions(paths, agent_filter=agent)
141
+
142
+ all_invocations = [inv for s in result.sessions for inv in s.invocations]
143
+ total_subagent_traces = sum(si.subagent_count for si in session_infos)
144
+
145
+ if all_invocations:
146
+ result.diagnostics = run_diagnostics(
147
+ all_invocations, subagent_trace_count=total_subagent_traces,
148
+ )
149
+ elif result.agent_metrics.total_invocations == 0 and diagnostics:
150
+ console.print(
151
+ "[dim]No agent invocations found -- "
152
+ "diagnostics require agent activity.[/dim]"
153
+ )
154
+
155
+ if format == "json":
156
+ _print_json(result, quiet=quiet, project_name=project_info.display_name)
157
+ elif quiet:
158
+ _print_quiet(result, project_info.display_name)
159
+ else:
160
+ format_analysis_table(
161
+ console, result, verbose=verbose, show_diagnostics=diagnostics,
162
+ )
@@ -0,0 +1,110 @@
1
+ """agentfluent config-check -- assess agent configuration quality."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from agentfluent.cli.exit_codes import EXIT_NO_DATA, EXIT_USER_ERROR
11
+ from agentfluent.cli.formatters.helpers import average_score
12
+ from agentfluent.cli.formatters.json_output import format_json_output
13
+ from agentfluent.cli.formatters.table import format_config_check_table
14
+ from agentfluent.config import assess_agents
15
+ from agentfluent.config.models import ConfigScore
16
+
17
+ CONFIG_CHECK_EPILOG = """\
18
+ Examples:
19
+
20
+ agentfluent config-check
21
+ Score all user and project agent definitions.
22
+
23
+ agentfluent config-check --scope user
24
+ Check only user-level agents in ~/.claude/agents/.
25
+
26
+ agentfluent config-check --agent pm --verbose
27
+ Score a specific agent with detailed recommendations.
28
+
29
+ agentfluent config-check --format json | jq '.data.scores[] | select(.overall_score < 60)'
30
+ Find agents that need improvement.
31
+ """
32
+
33
+ app = typer.Typer(help="Check agent configuration quality.")
34
+ console = Console()
35
+ err_console = Console(stderr=True)
36
+
37
+
38
+ def _print_quiet(scores: list[ConfigScore]) -> None:
39
+ """Print a one-line summary."""
40
+ total_recs = sum(len(s.recommendations) for s in scores)
41
+ console.print(
42
+ f"Agents: {len(scores)} | "
43
+ f"Avg score: {average_score(scores)}/100 | "
44
+ f"Recommendations: {total_recs}"
45
+ )
46
+
47
+
48
+ def _print_json(scores: list[ConfigScore], *, quiet: bool) -> None:
49
+ """Print JSON output. Quiet emits a minimal summary; default emits all scores."""
50
+ if quiet:
51
+ payload: dict[str, object] = {
52
+ "agent_count": len(scores),
53
+ "average_score": average_score(scores),
54
+ "recommendation_count": sum(len(s.recommendations) for s in scores),
55
+ }
56
+ else:
57
+ payload = {"scores": [s.model_dump(mode="json") for s in scores]}
58
+ print(format_json_output("config-check", payload))
59
+
60
+
61
+ @app.callback(invoke_without_command=True, epilog=CONFIG_CHECK_EPILOG)
62
+ def config_check(
63
+ scope: str = typer.Option(
64
+ "all",
65
+ "--scope",
66
+ help="Scanning scope: 'user', 'project', or 'all'.",
67
+ ),
68
+ agent: Optional[str] = typer.Option( # noqa: UP007, UP045
69
+ None,
70
+ "--agent",
71
+ "-a",
72
+ help="Score a specific agent by name.",
73
+ ),
74
+ format: str = typer.Option(
75
+ "table",
76
+ "--format",
77
+ "-f",
78
+ help="Output format: table or json.",
79
+ ),
80
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output."),
81
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Show summary only."),
82
+ ) -> None:
83
+ """Scan agent definitions and score them against best practices."""
84
+ if verbose and quiet:
85
+ raise typer.BadParameter("--verbose and --quiet are mutually exclusive")
86
+
87
+ if scope not in ("user", "project", "all"):
88
+ err_console.print(f"[red]Invalid scope:[/red] {scope}")
89
+ err_console.print("Valid scopes: user, project, all")
90
+ raise typer.Exit(code=EXIT_USER_ERROR)
91
+
92
+ scores = assess_agents(scope, agent_filter=agent)
93
+
94
+ if not scores:
95
+ if agent:
96
+ err_console.print(f"[yellow]No agent found named:[/yellow] {agent}")
97
+ raise typer.Exit(code=EXIT_USER_ERROR)
98
+ err_console.print("[yellow]No agent definition files found.[/yellow]")
99
+ err_console.print(
100
+ "Agent definitions are `.md` files in "
101
+ "`~/.claude/agents/` (user) or `.claude/agents/` (project)."
102
+ )
103
+ raise typer.Exit(code=EXIT_NO_DATA)
104
+
105
+ if format == "json":
106
+ _print_json(scores, quiet=quiet)
107
+ elif quiet:
108
+ _print_quiet(scores)
109
+ else:
110
+ format_config_check_table(console, scores, verbose=verbose)
@@ -0,0 +1,165 @@
1
+ """agentfluent list -- discover projects and sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from agentfluent.cli.exit_codes import EXIT_NO_DATA, EXIT_USER_ERROR
11
+ from agentfluent.cli.formatters.json_output import format_json_output
12
+ from agentfluent.cli.formatters.table import (
13
+ format_projects_table,
14
+ format_sessions_table,
15
+ )
16
+ from agentfluent.core.discovery import (
17
+ ProjectInfo,
18
+ discover_projects,
19
+ find_project,
20
+ )
21
+ from agentfluent.core.parser import parse_session
22
+
23
+ LIST_EPILOG = """\
24
+ Examples:
25
+
26
+ agentfluent list
27
+ List all projects in ~/.claude/projects/.
28
+
29
+ agentfluent list --project codefluent
30
+ List sessions in the codefluent project.
31
+
32
+ agentfluent list --format json | jq '.data.projects[].name'
33
+ Extract project names (command is "list-projects").
34
+
35
+ agentfluent list --project codefluent --format json | jq '.data.sessions[].filename'
36
+ Extract session filenames (command is "list-sessions").
37
+ """
38
+
39
+ app = typer.Typer(help="List projects and sessions.")
40
+ console = Console()
41
+ err_console = Console(stderr=True)
42
+
43
+
44
+ def _discover_or_exit() -> list[ProjectInfo]:
45
+ """Discover projects; print error and exit on failure."""
46
+ try:
47
+ return discover_projects()
48
+ except FileNotFoundError as e:
49
+ err_console.print(f"[red]{e}[/red]")
50
+ raise typer.Exit(code=EXIT_NO_DATA) from None
51
+
52
+
53
+ def _list_projects_table(*, verbose: bool, quiet: bool) -> None:
54
+ """Display all projects as a Rich table."""
55
+ projects = _discover_or_exit()
56
+ if quiet:
57
+ total_sessions = sum(p.session_count for p in projects)
58
+ console.print(f"{len(projects)} projects, {total_sessions} total sessions")
59
+ return
60
+ format_projects_table(console, projects, verbose=verbose)
61
+
62
+
63
+ def _list_projects_json(*, quiet: bool) -> None:
64
+ """Output all projects as JSON."""
65
+ projects = _discover_or_exit()
66
+ if quiet:
67
+ payload: dict[str, object] = {
68
+ "project_count": len(projects),
69
+ "total_sessions": sum(p.session_count for p in projects),
70
+ }
71
+ else:
72
+ payload = {
73
+ "projects": [
74
+ {
75
+ "name": p.display_name,
76
+ "slug": p.slug,
77
+ "session_count": p.session_count,
78
+ "total_size_bytes": p.total_size_bytes,
79
+ "earliest_session": (
80
+ p.earliest_session.isoformat() if p.earliest_session else None
81
+ ),
82
+ "latest_session": (
83
+ p.latest_session.isoformat() if p.latest_session else None
84
+ ),
85
+ }
86
+ for p in projects
87
+ ]
88
+ }
89
+ print(format_json_output("list-projects", payload))
90
+
91
+
92
+ def _find_or_exit(project_slug: str) -> ProjectInfo:
93
+ """Look up a project by slug; print error and exit if not found."""
94
+ project = find_project(project_slug)
95
+ if project is None:
96
+ err_console.print(f"[red]Project not found: {project_slug}[/red]")
97
+ raise typer.Exit(code=EXIT_USER_ERROR)
98
+ return project
99
+
100
+
101
+ def _list_sessions_table(project_slug: str, *, verbose: bool, quiet: bool) -> None:
102
+ """Display sessions for a project as a Rich table."""
103
+ project = _find_or_exit(project_slug)
104
+ if quiet:
105
+ console.print(f"Project {project.display_name}: {len(project.sessions)} sessions")
106
+ return
107
+ sessions = [(s, len(parse_session(s.path))) for s in project.sessions]
108
+ format_sessions_table(console, project.display_name, sessions, verbose=verbose)
109
+
110
+
111
+ def _list_sessions_json(project_slug: str, *, quiet: bool) -> None:
112
+ """Output sessions for a project as JSON."""
113
+ project = _find_or_exit(project_slug)
114
+ if quiet:
115
+ payload: dict[str, object] = {
116
+ "project": project.display_name,
117
+ "session_count": len(project.sessions),
118
+ }
119
+ else:
120
+ sessions = []
121
+ for s in project.sessions:
122
+ messages = parse_session(s.path)
123
+ sessions.append(
124
+ {
125
+ "filename": s.filename,
126
+ "size_bytes": s.size_bytes,
127
+ "modified": s.modified.isoformat(),
128
+ "message_count": len(messages),
129
+ "subagent_count": s.subagent_count,
130
+ }
131
+ )
132
+ payload = {"project": project.display_name, "sessions": sessions}
133
+ print(format_json_output("list-sessions", payload))
134
+
135
+
136
+ @app.callback(invoke_without_command=True, epilog=LIST_EPILOG)
137
+ def list_cmd(
138
+ project: Optional[str] = typer.Option( # noqa: UP007, UP045
139
+ None,
140
+ "--project",
141
+ "-p",
142
+ help="Project slug or name to list sessions for.",
143
+ ),
144
+ format: str = typer.Option(
145
+ "table",
146
+ "--format",
147
+ "-f",
148
+ help="Output format: 'table' or 'json'.",
149
+ ),
150
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output."),
151
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Show summary only."),
152
+ ) -> None:
153
+ """List available projects, or sessions within a project."""
154
+ if verbose and quiet:
155
+ raise typer.BadParameter("--verbose and --quiet are mutually exclusive")
156
+ if project:
157
+ if format == "json":
158
+ _list_sessions_json(project, quiet=quiet)
159
+ else:
160
+ _list_sessions_table(project, verbose=verbose, quiet=quiet)
161
+ else:
162
+ if format == "json":
163
+ _list_projects_json(quiet=quiet)
164
+ else:
165
+ _list_projects_table(verbose=verbose, quiet=quiet)
@@ -0,0 +1,18 @@
1
+ """Exit code invariant for AgentFluent commands.
2
+
3
+ 0 = success.
4
+ 1 = user named something specific and it's wrong (bad project slug, unknown
5
+ session, unknown agent name, invalid scope value).
6
+ 2 = system searched and found nothing (no projects dir, project has no
7
+ sessions, no agent definitions found).
8
+
9
+ `typer.BadParameter` exits 2 by Click convention for argument-level usage
10
+ errors (e.g., `--verbose --quiet` together). That's a framework-handled
11
+ separate category and does not fold into the invariant above.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ EXIT_OK = 0
17
+ EXIT_USER_ERROR = 1
18
+ EXIT_NO_DATA = 2
File without changes