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,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
+ )
@@ -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."""