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.
- agentfluent/__init__.py +8 -0
- agentfluent/agents/__init__.py +0 -0
- agentfluent/agents/extractor.py +78 -0
- agentfluent/agents/models.py +71 -0
- agentfluent/analytics/__init__.py +0 -0
- agentfluent/analytics/agent_metrics.py +127 -0
- agentfluent/analytics/pipeline.py +278 -0
- agentfluent/analytics/pricing.py +96 -0
- agentfluent/analytics/tokens.py +137 -0
- agentfluent/analytics/tools.py +93 -0
- agentfluent/cli/__init__.py +0 -0
- agentfluent/cli/commands/__init__.py +0 -0
- agentfluent/cli/commands/analyze.py +162 -0
- agentfluent/cli/commands/config_check.py +110 -0
- agentfluent/cli/commands/list_cmd.py +165 -0
- agentfluent/cli/exit_codes.py +18 -0
- agentfluent/cli/formatters/__init__.py +0 -0
- agentfluent/cli/formatters/helpers.py +59 -0
- agentfluent/cli/formatters/json_output.py +60 -0
- agentfluent/cli/formatters/table.py +358 -0
- agentfluent/cli/main.py +63 -0
- agentfluent/config/__init__.py +31 -0
- agentfluent/config/models.py +118 -0
- agentfluent/config/scanner.py +146 -0
- agentfluent/config/scoring.py +299 -0
- agentfluent/core/__init__.py +0 -0
- agentfluent/core/discovery.py +158 -0
- agentfluent/core/parser.py +251 -0
- agentfluent/core/session.py +133 -0
- agentfluent/diagnostics/__init__.py +51 -0
- agentfluent/diagnostics/correlator.py +248 -0
- agentfluent/diagnostics/models.py +75 -0
- agentfluent/diagnostics/signals.py +162 -0
- agentfluent/py.typed +0 -0
- agentfluent-0.1.0.dist-info/METADATA +52 -0
- agentfluent-0.1.0.dist-info/RECORD +39 -0
- agentfluent-0.1.0.dist-info/WHEEL +4 -0
- agentfluent-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|