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,59 @@
|
|
|
1
|
+
"""Shared formatting utilities for CLI output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from agentfluent.config.models import Severity
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from agentfluent.config.models import ConfigScore
|
|
12
|
+
|
|
13
|
+
SEVERITY_COLORS: dict[Severity, str] = {
|
|
14
|
+
Severity.CRITICAL: "red",
|
|
15
|
+
Severity.WARNING: "yellow",
|
|
16
|
+
Severity.INFO: "cyan",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_cost(cost: float) -> str:
|
|
21
|
+
"""Format a dollar cost for display."""
|
|
22
|
+
if cost < 0.01:
|
|
23
|
+
return f"${cost:.4f}"
|
|
24
|
+
return f"${cost:.2f}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_tokens(tokens: int) -> str:
|
|
28
|
+
"""Format token count with comma separator."""
|
|
29
|
+
return f"{tokens:,}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_size(size_bytes: int) -> str:
|
|
33
|
+
"""Format bytes as human-readable size."""
|
|
34
|
+
if size_bytes < 1024:
|
|
35
|
+
return f"{size_bytes} B"
|
|
36
|
+
if size_bytes < 1024 * 1024:
|
|
37
|
+
return f"{size_bytes / 1024:.1f} KB"
|
|
38
|
+
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def format_date(dt: datetime | None) -> str:
|
|
42
|
+
"""Format a datetime for display."""
|
|
43
|
+
if dt is None:
|
|
44
|
+
return "—"
|
|
45
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def score_color(score: int) -> str:
|
|
49
|
+
"""Return a Rich color based on score value."""
|
|
50
|
+
if score >= 80:
|
|
51
|
+
return "green"
|
|
52
|
+
if score >= 50:
|
|
53
|
+
return "yellow"
|
|
54
|
+
return "red"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def average_score(scores: list[ConfigScore]) -> int:
|
|
58
|
+
"""Integer average of overall_score across agents; 0 for an empty list."""
|
|
59
|
+
return sum(s.overall_score for s in scores) // len(scores) if scores else 0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""JSON output envelope for CLI commands.
|
|
2
|
+
|
|
3
|
+
All commands emit JSON in a stable envelope:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"version": "1",
|
|
7
|
+
"command": "<list|analyze|config-check>",
|
|
8
|
+
"data": { ... command-specific payload ... }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
`version` is a string and bumps independently of the package version when the
|
|
12
|
+
schema changes. Consumers should check `command` before parsing `data`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "1"
|
|
21
|
+
|
|
22
|
+
CommandName = Literal["list-projects", "list-sessions", "analyze", "config-check"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_json_output(command: CommandName, data: Any) -> str:
|
|
26
|
+
"""Wrap a command payload in the versioned JSON envelope."""
|
|
27
|
+
envelope = {
|
|
28
|
+
"version": SCHEMA_VERSION,
|
|
29
|
+
"command": command,
|
|
30
|
+
"data": data,
|
|
31
|
+
}
|
|
32
|
+
return json.dumps(envelope, indent=2, default=str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_json_output(
|
|
36
|
+
text: str, *, expected_command: CommandName | None = None,
|
|
37
|
+
) -> Any:
|
|
38
|
+
"""Validate the envelope and return its `data` payload.
|
|
39
|
+
|
|
40
|
+
Raises ValueError on schema violation (missing keys, wrong version,
|
|
41
|
+
wrong command).
|
|
42
|
+
"""
|
|
43
|
+
envelope = json.loads(text)
|
|
44
|
+
missing = {"version", "command", "data"} - envelope.keys()
|
|
45
|
+
if missing:
|
|
46
|
+
msg = f"JSON envelope missing keys: {sorted(missing)}"
|
|
47
|
+
raise ValueError(msg)
|
|
48
|
+
if envelope["version"] != SCHEMA_VERSION:
|
|
49
|
+
msg = (
|
|
50
|
+
f"JSON envelope version {envelope['version']!r} does not match "
|
|
51
|
+
f"SCHEMA_VERSION {SCHEMA_VERSION!r}"
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
if expected_command is not None and envelope["command"] != expected_command:
|
|
55
|
+
msg = (
|
|
56
|
+
f"JSON envelope command {envelope['command']!r} does not match "
|
|
57
|
+
f"expected {expected_command!r}"
|
|
58
|
+
)
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
return envelope["data"]
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Rich table formatters for CLI output.
|
|
2
|
+
|
|
3
|
+
Each command gets its own `format_*_table` function -- no shared Formatter
|
|
4
|
+
base class, since the three commands produce different data shapes that
|
|
5
|
+
would not benefit from unification. Functions render already-computed data
|
|
6
|
+
to a Rich Console; I/O stays in the command callbacks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
|
|
16
|
+
from agentfluent.cli.formatters.helpers import (
|
|
17
|
+
SEVERITY_COLORS,
|
|
18
|
+
average_score,
|
|
19
|
+
format_cost,
|
|
20
|
+
format_date,
|
|
21
|
+
format_size,
|
|
22
|
+
format_tokens,
|
|
23
|
+
score_color,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from agentfluent.analytics.pipeline import AnalysisResult
|
|
28
|
+
from agentfluent.config.models import ConfigScore
|
|
29
|
+
from agentfluent.core.discovery import ProjectInfo, SessionInfo
|
|
30
|
+
from agentfluent.diagnostics.models import DiagnosticsResult
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def format_projects_table(
|
|
34
|
+
console: Console,
|
|
35
|
+
projects: list[ProjectInfo],
|
|
36
|
+
*,
|
|
37
|
+
verbose: bool = False,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Render discovered projects as a Rich table."""
|
|
40
|
+
if not projects:
|
|
41
|
+
console.print("No projects found in ~/.claude/projects/")
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
table = Table(title="Projects")
|
|
45
|
+
table.add_column("Name", style="cyan")
|
|
46
|
+
table.add_column("Sessions", justify="right")
|
|
47
|
+
table.add_column("Size", justify="right")
|
|
48
|
+
table.add_column("Latest", style="dim")
|
|
49
|
+
if verbose:
|
|
50
|
+
table.add_column("Slug", style="dim")
|
|
51
|
+
|
|
52
|
+
for p in projects:
|
|
53
|
+
row = [
|
|
54
|
+
p.display_name,
|
|
55
|
+
str(p.session_count),
|
|
56
|
+
format_size(p.total_size_bytes),
|
|
57
|
+
format_date(p.latest_session),
|
|
58
|
+
]
|
|
59
|
+
if verbose:
|
|
60
|
+
row.append(p.slug)
|
|
61
|
+
table.add_row(*row)
|
|
62
|
+
|
|
63
|
+
console.print(table)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def format_sessions_table(
|
|
67
|
+
console: Console,
|
|
68
|
+
project_name: str,
|
|
69
|
+
sessions: list[tuple[SessionInfo, int]],
|
|
70
|
+
*,
|
|
71
|
+
verbose: bool = False,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Render per-session stats as a Rich table.
|
|
74
|
+
|
|
75
|
+
`sessions` is a list of (SessionInfo, message_count) tuples. The caller
|
|
76
|
+
owns session parsing so this function stays pure rendering.
|
|
77
|
+
"""
|
|
78
|
+
if not sessions:
|
|
79
|
+
console.print(f"No sessions in project '{project_name}'")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
table = Table(title=f"Sessions — {project_name}")
|
|
83
|
+
file_label = "Path" if verbose else "File"
|
|
84
|
+
table.add_column(file_label, style="cyan")
|
|
85
|
+
table.add_column("Size", justify="right")
|
|
86
|
+
table.add_column("Modified", style="dim")
|
|
87
|
+
table.add_column("Messages", justify="right")
|
|
88
|
+
table.add_column("Subagents", justify="right", style="dim")
|
|
89
|
+
|
|
90
|
+
for info, message_count in sessions:
|
|
91
|
+
table.add_row(
|
|
92
|
+
str(info.path) if verbose else info.filename,
|
|
93
|
+
format_size(info.size_bytes),
|
|
94
|
+
format_date(info.modified),
|
|
95
|
+
str(message_count),
|
|
96
|
+
str(info.subagent_count) if info.subagent_count > 0 else "—",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
console.print(table)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def format_analysis_table(
|
|
103
|
+
console: Console,
|
|
104
|
+
result: AnalysisResult,
|
|
105
|
+
*,
|
|
106
|
+
verbose: bool = False,
|
|
107
|
+
show_diagnostics: bool = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Render analyze output: token, cost, tool, agent, and diagnostics tables."""
|
|
110
|
+
tm = result.token_metrics
|
|
111
|
+
am = result.agent_metrics
|
|
112
|
+
tlm = result.tool_metrics
|
|
113
|
+
|
|
114
|
+
token_table = Table(title="Token Usage", show_header=True)
|
|
115
|
+
token_table.add_column("Metric", style="cyan")
|
|
116
|
+
token_table.add_column("Value", justify="right")
|
|
117
|
+
token_table.add_row("Input tokens", format_tokens(tm.input_tokens))
|
|
118
|
+
token_table.add_row("Output tokens", format_tokens(tm.output_tokens))
|
|
119
|
+
token_table.add_row("Cache creation tokens", format_tokens(tm.cache_creation_input_tokens))
|
|
120
|
+
token_table.add_row("Cache read tokens", format_tokens(tm.cache_read_input_tokens))
|
|
121
|
+
token_table.add_row("Total tokens", format_tokens(tm.total_tokens))
|
|
122
|
+
token_table.add_row("Total cost", format_cost(tm.total_cost))
|
|
123
|
+
token_table.add_row("Cache efficiency", f"{tm.cache_efficiency}%")
|
|
124
|
+
token_table.add_row("API calls", str(tm.api_call_count))
|
|
125
|
+
console.print(token_table)
|
|
126
|
+
|
|
127
|
+
if tm.by_model and (verbose or len(tm.by_model) > 1):
|
|
128
|
+
model_table = Table(title="Cost by Model", show_header=True)
|
|
129
|
+
model_table.add_column("Model", style="cyan")
|
|
130
|
+
model_table.add_column("Tokens", justify="right")
|
|
131
|
+
model_table.add_column("Cost", justify="right")
|
|
132
|
+
for model_name, breakdown in sorted(tm.by_model.items()):
|
|
133
|
+
model_table.add_row(
|
|
134
|
+
model_name,
|
|
135
|
+
format_tokens(breakdown.total_tokens),
|
|
136
|
+
format_cost(breakdown.cost),
|
|
137
|
+
)
|
|
138
|
+
console.print(model_table)
|
|
139
|
+
|
|
140
|
+
if tlm.total_tool_calls > 0:
|
|
141
|
+
tool_table = Table(title="Tool Usage", show_header=True)
|
|
142
|
+
tool_table.add_column("Tool", style="cyan")
|
|
143
|
+
tool_table.add_column("Calls", justify="right")
|
|
144
|
+
tool_table.add_column("% of Total", justify="right")
|
|
145
|
+
for name, count in tlm.tool_frequency.items():
|
|
146
|
+
pct = round(count / tlm.total_tool_calls * 100, 1)
|
|
147
|
+
tool_table.add_row(name, str(count), f"{pct}%")
|
|
148
|
+
tool_table.add_row("", "", "")
|
|
149
|
+
tool_table.add_row("Total", str(tlm.total_tool_calls), "")
|
|
150
|
+
tool_table.add_row("Unique tools", str(tlm.unique_tool_count), "")
|
|
151
|
+
console.print(tool_table)
|
|
152
|
+
|
|
153
|
+
if am.total_invocations > 0:
|
|
154
|
+
agent_table = Table(title="Agent Invocations", show_header=True)
|
|
155
|
+
agent_table.add_column("Agent Type", style="cyan")
|
|
156
|
+
agent_table.add_column("Count", justify="right")
|
|
157
|
+
agent_table.add_column("Tokens", justify="right")
|
|
158
|
+
agent_table.add_column("Avg Tokens/Call", justify="right")
|
|
159
|
+
agent_table.add_column("Duration", justify="right")
|
|
160
|
+
for _key, m in sorted(am.by_agent_type.items()):
|
|
161
|
+
label = f"{m.agent_type} {'(builtin)' if m.is_builtin else ''}"
|
|
162
|
+
avg_tok = (
|
|
163
|
+
format_tokens(int(m.avg_tokens_per_invocation))
|
|
164
|
+
if m.avg_tokens_per_invocation
|
|
165
|
+
else "-"
|
|
166
|
+
)
|
|
167
|
+
duration = f"{m.total_duration_ms / 1000:.1f}s" if m.total_duration_ms else "-"
|
|
168
|
+
agent_table.add_row(
|
|
169
|
+
label.strip(),
|
|
170
|
+
str(m.invocation_count),
|
|
171
|
+
format_tokens(m.total_tokens),
|
|
172
|
+
avg_tok,
|
|
173
|
+
duration,
|
|
174
|
+
)
|
|
175
|
+
agent_table.add_row("", "", "", "", "")
|
|
176
|
+
agent_table.add_row("Total", str(am.total_invocations), "", "", "")
|
|
177
|
+
agent_table.add_row(
|
|
178
|
+
"Agent token %",
|
|
179
|
+
f"{am.agent_token_percentage}%",
|
|
180
|
+
"",
|
|
181
|
+
"",
|
|
182
|
+
"",
|
|
183
|
+
)
|
|
184
|
+
console.print(agent_table)
|
|
185
|
+
|
|
186
|
+
if verbose and len(result.sessions) > 1:
|
|
187
|
+
session_table = Table(title="Per-Session Breakdown", show_header=True)
|
|
188
|
+
session_table.add_column("Session", style="cyan")
|
|
189
|
+
session_table.add_column("Tokens", justify="right")
|
|
190
|
+
session_table.add_column("Cost", justify="right")
|
|
191
|
+
session_table.add_column("Tool calls", justify="right")
|
|
192
|
+
session_table.add_column("Invocations", justify="right")
|
|
193
|
+
for s in result.sessions:
|
|
194
|
+
session_table.add_row(
|
|
195
|
+
s.session_path.name,
|
|
196
|
+
format_tokens(s.token_metrics.total_tokens),
|
|
197
|
+
format_cost(s.token_metrics.total_cost),
|
|
198
|
+
str(s.tool_metrics.total_tool_calls),
|
|
199
|
+
str(s.agent_metrics.total_invocations),
|
|
200
|
+
)
|
|
201
|
+
console.print(session_table)
|
|
202
|
+
|
|
203
|
+
if verbose and am.total_invocations > 0:
|
|
204
|
+
inv_table = Table(title="Per-Invocation Detail", show_header=True)
|
|
205
|
+
inv_table.add_column("Agent", style="cyan")
|
|
206
|
+
inv_table.add_column("Description")
|
|
207
|
+
inv_table.add_column("Tokens", justify="right")
|
|
208
|
+
inv_table.add_column("Tool uses", justify="right")
|
|
209
|
+
inv_table.add_column("Duration", justify="right")
|
|
210
|
+
for s in result.sessions:
|
|
211
|
+
for inv in s.invocations:
|
|
212
|
+
tokens = format_tokens(inv.total_tokens) if inv.total_tokens else "-"
|
|
213
|
+
tools = str(inv.tool_uses) if inv.tool_uses else "-"
|
|
214
|
+
duration = f"{inv.duration_ms / 1000:.1f}s" if inv.duration_ms else "-"
|
|
215
|
+
desc = (
|
|
216
|
+
inv.description
|
|
217
|
+
if len(inv.description) <= 60
|
|
218
|
+
else inv.description[:57] + "..."
|
|
219
|
+
)
|
|
220
|
+
inv_table.add_row(inv.agent_type, desc, tokens, tools, duration)
|
|
221
|
+
console.print(inv_table)
|
|
222
|
+
|
|
223
|
+
diag = result.diagnostics
|
|
224
|
+
if diag:
|
|
225
|
+
if show_diagnostics:
|
|
226
|
+
_format_diagnostics_table(console, diag, verbose=verbose)
|
|
227
|
+
else:
|
|
228
|
+
_format_diagnostics_summary(console, diag)
|
|
229
|
+
|
|
230
|
+
console.print(f"\n[bold]Sessions analyzed:[/bold] {result.session_count}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _format_diagnostics_table(
|
|
234
|
+
console: Console,
|
|
235
|
+
diag: DiagnosticsResult,
|
|
236
|
+
*,
|
|
237
|
+
verbose: bool = False,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Render diagnostic signals and recommendations tables."""
|
|
240
|
+
if diag.signals:
|
|
241
|
+
sig_table = Table(title="Diagnostic Signals", show_header=True)
|
|
242
|
+
sig_table.add_column("Agent", style="cyan")
|
|
243
|
+
sig_table.add_column("Type")
|
|
244
|
+
sig_table.add_column("Severity")
|
|
245
|
+
sig_table.add_column("Message")
|
|
246
|
+
|
|
247
|
+
for sig in diag.signals:
|
|
248
|
+
color = SEVERITY_COLORS.get(sig.severity, "white")
|
|
249
|
+
sig_table.add_row(
|
|
250
|
+
sig.agent_type,
|
|
251
|
+
sig.signal_type.value,
|
|
252
|
+
f"[{color}]{sig.severity.value}[/{color}]",
|
|
253
|
+
sig.message,
|
|
254
|
+
)
|
|
255
|
+
console.print(sig_table)
|
|
256
|
+
|
|
257
|
+
if diag.recommendations:
|
|
258
|
+
rec_table = Table(title="Recommendations", show_header=True)
|
|
259
|
+
rec_table.add_column("Agent", style="cyan")
|
|
260
|
+
rec_table.add_column("Target")
|
|
261
|
+
rec_table.add_column("Severity")
|
|
262
|
+
if verbose:
|
|
263
|
+
rec_table.add_column("Observation")
|
|
264
|
+
rec_table.add_column("Action")
|
|
265
|
+
else:
|
|
266
|
+
rec_table.add_column("Recommendation")
|
|
267
|
+
|
|
268
|
+
for rec in diag.recommendations:
|
|
269
|
+
color = SEVERITY_COLORS.get(rec.severity, "white")
|
|
270
|
+
if verbose:
|
|
271
|
+
rec_table.add_row(
|
|
272
|
+
rec.agent_type,
|
|
273
|
+
rec.target,
|
|
274
|
+
f"[{color}]{rec.severity.value}[/{color}]",
|
|
275
|
+
rec.observation,
|
|
276
|
+
rec.action,
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
rec_table.add_row(
|
|
280
|
+
rec.agent_type,
|
|
281
|
+
rec.target,
|
|
282
|
+
f"[{color}]{rec.severity.value}[/{color}]",
|
|
283
|
+
rec.message,
|
|
284
|
+
)
|
|
285
|
+
console.print(rec_table)
|
|
286
|
+
|
|
287
|
+
if diag.subagent_trace_count > 0:
|
|
288
|
+
console.print(
|
|
289
|
+
f"\n[dim]{diag.subagent_trace_count} subagent trace files available. "
|
|
290
|
+
"Deep diagnostics (per-tool-call analysis) coming in v1.1.[/dim]"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _format_diagnostics_summary(console: Console, diag: DiagnosticsResult) -> None:
|
|
295
|
+
"""Print a brief diagnostics summary when --diagnostics is not passed."""
|
|
296
|
+
signal_count = len(diag.signals)
|
|
297
|
+
if signal_count > 0:
|
|
298
|
+
console.print(
|
|
299
|
+
f"\n[yellow]{signal_count} diagnostic signal(s) detected.[/yellow] "
|
|
300
|
+
"Run with [bold]--diagnostics[/bold] for details."
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def format_config_check_table(
|
|
305
|
+
console: Console,
|
|
306
|
+
scores: list[ConfigScore],
|
|
307
|
+
*,
|
|
308
|
+
verbose: bool = False,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Render agent configuration scores and recommendations."""
|
|
311
|
+
summary = Table(title="Agent Configuration Scores", show_header=True)
|
|
312
|
+
summary.add_column("Agent", style="cyan")
|
|
313
|
+
summary.add_column("Score", justify="right")
|
|
314
|
+
summary.add_column("Description", justify="right")
|
|
315
|
+
summary.add_column("Tools", justify="right")
|
|
316
|
+
summary.add_column("Model", justify="right")
|
|
317
|
+
summary.add_column("Prompt", justify="right")
|
|
318
|
+
summary.add_column("Recs", justify="right")
|
|
319
|
+
|
|
320
|
+
for s in scores:
|
|
321
|
+
color = score_color(s.overall_score)
|
|
322
|
+
summary.add_row(
|
|
323
|
+
s.agent_name,
|
|
324
|
+
f"[{color}]{s.overall_score}/100[/{color}]",
|
|
325
|
+
f"{s.dimension_scores.get('description', 0)}/25",
|
|
326
|
+
f"{s.dimension_scores.get('tool_restrictions', 0)}/25",
|
|
327
|
+
f"{s.dimension_scores.get('model_selection', 0)}/25",
|
|
328
|
+
f"{s.dimension_scores.get('prompt_body', 0)}/25",
|
|
329
|
+
str(len(s.recommendations)),
|
|
330
|
+
)
|
|
331
|
+
console.print(summary)
|
|
332
|
+
|
|
333
|
+
all_recs = [(s.agent_name, r) for s in scores for r in s.recommendations]
|
|
334
|
+
if all_recs:
|
|
335
|
+
rec_table = Table(title="Recommendations", show_header=True)
|
|
336
|
+
rec_table.add_column("Agent", style="cyan")
|
|
337
|
+
rec_table.add_column("Severity")
|
|
338
|
+
rec_table.add_column("Recommendation")
|
|
339
|
+
if verbose:
|
|
340
|
+
rec_table.add_column("Action")
|
|
341
|
+
|
|
342
|
+
for agent_name, rec in all_recs:
|
|
343
|
+
color = SEVERITY_COLORS.get(rec.severity, "white")
|
|
344
|
+
row = [
|
|
345
|
+
agent_name,
|
|
346
|
+
f"[{color}]{rec.severity.value}[/{color}]",
|
|
347
|
+
rec.message,
|
|
348
|
+
]
|
|
349
|
+
if verbose:
|
|
350
|
+
row.append(rec.suggested_action)
|
|
351
|
+
rec_table.add_row(*row)
|
|
352
|
+
console.print(rec_table)
|
|
353
|
+
|
|
354
|
+
console.print(
|
|
355
|
+
f"\n[bold]Agents scanned:[/bold] {len(scores)}, "
|
|
356
|
+
f"[bold]average score:[/bold] {average_score(scores)}/100, "
|
|
357
|
+
f"[bold]recommendations:[/bold] {len(all_recs)}"
|
|
358
|
+
)
|
agentfluent/cli/main.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""AgentFluent CLI application."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from agentfluent import __version__
|
|
8
|
+
from agentfluent.cli.commands import analyze, config_check, list_cmd
|
|
9
|
+
|
|
10
|
+
TOP_LEVEL_HELP = """\
|
|
11
|
+
Local-first agent analytics for the Claude Agent SDK and Claude Code subagents.
|
|
12
|
+
|
|
13
|
+
AgentFluent analyzes session JSONL files in ~/.claude/projects/ to diagnose
|
|
14
|
+
agent quality -- token usage, tool patterns, behavior signals, and config
|
|
15
|
+
health -- and produces specific recommendations for improving agent prompts,
|
|
16
|
+
tool access, model selection, and other configuration surfaces.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
TOP_LEVEL_EPILOG = """\
|
|
20
|
+
Common workflows:
|
|
21
|
+
|
|
22
|
+
agentfluent list
|
|
23
|
+
Discover which projects have session data.
|
|
24
|
+
|
|
25
|
+
agentfluent analyze --project <slug> --diagnostics
|
|
26
|
+
Full analytics with behavior diagnostics.
|
|
27
|
+
|
|
28
|
+
agentfluent config-check
|
|
29
|
+
Score agent definitions against best practices.
|
|
30
|
+
|
|
31
|
+
Run any command with --help for command-specific options and examples.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(
|
|
35
|
+
name="agentfluent",
|
|
36
|
+
help=TOP_LEVEL_HELP,
|
|
37
|
+
epilog=TOP_LEVEL_EPILOG,
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
app.add_typer(list_cmd.app, name="list")
|
|
42
|
+
app.add_typer(analyze.app, name="analyze")
|
|
43
|
+
app.add_typer(config_check.app, name="config-check")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def version_callback(value: bool) -> None:
|
|
47
|
+
if value:
|
|
48
|
+
typer.echo(f"agentfluent {__version__}")
|
|
49
|
+
raise typer.Exit()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.callback()
|
|
53
|
+
def main(
|
|
54
|
+
version: Optional[bool] = typer.Option( # noqa: UP007, UP045
|
|
55
|
+
None,
|
|
56
|
+
"--version",
|
|
57
|
+
"-V",
|
|
58
|
+
help="Show version and exit.",
|
|
59
|
+
callback=version_callback,
|
|
60
|
+
is_eager=True,
|
|
61
|
+
),
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Local-first agent analytics with prompt diagnostics."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agent configuration assessment package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from agentfluent.config.models import ConfigScore
|
|
6
|
+
from agentfluent.config.scanner import scan_agents
|
|
7
|
+
from agentfluent.config.scoring import score_agent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def assess_agents(
|
|
11
|
+
scope: str = "all",
|
|
12
|
+
*,
|
|
13
|
+
agent_filter: str | None = None,
|
|
14
|
+
) -> list[ConfigScore]:
|
|
15
|
+
"""Scan and score agent definitions.
|
|
16
|
+
|
|
17
|
+
Reusable orchestration function for CLI, diagnostics, and tests.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
scope: Which locations to scan -- "user", "project", or "all".
|
|
21
|
+
agent_filter: If set, only score this agent name (case-insensitive).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
List of ConfigScore results.
|
|
25
|
+
"""
|
|
26
|
+
agents = scan_agents(scope)
|
|
27
|
+
|
|
28
|
+
if agent_filter:
|
|
29
|
+
agents = [a for a in agents if a.name.lower() == agent_filter.lower()]
|
|
30
|
+
|
|
31
|
+
return [score_agent(a) for a in agents]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Data models for agent configuration assessment.
|
|
2
|
+
|
|
3
|
+
These models represent parsed agent definition files and their scoring results.
|
|
4
|
+
They cross module boundaries (scanner -> scorer -> CLI -> diagnostics), so
|
|
5
|
+
Pydantic is used for validation and serialization.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Scope(StrEnum):
|
|
18
|
+
"""Where an agent definition was discovered."""
|
|
19
|
+
|
|
20
|
+
USER = "user"
|
|
21
|
+
PROJECT = "project"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Severity(StrEnum):
|
|
25
|
+
"""Recommendation severity level."""
|
|
26
|
+
|
|
27
|
+
INFO = "info"
|
|
28
|
+
WARNING = "warning"
|
|
29
|
+
CRITICAL = "critical"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AgentConfig(BaseModel):
|
|
33
|
+
"""Parsed agent definition from a `.md` file.
|
|
34
|
+
|
|
35
|
+
Captures both explicitly-modeled fields and raw frontmatter for
|
|
36
|
+
forward compatibility with new agent config fields.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
"""Agent name from frontmatter or filename."""
|
|
41
|
+
|
|
42
|
+
file_path: Path
|
|
43
|
+
"""Absolute path to the `.md` file."""
|
|
44
|
+
|
|
45
|
+
scope: Scope
|
|
46
|
+
"""Whether this came from user or project agents directory."""
|
|
47
|
+
|
|
48
|
+
# Core fields
|
|
49
|
+
description: str = ""
|
|
50
|
+
"""Agent description from frontmatter."""
|
|
51
|
+
|
|
52
|
+
model: str | None = None
|
|
53
|
+
"""Model name (e.g., 'claude-opus-4-6')."""
|
|
54
|
+
|
|
55
|
+
prompt_body: str = ""
|
|
56
|
+
"""Everything after the YAML frontmatter closing `---`."""
|
|
57
|
+
|
|
58
|
+
# Tool access
|
|
59
|
+
tools: list[str] = Field(default_factory=list)
|
|
60
|
+
"""Allowed tools list."""
|
|
61
|
+
|
|
62
|
+
disallowed_tools: list[str] = Field(default_factory=list)
|
|
63
|
+
"""Disallowed tools list."""
|
|
64
|
+
|
|
65
|
+
# Additional config fields
|
|
66
|
+
mcp_servers: list[str] = Field(default_factory=list)
|
|
67
|
+
"""MCP server names."""
|
|
68
|
+
|
|
69
|
+
hooks: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
"""Hook configuration (complex nested structure)."""
|
|
71
|
+
|
|
72
|
+
skills: list[str] = Field(default_factory=list)
|
|
73
|
+
"""Skill names."""
|
|
74
|
+
|
|
75
|
+
memory: str | None = None
|
|
76
|
+
"""Memory scope (e.g., 'user')."""
|
|
77
|
+
|
|
78
|
+
isolation: str | None = None
|
|
79
|
+
"""Isolation mode (e.g., 'worktree')."""
|
|
80
|
+
|
|
81
|
+
color: str | None = None
|
|
82
|
+
"""Agent color for display."""
|
|
83
|
+
|
|
84
|
+
raw_frontmatter: dict[str, Any] = Field(default_factory=dict)
|
|
85
|
+
"""Complete raw frontmatter dict for fields not explicitly modeled."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConfigRecommendation(BaseModel):
|
|
89
|
+
"""A specific, actionable recommendation from config scoring."""
|
|
90
|
+
|
|
91
|
+
dimension: str
|
|
92
|
+
"""Which scoring dimension produced this recommendation."""
|
|
93
|
+
|
|
94
|
+
severity: Severity
|
|
95
|
+
"""How important this recommendation is."""
|
|
96
|
+
|
|
97
|
+
message: str
|
|
98
|
+
"""Human-readable recommendation text."""
|
|
99
|
+
|
|
100
|
+
current_value: str = ""
|
|
101
|
+
"""What was found in the config (for context)."""
|
|
102
|
+
|
|
103
|
+
suggested_action: str = ""
|
|
104
|
+
"""What the user should change."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConfigScore(BaseModel):
|
|
108
|
+
"""Scoring results for a single agent configuration."""
|
|
109
|
+
|
|
110
|
+
agent_name: str
|
|
111
|
+
overall_score: int = 0
|
|
112
|
+
"""Overall score (0-100), sum of dimension scores."""
|
|
113
|
+
|
|
114
|
+
dimension_scores: dict[str, int] = Field(default_factory=dict)
|
|
115
|
+
"""Per-dimension scores, keyed by dimension name."""
|
|
116
|
+
|
|
117
|
+
recommendations: list[ConfigRecommendation] = Field(default_factory=list)
|
|
118
|
+
"""Actionable recommendations for improving the config."""
|