pantheon-opencode 1.4.3 → 1.5.0-beta.2
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.
- package/.pantheon/code-mode/README.md +93 -0
- package/.pantheon/code-mode/checkpoint-session.py +7 -0
- package/.pantheon/code-mode/checkpoint-session.sh +11 -0
- package/.pantheon/code-mode/checkpoint_session.py +481 -0
- package/.pantheon/code-mode/compress-inline.py +450 -0
- package/.pantheon/code-mode/eval-llm-judge.py +255 -0
- package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
- package/.pantheon/code-mode/eval-run.py +197 -0
- package/.pantheon/code-mode/eval-static.py +321 -0
- package/.pantheon/code-mode/example-sync.sh +5 -0
- package/.pantheon/code-mode/session-end-save.py +186 -0
- package/.pantheon/code-mode/session-end-save.sh +18 -0
- package/.pantheon/code-mode/test-allowed.sh +5 -0
- package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
- package/.pantheon/code-mode/test-env.sh +7 -0
- package/.pantheon/code-mode/test-prlimit.sh +9 -0
- package/.pantheon/code-mode/test-timeout.sh +6 -0
- package/README.md +161 -1101
- package/README.pt-BR.md +222 -0
- package/bin/pantheon-init.mjs +30 -11
- package/opencode.json +1 -0
- package/package.json +14 -3
- package/plugin.json +15 -0
- package/scripts/_pantheon_paths.py +48 -7
- package/scripts/ci-validate-yaml.py +7 -2
- package/scripts/code_mode_server.py +33 -2
- package/scripts/cost.mjs +30 -14
- package/scripts/doctor.mjs +135 -127
- package/scripts/generate-preset-docs.mjs +127 -66
- package/scripts/generate-prompts.sh +2 -2
- package/scripts/install/cli-ui.mjs +8 -1
- package/scripts/install/config-migration.mjs +401 -0
- package/scripts/install/health-check.mjs +3 -3
- package/scripts/install/model-picker.mjs +72 -23
- package/scripts/install/opencode-version.mjs +47 -0
- package/scripts/install/opencode.mjs +261 -88
- package/scripts/install/plugin.mjs +112 -42
- package/scripts/install/shared.mjs +13 -158
- package/scripts/install-mcp.mjs +44 -232
- package/scripts/mcp_persistence_server.py +382 -16
- package/scripts/mcp_resources_server.py +144 -40
- package/scripts/memory_mcp_server.py +2 -2
- package/scripts/probe-context-rehydrate.mjs +240 -0
- package/scripts/probe-pantheon-cost.mjs +304 -0
- package/scripts/recover-zenodo-deposition.mjs +146 -0
- package/scripts/sync-tui.mjs +32 -7
- package/scripts/test-opencode-v1-v2-sandbox.sh +944 -0
- package/scripts/uninstall.mjs +298 -954
- package/scripts/validate-zenodo-release.mjs +154 -0
- package/scripts/zenodo-artifact.mjs +21 -0
- package/scripts/zenodo-release-state.mjs +53 -0
- package/src/agents/zeus.md +25 -14
- package/src/mcp/_pantheon_paths.py +48 -7
- package/src/mcp/code_mode_server.py +33 -2
- package/src/mcp/eval_store.py +1 -1
- package/src/mcp/mcp_persistence_server.py +382 -16
- package/src/mcp/mcp_resources_server.py +144 -40
- package/src/mcp/pantheon_vision_server.py +1 -1
- package/src/mcp/toon_codec.py +491 -0
- package/src/pantheon/background-job-board.ts +67 -1
- package/src/pantheon/cost-command.ts +60 -39
- package/src/pantheon/delegate-manager.ts +669 -0
- package/src/pantheon/delegation-classifier.ts +307 -0
- package/src/pantheon/delegation-enforce.ts +13 -12
- package/src/pantheon/delegation-finalize.ts +374 -0
- package/src/pantheon/delegation-notify.ts +212 -0
- package/src/pantheon/delegation-toast.ts +73 -0
- package/src/pantheon/delegation.ts +1120 -0
- package/src/pantheon/goal-loop.ts +2 -2
- package/src/pantheon/goal-store.ts +1 -1
- package/src/pantheon/hashline/tool.ts +2 -2
- package/src/pantheon/model-command.ts +2 -1
- package/src/pantheon/permission-eval.ts +284 -0
- package/src/pantheon/step-cap.ts +110 -0
- package/src/pantheon/token-opt.ts +781 -0
- package/src/pantheon/toon-codec.ts +481 -0
- package/src/pantheon/tui-version-check.ts +1 -3
- package/src/pantheon/v2-bridge.ts +129 -0
- package/src/pantheon/v2-events.ts +131 -0
- package/src/pantheon/v2-hooks.ts +287 -0
- package/src/pantheon/v2-tool-definitions.ts +331 -0
- package/src/pantheon/zeus-delegate-with-retry.ts +406 -0
- package/src/plugin-v2/index.ts +21 -0
- package/src/plugin-v2.ts +824 -0
- package/src/plugin.ts +236 -42
- package/src/plugins/tui/dist/server.d.ts +1 -1
- package/src/plugins/tui/dist/server.d.ts.map +1 -1
- package/src/plugins/tui/dist/server.js +1 -1
- package/src/plugins/tui/dist/server.js.map +1 -1
- package/src/plugins/tui/dist/tui.d.ts +10 -7
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +11 -8
- package/src/plugins/tui/dist/tui.js.map +1 -1
- package/src/plugins/tui/dist/tui.tsx +22 -16
- package/src/plugins/tui/package-lock.json +51 -51
- package/src/plugins/tui/package.json +7 -7
- package/src/plugins/tui/src/index.tsx +28 -24
- package/src/skills/README.md +0 -2
- package/src/pantheon/tool-context.ts +0 -41
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ---
|
|
3
|
+
# description: plugin-eval orchestrator — runs static + LLM judge + Monte Carlo layers and emits one report JSON
|
|
4
|
+
# timeout: 300
|
|
5
|
+
# ---
|
|
6
|
+
"""Orchestrator for the plugin-eval certification pipeline (PR 3).
|
|
7
|
+
|
|
8
|
+
Runs the three layers in sequence (static → LLM judge → Monte Carlo) and
|
|
9
|
+
aggregates them into a single report JSON:
|
|
10
|
+
|
|
11
|
+
{name, date, static, llm_judge, monte_carlo, overall_score, verdict}
|
|
12
|
+
|
|
13
|
+
Verdict thresholds: certified >= 75, needs_work 50-74, failed < 50. Layer
|
|
14
|
+
scripts may exit non-zero to signal a below-threshold score while still
|
|
15
|
+
printing a valid report; such layers are marked ``below_threshold`` and
|
|
16
|
+
their scores count toward the overall. Layers that fail outright (e.g. no
|
|
17
|
+
LLM available, invalid JSON) are recorded with an `error` field and
|
|
18
|
+
excluded from the overall score; the report still emits.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import importlib.util
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import date
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
34
|
+
STATIC_SCRIPT = SCRIPT_DIR / "eval-static.py"
|
|
35
|
+
JUDGE_SCRIPT = SCRIPT_DIR / "eval-llm-judge.py"
|
|
36
|
+
MONTE_SCRIPT = SCRIPT_DIR / "eval-monte-carlo.py"
|
|
37
|
+
LAYER_TIMEOUT = 240.0
|
|
38
|
+
SCORE_KEYS = ("score", "overall", "overall_score", "reliability", "total")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_layer(script: Path, target: str, extra: list[str] | None = None) -> dict[str, Any]:
|
|
42
|
+
"""Run one layer script; return parsed JSON or an error dict.
|
|
43
|
+
|
|
44
|
+
A non-zero exit is not fatal by itself: layer scripts print their report
|
|
45
|
+
JSON before exiting non-zero to signal a below-threshold score (e.g.
|
|
46
|
+
eval-monte-carlo.py exits 1 when reliability < 75). In that case the
|
|
47
|
+
parsed report is returned with ``below_threshold`` set so its score
|
|
48
|
+
still counts toward the overall verdict. Only when stdout holds no
|
|
49
|
+
usable report does the layer degrade to an error dict.
|
|
50
|
+
"""
|
|
51
|
+
cmd = [sys.executable, str(script), target, *(extra or [])]
|
|
52
|
+
try:
|
|
53
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=LAYER_TIMEOUT, check=False)
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
return {"error": f"{script.name} timed out"}
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(proc.stdout)
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
if proc.returncode != 0:
|
|
60
|
+
return {"error": proc.stderr.strip() or f"{script.name} exited {proc.returncode}"}
|
|
61
|
+
return {"error": f"{script.name} returned invalid JSON: {exc}"}
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
if proc.returncode != 0:
|
|
64
|
+
return {"error": proc.stderr.strip() or f"{script.name} exited {proc.returncode}"}
|
|
65
|
+
return {"error": f"{script.name} returned non-object JSON"}
|
|
66
|
+
if proc.returncode != 0:
|
|
67
|
+
data["below_threshold"] = True
|
|
68
|
+
return data
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _extract_score(data: dict[str, Any], keys: tuple[str, ...]) -> float | None:
|
|
72
|
+
"""Find the first numeric score under any key, recursing into nested dicts."""
|
|
73
|
+
for key in keys:
|
|
74
|
+
value = data.get(key)
|
|
75
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
76
|
+
return float(value)
|
|
77
|
+
for value in data.values():
|
|
78
|
+
if isinstance(value, dict):
|
|
79
|
+
found = _extract_score(value, keys)
|
|
80
|
+
if found is not None:
|
|
81
|
+
return found
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _verdict(score: float) -> str:
|
|
86
|
+
if score >= 75:
|
|
87
|
+
return "certified"
|
|
88
|
+
if score >= 50:
|
|
89
|
+
return "needs_work"
|
|
90
|
+
return "failed"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _doc_name(path: Path) -> str:
|
|
94
|
+
"""Best-effort name from the doc frontmatter, falling back to the stem."""
|
|
95
|
+
if path.is_file():
|
|
96
|
+
doc = path
|
|
97
|
+
else:
|
|
98
|
+
skill = path / "SKILL.md"
|
|
99
|
+
doc = skill if skill.is_file() else path
|
|
100
|
+
try:
|
|
101
|
+
text = doc.read_text(encoding="utf-8", errors="replace")
|
|
102
|
+
except OSError:
|
|
103
|
+
return path.stem
|
|
104
|
+
if text.startswith("---"):
|
|
105
|
+
end = text.find("\n---", 3)
|
|
106
|
+
if end != -1:
|
|
107
|
+
for line in text[3:end].splitlines():
|
|
108
|
+
if line.startswith("name:"):
|
|
109
|
+
return line.split(":", 1)[1].strip().strip('"').strip("'")
|
|
110
|
+
return path.stem
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _persist_report(report: dict[str, Any]) -> None:
|
|
114
|
+
"""Best-effort publish of the final report to the ``plugin_eval`` namespace.
|
|
115
|
+
|
|
116
|
+
Reuses ``src/mcp/eval_store.py`` (stdlib-only, designed to be loaded from
|
|
117
|
+
.pantheon/code-mode/ scripts via importlib) so DB path resolution
|
|
118
|
+
(PANTHEON_HOME → XDG → ~/.config/opencode), schema and dedup stay in one
|
|
119
|
+
place. Never raises: failures print a hint to stderr — the JSON report on
|
|
120
|
+
stdout remains the single source of truth. No-op under pytest so tests
|
|
121
|
+
never touch the real memory DB.
|
|
122
|
+
"""
|
|
123
|
+
if "PYTEST_CURRENT_TEST" in os.environ:
|
|
124
|
+
return
|
|
125
|
+
try:
|
|
126
|
+
store_path = SCRIPT_DIR.parent.parent / "src" / "mcp" / "eval_store.py"
|
|
127
|
+
spec = importlib.util.spec_from_file_location("eval_store", str(store_path))
|
|
128
|
+
module = importlib.util.module_from_spec(spec)
|
|
129
|
+
spec.loader.exec_module(module)
|
|
130
|
+
result = module.store_eval(
|
|
131
|
+
report["name"], report, int(report.get("overall_score", 0)), report["date"]
|
|
132
|
+
)
|
|
133
|
+
if "error" in result:
|
|
134
|
+
print(f"eval_store: {result['error']}", file=sys.stderr)
|
|
135
|
+
except Exception: # noqa: BLE001
|
|
136
|
+
print("To publish: use eval_store.store_eval() or the pantheon-memory MCP",
|
|
137
|
+
file=sys.stderr)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
"""CLI entry point: `python eval-run.py <path> [--runs N] [--skip-llm]`."""
|
|
142
|
+
parser = argparse.ArgumentParser(
|
|
143
|
+
description="plugin-eval orchestrator (static + LLM judge + Monte Carlo)."
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument("path", help="Path to a skill/agent directory or doc file")
|
|
146
|
+
parser.add_argument("--runs", type=int, default=20, help="Monte Carlo runs (default 20)")
|
|
147
|
+
parser.add_argument("--skip-llm", action="store_true", help="Skip the LLM judge layer")
|
|
148
|
+
parser.add_argument("--allow-external-llm", action="store_true", help="Permit the LLM judge to send skill content externally")
|
|
149
|
+
args = parser.parse_args(argv)
|
|
150
|
+
|
|
151
|
+
report: dict[str, Any] = {
|
|
152
|
+
"name": _doc_name(Path(args.path)),
|
|
153
|
+
"date": date.today().isoformat(),
|
|
154
|
+
"static": {},
|
|
155
|
+
"llm_judge": {},
|
|
156
|
+
"monte_carlo": {},
|
|
157
|
+
}
|
|
158
|
+
scores: list[float] = []
|
|
159
|
+
|
|
160
|
+
if STATIC_SCRIPT.exists():
|
|
161
|
+
static = _run_layer(STATIC_SCRIPT, args.path)
|
|
162
|
+
report["static"] = static
|
|
163
|
+
score = _extract_score(static, SCORE_KEYS)
|
|
164
|
+
if score is not None:
|
|
165
|
+
scores.append(score)
|
|
166
|
+
else:
|
|
167
|
+
report["static"] = {"skipped": True, "reason": "eval-static.py not present yet"}
|
|
168
|
+
|
|
169
|
+
if args.skip_llm:
|
|
170
|
+
report["llm_judge"] = {"skipped": True}
|
|
171
|
+
else:
|
|
172
|
+
judge_extra = ["--allow-external-llm"] if args.allow_external_llm else []
|
|
173
|
+
judge = _run_layer(JUDGE_SCRIPT, args.path, judge_extra)
|
|
174
|
+
report["llm_judge"] = judge
|
|
175
|
+
score = _extract_score(judge, ("overall", "score"))
|
|
176
|
+
if score is not None:
|
|
177
|
+
scores.append(score)
|
|
178
|
+
|
|
179
|
+
monte = _run_layer(MONTE_SCRIPT, args.path, ["--runs", str(args.runs)])
|
|
180
|
+
report["monte_carlo"] = monte
|
|
181
|
+
score = _extract_score(monte, ("reliability", "score"))
|
|
182
|
+
if score is not None:
|
|
183
|
+
scores.append(score)
|
|
184
|
+
|
|
185
|
+
overall = round(sum(scores) / len(scores), 1) if scores else 0.0
|
|
186
|
+
report["overall_score"] = overall
|
|
187
|
+
report["layers_scored"] = len(scores)
|
|
188
|
+
if not scores:
|
|
189
|
+
report["note"] = "no layer produced a score; overall_score is 0"
|
|
190
|
+
report["verdict"] = _verdict(overall)
|
|
191
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
192
|
+
_persist_report(report)
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
sys.exit(main())
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# ---
|
|
3
|
+
# description: Static structural certification for a skill/agent directory
|
|
4
|
+
# timeout: 30
|
|
5
|
+
# ---
|
|
6
|
+
"""eval-static.py — static structural checks for Pantheon skills/agents.
|
|
7
|
+
|
|
8
|
+
Runs deterministic, LLM-free structural checks against a skill or agent
|
|
9
|
+
directory and prints a JSON report to stdout:
|
|
10
|
+
|
|
11
|
+
frontmatter — SKILL.md (or agent *.md) exists with name + description
|
|
12
|
+
referenced_files — files referenced in frontmatter or markdown links exist
|
|
13
|
+
secrets — no obvious secrets (API keys, tokens, private keys)
|
|
14
|
+
file_size — every file is under MAX_FILE_SIZE (100 KiB)
|
|
15
|
+
yaml — YAML frontmatter parses
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python eval-static.py <path-to-skill-or-agent-dir>
|
|
19
|
+
|
|
20
|
+
Output (stdout):
|
|
21
|
+
{"name": ..., "checks": [{"check", "pass", "detail"}, ...], "score": 0-100}
|
|
22
|
+
|
|
23
|
+
Exit codes:
|
|
24
|
+
0 — all checks passed
|
|
25
|
+
1 — one or more checks failed
|
|
26
|
+
2 — usage error (missing/invalid path)
|
|
27
|
+
|
|
28
|
+
Stdlib only. Paths are self-resolved (expanduser + resolve); nothing is
|
|
29
|
+
hardcoded. The frontmatter parser supports the flat YAML subset used by
|
|
30
|
+
skill manifests (scalars, flow lists, block lists) and raises on anything
|
|
31
|
+
it cannot parse so malformed frontmatter is flagged rather than ignored.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import argparse
|
|
37
|
+
import json
|
|
38
|
+
import re
|
|
39
|
+
import sys
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
MAX_FILE_SIZE = 100 * 1024 # every file must be < 100 KB
|
|
44
|
+
|
|
45
|
+
# High-confidence credential shapes. Keep in sync with scripts/secret-scan.mjs.
|
|
46
|
+
SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
|
47
|
+
("openai_key", re.compile(r"\b(sk|pk|rk)-[A-Za-z0-9]{20,}\b")),
|
|
48
|
+
("aws_access_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
|
|
49
|
+
("github_pat", re.compile(r"\bghp_[A-Za-z0-9]{36}\b")),
|
|
50
|
+
("slack_token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")),
|
|
51
|
+
("google_api_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
|
|
52
|
+
("private_key", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")),
|
|
53
|
+
(
|
|
54
|
+
"credential_assignment",
|
|
55
|
+
re.compile(
|
|
56
|
+
r"\b(?:api[_-]?key|secret|pass" + r"word|token)\s*[:=]\s*['\"]?[A-Za-z0-9_\-./+]{12,}",
|
|
57
|
+
re.IGNORECASE,
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# Frontmatter keys whose values may reference local files.
|
|
63
|
+
_REFERENCE_KEYS = ("scripts", "files", "location", "reference", "references")
|
|
64
|
+
|
|
65
|
+
# Markdown link targets that are NOT local file references.
|
|
66
|
+
_EXTERNAL_PREFIXES = ("http://", "https://", "mailto:", "#", "data:")
|
|
67
|
+
|
|
68
|
+
_FM_RE = re.compile(r"\A---\s*\n(.*?)\n---", re.DOTALL)
|
|
69
|
+
_KEY_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*(.*)$")
|
|
70
|
+
_CLOSERS = {"[": "]", "{": "}"}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ── Minimal YAML-subset frontmatter parser (stdlib) ──────────────────────────
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _scalar(raw: str) -> str:
|
|
77
|
+
"""Return a scalar value with optional surrounding quotes stripped."""
|
|
78
|
+
raw = raw.strip()
|
|
79
|
+
if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "'\"":
|
|
80
|
+
return raw[1:-1]
|
|
81
|
+
return raw
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_yaml_subset(text: str) -> dict[str, Any]:
|
|
85
|
+
"""Parse the flat YAML subset used by skill frontmatter.
|
|
86
|
+
|
|
87
|
+
Supports ``key: scalar``, ``key: [a, b]`` flow lists and block lists
|
|
88
|
+
(``key:`` followed by ``- item`` lines). Raises ValueError on anything
|
|
89
|
+
it cannot parse (e.g. unbalanced brackets), so callers can flag
|
|
90
|
+
malformed frontmatter instead of silently ignoring it.
|
|
91
|
+
"""
|
|
92
|
+
data: dict[str, Any] = {}
|
|
93
|
+
pending_list: str | None = None
|
|
94
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
95
|
+
stripped = line.strip()
|
|
96
|
+
if not stripped or stripped.startswith("#"):
|
|
97
|
+
continue
|
|
98
|
+
if stripped.startswith("-"):
|
|
99
|
+
if pending_list is None:
|
|
100
|
+
raise ValueError(f"line {lineno}: list item outside a mapping key")
|
|
101
|
+
data[pending_list].append(_scalar(stripped[1:]))
|
|
102
|
+
continue
|
|
103
|
+
match = _KEY_RE.match(line)
|
|
104
|
+
if not match:
|
|
105
|
+
raise ValueError(f"line {lineno}: unparsable line: {stripped!r}")
|
|
106
|
+
key, raw_value = match.group(1), match.group(2).strip()
|
|
107
|
+
pending_list = None
|
|
108
|
+
if not raw_value:
|
|
109
|
+
data[key] = []
|
|
110
|
+
pending_list = key # promoted to a real list if "- item" follows
|
|
111
|
+
continue
|
|
112
|
+
if raw_value[0] in _CLOSERS:
|
|
113
|
+
closer = _CLOSERS[raw_value[0]]
|
|
114
|
+
if not raw_value.endswith(closer):
|
|
115
|
+
raise ValueError(f"line {lineno}: unbalanced {raw_value[0]!r} in {key!r}")
|
|
116
|
+
inner = raw_value[1:-1].strip()
|
|
117
|
+
data[key] = [_scalar(v) for v in inner.split(",")] if inner else []
|
|
118
|
+
continue
|
|
119
|
+
data[key] = _scalar(raw_value)
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def frontmatter_text(content: str) -> str | None:
|
|
124
|
+
"""Extract the raw frontmatter body from markdown content, or None."""
|
|
125
|
+
match = _FM_RE.match(content)
|
|
126
|
+
return match.group(1) if match else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_frontmatter(filepath: Path) -> dict[str, Any]:
|
|
130
|
+
"""Parse frontmatter into a dict; {} when absent or unparsable."""
|
|
131
|
+
text = frontmatter_text(filepath.read_text(encoding="utf-8", errors="replace"))
|
|
132
|
+
if text is None:
|
|
133
|
+
return {}
|
|
134
|
+
try:
|
|
135
|
+
return parse_yaml_subset(text)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ── Path helpers ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def find_manifest(dir_path: Path) -> Path | None:
|
|
144
|
+
"""Return the primary manifest file for a skill/agent directory.
|
|
145
|
+
|
|
146
|
+
Prefers ``SKILL.md``; otherwise the first ``*.md`` file that is not
|
|
147
|
+
``README.md`` (agent files). Returns None if no manifest exists.
|
|
148
|
+
"""
|
|
149
|
+
skill = dir_path / "SKILL.md"
|
|
150
|
+
if skill.is_file():
|
|
151
|
+
return skill
|
|
152
|
+
for f in sorted(dir_path.iterdir()):
|
|
153
|
+
if f.is_file() and f.suffix == ".md" and f.stem.lower() != "readme":
|
|
154
|
+
return f
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _iter_files(dir_path: Path) -> list[Path]:
|
|
159
|
+
"""Return all regular files under dir_path (recursive, sorted)."""
|
|
160
|
+
return sorted(p for p in dir_path.rglob("*") if p.is_file())
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ── Checks ────────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def check_frontmatter(dir_path: Path) -> tuple[bool, str]:
|
|
167
|
+
"""Check (a): manifest exists with required name + description."""
|
|
168
|
+
manifest = find_manifest(dir_path)
|
|
169
|
+
if manifest is None:
|
|
170
|
+
return False, "No SKILL.md or agent .md manifest found"
|
|
171
|
+
fm = parse_frontmatter(manifest)
|
|
172
|
+
missing = [k for k in ("name", "description") if not fm.get(k)]
|
|
173
|
+
if missing:
|
|
174
|
+
return False, f"{manifest.name} missing frontmatter: {', '.join(missing)}"
|
|
175
|
+
return True, f"{manifest.name} has name + description"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _frontmatter_references(fm: dict[str, Any]) -> list[str]:
|
|
179
|
+
"""Collect file-path-looking values from frontmatter reference keys."""
|
|
180
|
+
refs: list[str] = []
|
|
181
|
+
for key in _REFERENCE_KEYS:
|
|
182
|
+
value = fm.get(key)
|
|
183
|
+
if isinstance(value, str):
|
|
184
|
+
refs.append(value)
|
|
185
|
+
elif isinstance(value, list):
|
|
186
|
+
refs.extend(v for v in value if isinstance(v, str))
|
|
187
|
+
return [r for r in refs if r and not r.startswith(("http://", "https://", "#"))]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _markdown_references(manifest: Path) -> list[str]:
|
|
191
|
+
"""Collect relative markdown link targets from the manifest body."""
|
|
192
|
+
content = manifest.read_text(encoding="utf-8", errors="replace")
|
|
193
|
+
refs: list[str] = []
|
|
194
|
+
for target in re.findall(r"\]\(([^)]+)\)", content):
|
|
195
|
+
target = target.strip()
|
|
196
|
+
if not target or target.startswith(_EXTERNAL_PREFIXES):
|
|
197
|
+
continue
|
|
198
|
+
# Strip optional anchor suffix (path#section)
|
|
199
|
+
refs.append(target.split("#", 1)[0])
|
|
200
|
+
return refs
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_referenced_files(dir_path: Path) -> tuple[bool, str]:
|
|
204
|
+
"""Check (b): referenced scripts/files exist relative to the dir."""
|
|
205
|
+
manifest = find_manifest(dir_path)
|
|
206
|
+
refs = _frontmatter_references(parse_frontmatter(manifest)) if manifest else []
|
|
207
|
+
if manifest is not None:
|
|
208
|
+
refs.extend(_markdown_references(manifest))
|
|
209
|
+
|
|
210
|
+
missing = [r for r in refs if r and not (dir_path / r).exists()]
|
|
211
|
+
if missing:
|
|
212
|
+
return False, f"Missing referenced files: {', '.join(sorted(set(missing)))}"
|
|
213
|
+
if refs:
|
|
214
|
+
return True, f"All {len(refs)} referenced files exist"
|
|
215
|
+
return True, "No local file references found"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def check_secrets(dir_path: Path) -> tuple[bool, str]:
|
|
219
|
+
"""Check (c): no obvious secrets (API keys, tokens, private keys)."""
|
|
220
|
+
hits: list[str] = []
|
|
221
|
+
for f in _iter_files(dir_path):
|
|
222
|
+
try:
|
|
223
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
224
|
+
except OSError:
|
|
225
|
+
continue
|
|
226
|
+
for name, pattern in SECRET_PATTERNS:
|
|
227
|
+
if pattern.search(content):
|
|
228
|
+
hits.append(f"{f.name} ({name})")
|
|
229
|
+
if hits:
|
|
230
|
+
return False, f"Potential secrets: {', '.join(sorted(set(hits)))}"
|
|
231
|
+
return True, "No secrets detected"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def check_file_sizes(dir_path: Path) -> tuple[bool, str]:
|
|
235
|
+
"""Check (d): every file is under MAX_FILE_SIZE."""
|
|
236
|
+
oversized = [f for f in _iter_files(dir_path) if f.stat().st_size > MAX_FILE_SIZE]
|
|
237
|
+
if oversized:
|
|
238
|
+
biggest = max(oversized, key=lambda f: f.stat().st_size)
|
|
239
|
+
return False, f"Oversized file: {biggest.name} ({biggest.stat().st_size} bytes)"
|
|
240
|
+
return True, f"All files under {MAX_FILE_SIZE} bytes"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def check_yaml(dir_path: Path) -> tuple[bool, str]:
|
|
244
|
+
"""Check (e): YAML frontmatter parses."""
|
|
245
|
+
manifest = find_manifest(dir_path)
|
|
246
|
+
if manifest is None:
|
|
247
|
+
return True, "No manifest to parse"
|
|
248
|
+
text = frontmatter_text(manifest.read_text(encoding="utf-8", errors="replace"))
|
|
249
|
+
if text is None:
|
|
250
|
+
return True, "No frontmatter to parse"
|
|
251
|
+
try:
|
|
252
|
+
parse_yaml_subset(text)
|
|
253
|
+
except ValueError as exc:
|
|
254
|
+
return False, f"Frontmatter does not parse: {exc}"
|
|
255
|
+
return True, "Frontmatter parses"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_CHECK_FUNCS: tuple[tuple[str, Any], ...] = (
|
|
259
|
+
("frontmatter", check_frontmatter),
|
|
260
|
+
("referenced_files", check_referenced_files),
|
|
261
|
+
("secrets", check_secrets),
|
|
262
|
+
("file_size", check_file_sizes),
|
|
263
|
+
("yaml", check_yaml),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ── Report ────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def run_eval(dir_path: Path) -> dict[str, Any]:
|
|
271
|
+
"""Run all checks and build the certification report dict."""
|
|
272
|
+
results = [(name, fn(dir_path)) for name, fn in _CHECK_FUNCS]
|
|
273
|
+
checks = [
|
|
274
|
+
{"check": name, "pass": ok, "detail": detail}
|
|
275
|
+
for name, (ok, detail) in results
|
|
276
|
+
]
|
|
277
|
+
passed = sum(1 for _, (ok, _) in results if ok)
|
|
278
|
+
total = len(results)
|
|
279
|
+
|
|
280
|
+
manifest = find_manifest(dir_path)
|
|
281
|
+
fm = parse_frontmatter(manifest) if manifest else {}
|
|
282
|
+
return {
|
|
283
|
+
"name": str(fm.get("name") or dir_path.name),
|
|
284
|
+
"checks": checks,
|
|
285
|
+
"score": round(passed / total * 100) if total else 0,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str] | None = None) -> int:
|
|
293
|
+
"""CLI entrypoint: eval a skill/agent dir, print JSON, set exit code."""
|
|
294
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
295
|
+
|
|
296
|
+
parser = argparse.ArgumentParser(
|
|
297
|
+
prog="eval-static.py",
|
|
298
|
+
description="Static structural certification for a skill/agent directory.",
|
|
299
|
+
)
|
|
300
|
+
parser.add_argument("path", nargs="?", help="Path to the skill or agent directory")
|
|
301
|
+
args = parser.parse_args(argv)
|
|
302
|
+
|
|
303
|
+
if not args.path:
|
|
304
|
+
print("Usage: python eval-static.py <skill-or-agent-dir>", file=sys.stderr)
|
|
305
|
+
return 2
|
|
306
|
+
|
|
307
|
+
dir_path = Path(args.path).expanduser().resolve()
|
|
308
|
+
if not dir_path.exists():
|
|
309
|
+
print(f"Error: path not found: {args.path}", file=sys.stderr)
|
|
310
|
+
return 2
|
|
311
|
+
if not dir_path.is_dir():
|
|
312
|
+
print(f"Error: not a directory: {args.path}", file=sys.stderr)
|
|
313
|
+
return 2
|
|
314
|
+
|
|
315
|
+
report = run_eval(dir_path)
|
|
316
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
317
|
+
return 0 if all(c["pass"] for c in report["checks"]) else 1
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
if __name__ == "__main__":
|
|
321
|
+
sys.exit(main())
|