crewscore 0.2.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.
crewscore/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """CrewScore — offline structural production-readiness scorecard for AI agent prompts."""
2
+
3
+ __version__ = "0.2.0"
crewscore/cli.py ADDED
@@ -0,0 +1,403 @@
1
+ """CrewScore CLI — structural production-readiness scoring for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ from crewscore import __version__
14
+ from crewscore.report import render_badge_svg, render_html_report, share_text
15
+ from crewscore.scoring import DIMENSIONS, build_result, tier_color
16
+ from crewscore.scorers import structural_analysis
17
+ from crewscore.vendor_scorecard import assess_vendor
18
+
19
+ console = Console()
20
+
21
+ BRAND = "CrewScore"
22
+ HOMEPAGE = "https://crewscore.ai"
23
+ REPO = "https://github.com/shmindmaster/crewscore"
24
+
25
+
26
+ def render_score_bar(score: int) -> str:
27
+ filled = round(score / 10)
28
+ empty = 10 - filled
29
+ bar = "=" * filled + "-" * empty
30
+
31
+ if score >= 90:
32
+ color = "green"
33
+ status = "PASS"
34
+ elif score >= 70:
35
+ color = "yellow"
36
+ status = "MONITOR"
37
+ elif score >= 50:
38
+ color = "dark_orange"
39
+ status = "WEAK"
40
+ else:
41
+ color = "red"
42
+ status = "MISSING" if score == 0 else "FAILS"
43
+
44
+ return f"[{color}][{bar}] {score:>3}/100[/{color}] {status}"
45
+
46
+
47
+ @click.group()
48
+ @click.version_option(version=__version__, prog_name="crewscore")
49
+ def main():
50
+ """CrewScore — offline structural scorecard for AI agent system prompts."""
51
+ pass
52
+
53
+
54
+ main.add_command(assess_vendor)
55
+
56
+
57
+ @main.command()
58
+ @click.option("--prompt", "-p", help="System prompt string to test")
59
+ @click.option(
60
+ "--prompt-file",
61
+ "-f",
62
+ type=click.Path(exists=True),
63
+ help="Path to system prompt file",
64
+ )
65
+ @click.option(
66
+ "--json",
67
+ "as_json",
68
+ is_flag=True,
69
+ help="Emit machine-readable JSON (for CI)",
70
+ )
71
+ @click.option(
72
+ "--threshold",
73
+ type=click.IntRange(0, 100),
74
+ default=None,
75
+ help="Exit non-zero if overall score is below this threshold",
76
+ )
77
+ @click.option(
78
+ "--explain",
79
+ is_flag=True,
80
+ help="Show matched vs missing guardrail signals per dimension",
81
+ )
82
+ @click.option(
83
+ "--report",
84
+ type=click.Path(),
85
+ default=None,
86
+ help="Write a self-contained HTML scorecard to this path",
87
+ )
88
+ @click.option(
89
+ "--badge",
90
+ type=click.Path(),
91
+ default=None,
92
+ help="Write an SVG badge to this path",
93
+ )
94
+ def test(prompt, prompt_file, as_json, threshold, explain, report, badge):
95
+ """Run structural production-readiness analysis on an agent system prompt.
96
+
97
+ This mode is offline and free: it scans the prompt text for guardrail
98
+ signals. It does not run live LLM adversarial attacks.
99
+ """
100
+ system_prompt = None
101
+ source = "prompt"
102
+
103
+ if prompt:
104
+ system_prompt = prompt
105
+ source = "prompt"
106
+ elif prompt_file:
107
+ system_prompt = Path(prompt_file).read_text(encoding="utf-8")
108
+ source = str(prompt_file)
109
+ else:
110
+ console.print(
111
+ "[red]Error: Provide --prompt or --prompt-file[/red]",
112
+ err=True,
113
+ )
114
+ console.print(
115
+ '[dim]Example: crewscore test --prompt "You are a helpful assistant..."[/dim]',
116
+ err=True,
117
+ )
118
+ sys.exit(1)
119
+
120
+ findings = None
121
+ if explain:
122
+ dimensions, findings = structural_analysis.analyze_with_findings(system_prompt)
123
+ else:
124
+ dimensions = structural_analysis.analyze(system_prompt)
125
+ result = build_result(dimensions, mode="structural", source=source)
126
+
127
+ if report:
128
+ report_path = Path(report)
129
+ report_path.parent.mkdir(parents=True, exist_ok=True)
130
+ report_path.write_text(render_html_report(result), encoding="utf-8")
131
+ if badge:
132
+ badge_path = Path(badge)
133
+ badge_path.parent.mkdir(parents=True, exist_ok=True)
134
+ badge_path.write_text(render_badge_svg(result), encoding="utf-8")
135
+
136
+ if as_json:
137
+ payload = result.to_dict()
138
+ if findings is not None:
139
+ payload["findings"] = findings
140
+ click.echo(json.dumps(payload, indent=2, sort_keys=True))
141
+ else:
142
+ color = tier_color(result.overall)
143
+ console.print()
144
+ console.print(
145
+ Panel(
146
+ f"[bold]{BRAND.upper()} — Structural Production Readiness Report[/bold]",
147
+ border_style="blue",
148
+ expand=False,
149
+ )
150
+ )
151
+ console.print()
152
+ console.print(
153
+ "[dim]Mode: structural (offline prompt scan). "
154
+ "Not a substitute for live behavioral red-teaming.[/dim]"
155
+ )
156
+ console.print()
157
+
158
+ for label, key in DIMENSIONS:
159
+ score = result.dimensions.get(key, 0)
160
+ console.print(f" {label:<32} {render_score_bar(score)}")
161
+
162
+ console.print()
163
+ console.print(f" {'-' * 54}")
164
+ console.print(
165
+ f" [{color}]OVERALL SCORE: {result.overall}/100 {result.tier}[/{color}]"
166
+ )
167
+ console.print(f" {'-' * 54}")
168
+
169
+ critical = [
170
+ (label, key)
171
+ for label, key in DIMENSIONS
172
+ if result.dimensions.get(key, 0) < 50
173
+ ]
174
+ if critical:
175
+ console.print()
176
+ for label, key in critical:
177
+ score = result.dimensions[key]
178
+ if score == 0:
179
+ console.print(
180
+ f" [red]CRITICAL:[/red] No {label.lower()} detected in your agent."
181
+ )
182
+ else:
183
+ console.print(
184
+ f" [dark_orange]WEAK:[/dark_orange] {label} is below "
185
+ f"production threshold ({score}/100)."
186
+ )
187
+
188
+ if findings is not None:
189
+ _render_findings(findings)
190
+
191
+ console.print()
192
+ console.print(f" Share: {share_text(result)}")
193
+ console.print()
194
+ console.print(
195
+ f" -> Run [bold]crewscore fix[/bold] to apply recommended guardrail patterns."
196
+ )
197
+ console.print(
198
+ " -> Re-run with [bold]--json[/bold] for CI. "
199
+ "Use [bold]--threshold N[/bold] to fail builds below N."
200
+ )
201
+ if report:
202
+ console.print(f" -> HTML report written to [bold]{report}[/bold]")
203
+ if badge:
204
+ console.print(f" -> SVG badge written to [bold]{badge}[/bold]")
205
+ console.print(f" -> {HOMEPAGE}")
206
+ console.print()
207
+
208
+ if threshold is not None and result.overall < threshold:
209
+ if not as_json:
210
+ console.print(
211
+ f" [red]Threshold failure: {result.overall} < {threshold}[/red]",
212
+ err=True,
213
+ )
214
+ sys.exit(2)
215
+
216
+
217
+ def _render_findings(findings: list[dict]) -> None:
218
+ """Print matched/missing findings grouped by dimension."""
219
+ label_by_key = {key: label for label, key in DIMENSIONS}
220
+ by_dim: dict[str, list[dict]] = {}
221
+ for f in findings:
222
+ by_dim.setdefault(f["dimension"], []).append(f)
223
+
224
+ console.print()
225
+ console.print("[bold]Findings (matched vs missing signals)[/bold]")
226
+ for _, key in DIMENSIONS:
227
+ items = by_dim.get(key, [])
228
+ if not items:
229
+ continue
230
+ dim_label = label_by_key.get(key, key)
231
+ console.print()
232
+ console.print(f" [bold]{dim_label}[/bold] ({key})")
233
+ for f in items:
234
+ status = f["status"]
235
+ reason = f.get("pattern_or_reason") or ""
236
+ snippet = f.get("snippet")
237
+ if status == "matched":
238
+ detail = snippet or reason
239
+ console.print(f" [green]matched[/green] {detail}")
240
+ else:
241
+ console.print(f" [red]missing[/red] {reason}")
242
+
243
+
244
+
245
+ @main.command()
246
+ @click.option("--prompt", "-p", help="System prompt string to fix")
247
+ @click.option(
248
+ "--prompt-file",
249
+ "-f",
250
+ type=click.Path(exists=True),
251
+ help="Path to system prompt file",
252
+ )
253
+ @click.option(
254
+ "--apply",
255
+ is_flag=True,
256
+ help="Write the fixed prompt back to the file (in-place)",
257
+ )
258
+ @click.option(
259
+ "--output",
260
+ "-o",
261
+ type=click.Path(),
262
+ help="Write enhanced prompt to a new file",
263
+ )
264
+ @click.option(
265
+ "--json",
266
+ "as_json",
267
+ is_flag=True,
268
+ help="Emit JSON summary of applied dimensions and score delta",
269
+ )
270
+ def fix(prompt, prompt_file, apply, output, as_json):
271
+ """Append recommended guardrail patterns to a system prompt."""
272
+ from crewscore.scorers.fix_patterns import apply_fixes, explain_fixes, generate_fixes
273
+
274
+ system_prompt = None
275
+ source_path = None
276
+
277
+ if prompt:
278
+ system_prompt = prompt
279
+ elif prompt_file:
280
+ source_path = Path(prompt_file)
281
+ system_prompt = source_path.read_text(encoding="utf-8")
282
+ else:
283
+ console.print(
284
+ "[red]Error: Provide --prompt or --prompt-file[/red]",
285
+ err=True,
286
+ )
287
+ console.print(
288
+ "[dim]Example: crewscore fix --prompt-file ./system-prompt.md --apply[/dim]",
289
+ err=True,
290
+ )
291
+ sys.exit(1)
292
+
293
+ before = structural_analysis.analyze(system_prompt)
294
+ before_result = build_result(before)
295
+ fixes = generate_fixes(before)
296
+
297
+ honesty_note = (
298
+ "Templates must be paired with runtime gates "
299
+ "(tool allowlists, human approval hooks, logging, and policy enforcement)"
300
+ )
301
+
302
+ if not fixes:
303
+ if as_json:
304
+ click.echo(
305
+ json.dumps(
306
+ {
307
+ "fixes_applied": [],
308
+ "before": before_result.to_dict(),
309
+ "after": before_result.to_dict(),
310
+ "message": "No fixes needed",
311
+ "note": honesty_note,
312
+ },
313
+ indent=2,
314
+ sort_keys=True,
315
+ )
316
+ )
317
+ else:
318
+ console.print()
319
+ console.print(
320
+ " [green]No fixes needed — structural score is already strong.[/green]"
321
+ )
322
+ console.print()
323
+ return
324
+
325
+ enhanced = apply_fixes(system_prompt, fixes)
326
+ after = structural_analysis.analyze(enhanced)
327
+ after_result = build_result(after)
328
+
329
+ if apply and source_path:
330
+ source_path.write_text(enhanced, encoding="utf-8")
331
+ elif output:
332
+ Path(output).write_text(enhanced, encoding="utf-8")
333
+
334
+ if as_json:
335
+ click.echo(
336
+ json.dumps(
337
+ {
338
+ "fixes_applied": list(fixes.keys()),
339
+ "before": before_result.to_dict(),
340
+ "after": after_result.to_dict(),
341
+ "written": bool(apply and source_path) or bool(output),
342
+ "path": str(source_path)
343
+ if apply and source_path
344
+ else (output or None),
345
+ "note": honesty_note,
346
+ },
347
+ indent=2,
348
+ sort_keys=True,
349
+ )
350
+ )
351
+ return
352
+
353
+ console.print()
354
+ console.print(
355
+ Panel(
356
+ f"[bold]{BRAND.upper()} — Applying Fixes[/bold]",
357
+ border_style="green",
358
+ expand=False,
359
+ )
360
+ )
361
+ console.print()
362
+ console.print(explain_fixes(fixes))
363
+ console.print()
364
+ console.print(
365
+ "[yellow]Honesty note:[/yellow] These are prompt templates — "
366
+ "they must be paired with runtime gates (tool allowlists, human "
367
+ "approval hooks, logging, and policy enforcement) to have real effect."
368
+ )
369
+ console.print()
370
+
371
+ if apply and source_path:
372
+ console.print(f" [green]Fixes applied in-place to {source_path}[/green]")
373
+ console.print()
374
+ console.print(
375
+ f" Score: [red]{before_result.overall}/100[/red] -> "
376
+ f"[green]{after_result.overall}/100[/green] "
377
+ f"(+{after_result.overall - before_result.overall})"
378
+ )
379
+ console.print()
380
+ elif output:
381
+ console.print(f" [green]Enhanced prompt written to {output}[/green]")
382
+ console.print()
383
+ console.print(
384
+ f" Score: [red]{before_result.overall}/100[/red] -> "
385
+ f"[green]{after_result.overall}/100[/green] "
386
+ f"(+{after_result.overall - before_result.overall})"
387
+ )
388
+ console.print()
389
+ else:
390
+ console.print("[dim]--- Enhanced System Prompt ---[/dim]")
391
+ console.print()
392
+ console.print(enhanced)
393
+ console.print()
394
+ console.print("[dim]--- End ---[/dim]")
395
+ console.print()
396
+ console.print(
397
+ "[dim]Use --apply to write in-place, or --output <file> to save to a new file.[/dim]"
398
+ )
399
+ console.print()
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()
crewscore/report.py ADDED
@@ -0,0 +1,171 @@
1
+ """Self-contained HTML report, SVG badge, and share text for CrewScore."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from html import escape
7
+ from xml.sax.saxutils import escape as xml_escape
8
+
9
+ from crewscore import __version__
10
+ from crewscore.scoring import DIMENSIONS, ScoreResult
11
+
12
+ HOMEPAGE = "https://crewscore.ai"
13
+
14
+ # Tier colors aligned with index.html score classes
15
+ _TIER_HEX = {
16
+ "green": "#10b981",
17
+ "yellow": "#eab308",
18
+ "dark_orange": "#f59e0b",
19
+ "orange": "#f59e0b",
20
+ "red": "#ef4444",
21
+ }
22
+
23
+
24
+ def _score_color_hex(overall: int) -> str:
25
+ if overall >= 90:
26
+ return _TIER_HEX["green"]
27
+ if overall >= 70:
28
+ return _TIER_HEX["yellow"]
29
+ if overall >= 50:
30
+ return _TIER_HEX["dark_orange"]
31
+ return _TIER_HEX["red"]
32
+
33
+
34
+ def _bar_color(score: int) -> str:
35
+ return _score_color_hex(score)
36
+
37
+
38
+ def share_text(result: ScoreResult) -> str:
39
+ """One-line share copy with overall score and product URL."""
40
+ return (
41
+ f"My AI agent scored {result.overall}/100 on CrewScore "
42
+ f"({result.tier}) — structural production-readiness scan. "
43
+ f"{HOMEPAGE}"
44
+ )
45
+
46
+
47
+ def render_badge_svg(result: ScoreResult) -> str:
48
+ """Shields-style SVG badge: CrewScore | {score}/100 colored by tier."""
49
+ label = "CrewScore"
50
+ value = f"{result.overall}/100"
51
+ color = _score_color_hex(result.overall)
52
+
53
+ # Approximate widths for monospace-ish badge layout
54
+ label_w = 78
55
+ value_w = 54
56
+ total_w = label_w + value_w
57
+ label_x = label_w / 2
58
+ value_x = label_w + value_w / 2
59
+
60
+ label_esc = xml_escape(label)
61
+ value_esc = xml_escape(value)
62
+
63
+ return (
64
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" '
65
+ f'role="img" aria-label="{label_esc}: {value_esc}">'
66
+ f"<title>{label_esc}: {value_esc}</title>"
67
+ f'<linearGradient id="s" x2="0" y2="100%">'
68
+ f'<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>'
69
+ f'<stop offset="1" stop-opacity=".1"/>'
70
+ f"</linearGradient>"
71
+ f'<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>'
72
+ f'<g clip-path="url(#r)">'
73
+ f'<rect width="{label_w}" height="20" fill="#555"/>'
74
+ f'<rect x="{label_w}" width="{value_w}" height="20" fill="{color}"/>'
75
+ f'<rect width="{total_w}" height="20" fill="url(#s)"/>'
76
+ f"</g>"
77
+ f'<g fill="#fff" text-anchor="middle" '
78
+ f'font-family="Verdana,Geneva,DejaVu Sans,sans-serif" '
79
+ f'font-size="11">'
80
+ f'<text x="{label_x}" y="14">{label_esc}</text>'
81
+ f'<text x="{value_x}" y="14">{value_esc}</text>'
82
+ f"</g>"
83
+ f"</svg>"
84
+ )
85
+
86
+
87
+ def render_html_report(
88
+ result: ScoreResult,
89
+ *,
90
+ generated_at: str | None = None,
91
+ ) -> str:
92
+ """Self-contained dark HTML scorecard (inline CSS, no scripts/CDN)."""
93
+ if generated_at is None:
94
+ generated_at = (
95
+ datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
96
+ )
97
+
98
+ color = _score_color_hex(result.overall)
99
+ overall = result.overall
100
+ tier = escape(result.tier)
101
+ mode = escape(result.mode)
102
+ source = escape(result.source)
103
+ version = escape(__version__)
104
+ ts = escape(generated_at)
105
+
106
+ rows: list[str] = []
107
+ for label, key in DIMENSIONS:
108
+ score = result.dimensions.get(key, 0)
109
+ bar_color = _bar_color(score)
110
+ rows.append(
111
+ f'<div class="dim-row">'
112
+ f'<span class="dim-label">{escape(label)}</span>'
113
+ f'<div class="dim-bar">'
114
+ f'<div class="dim-fill" style="width:{score}%;background:{bar_color}"></div>'
115
+ f"</div>"
116
+ f'<span class="dim-score">{score}/100</span>'
117
+ f"</div>"
118
+ )
119
+ dim_html = "\n".join(rows)
120
+
121
+ return f"""<!DOCTYPE html>
122
+ <html lang="en">
123
+ <head>
124
+ <meta charset="UTF-8">
125
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
126
+ <title>CrewScore — {overall}/100</title>
127
+ <style>
128
+ *{{box-sizing:border-box;margin:0;padding:0}}
129
+ body{{font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;
130
+ background:#0f0f1a;color:#e2e8f0;min-height:100vh;padding:2rem 1rem;
131
+ display:flex;flex-direction:column;align-items:center}}
132
+ .container{{max-width:640px;width:100%}}
133
+ h1{{font-size:1.75rem;color:#fff;text-align:center;margin-bottom:0.25rem}}
134
+ .subtitle{{text-align:center;color:#94a3b8;font-size:0.85rem;margin-bottom:1.5rem}}
135
+ .card{{background:#1a1f2e;border:1px solid #334155;border-radius:12px;padding:1.5rem}}
136
+ .score-big{{font-size:3rem;font-weight:bold;text-align:center;color:{color};margin:0.5rem 0}}
137
+ .tier{{text-align:center;font-size:1.05rem;font-weight:bold;color:{color};margin-bottom:1rem}}
138
+ .meta{{text-align:center;font-size:0.75rem;color:#64748b;margin-bottom:1.25rem}}
139
+ .dim-row{{display:flex;align-items:center;gap:0.5rem;margin:0.45rem 0;font-size:0.75rem}}
140
+ .dim-label{{width:200px;color:#94a3b8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
141
+ .dim-bar{{flex:1;height:8px;background:#1e293b;border-radius:4px;overflow:hidden}}
142
+ .dim-fill{{height:100%;border-radius:4px}}
143
+ .dim-score{{width:55px;text-align:right;color:#64748b}}
144
+ .disclaimer{{margin-top:1.25rem;padding:0.75rem;background:#0f0f1a;border:1px solid #334155;
145
+ border-radius:8px;font-size:0.75rem;color:#94a3b8;line-height:1.45}}
146
+ .footer{{margin-top:1.25rem;text-align:center;font-size:0.7rem;color:#475569;line-height:1.5}}
147
+ .footer a{{color:#3b82f6;text-decoration:none}}
148
+ </style>
149
+ </head>
150
+ <body>
151
+ <div class="container">
152
+ <h1>CrewScore</h1>
153
+ <p class="subtitle">Structural Production Readiness Report</p>
154
+ <div class="card">
155
+ <div class="score-big">{overall}/100</div>
156
+ <div class="tier">{tier}</div>
157
+ <div class="meta">Mode: {mode} · Source: {source}</div>
158
+ {dim_html}
159
+ <div class="disclaimer">
160
+ <strong>Structural scan only</strong> — offline pattern match on prompt text.
161
+ Not a substitute for live behavioral red-teaming or runtime proof of safety.
162
+ </div>
163
+ </div>
164
+ <div class="footer">
165
+ CrewScore v{version} · Generated {ts}<br>
166
+ <a href="{HOMEPAGE}">crewscore.ai</a>
167
+ </div>
168
+ </div>
169
+ </body>
170
+ </html>
171
+ """
@@ -0,0 +1 @@
1
+ """Scorer modules for each evaluation dimension."""