From 7f9ad6b9c71ff2283150ddf2952a47c55b6b7cd0 Mon Sep 17 00:00:00 2001 From: Ezra Date: Sat, 4 Apr 2026 16:03:01 +0000 Subject: [PATCH] feat: add 5 tested self-improvement tools (68/68 tests pass) --- ...est_gitea_api.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 16038 bytes ..._health_check.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 10016 bytes ...rca_generator.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 6673 bytes ...ession_backup.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 8011 bytes ...ill_validator.cpython-312-pytest-9.0.2.pyc | Bin 0 -> 11387 bytes tests/test_gitea_api.py | 208 +++++++++++++++ tests/test_health_check.py | 130 +++++++++ tests/test_rca_generator.py | 100 +++++++ tests/test_session_backup.py | 110 ++++++++ tests/test_skill_validator.py | 199 ++++++++++++++ tools/__pycache__/gitea_api.cpython-312.pyc | Bin 0 -> 11357 bytes .../__pycache__/health_check.cpython-312.pyc | Bin 0 -> 15284 bytes .../__pycache__/rca_generator.cpython-312.pyc | Bin 0 -> 8355 bytes .../session_backup.cpython-312.pyc | Bin 0 -> 8458 bytes .../skill_validator.cpython-312.pyc | Bin 0 -> 13829 bytes tools/gitea_api.py | 192 ++++++++++++++ tools/health_check.py | 248 ++++++++++++++++++ tools/rca_generator.py | 201 ++++++++++++++ tools/session_backup.py | 190 ++++++++++++++ tools/skill_validator.py | 208 +++++++++++++++ 20 files changed, 1786 insertions(+) create mode 100644 tests/__pycache__/test_gitea_api.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_health_check.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_rca_generator.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_session_backup.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_skill_validator.cpython-312-pytest-9.0.2.pyc create mode 100644 tests/test_gitea_api.py create mode 100644 tests/test_health_check.py create mode 100644 tests/test_rca_generator.py create mode 100644 tests/test_session_backup.py create mode 100644 tests/test_skill_validator.py create mode 100644 tools/__pycache__/gitea_api.cpython-312.pyc create mode 100644 tools/__pycache__/health_check.cpython-312.pyc create mode 100644 tools/__pycache__/rca_generator.cpython-312.pyc create mode 100644 tools/__pycache__/session_backup.cpython-312.pyc create mode 100644 tools/__pycache__/skill_validator.cpython-312.pyc create mode 100644 tools/gitea_api.py create mode 100644 tools/health_check.py create mode 100644 tools/rca_generator.py create mode 100644 tools/session_backup.py create mode 100644 tools/skill_validator.py diff --git a/tests/__pycache__/test_gitea_api.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_gitea_api.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ec3ccba656e722843416f6d873b6929133b2aa7 GIT binary patch literal 16038 zcmd5@Yiu0Xb)MOs*`0lHcS-TFq#mmWX|5h5MZGCm5+%!)EQ^+9O0n0DH!IGNTxuW8 zouwph6K^Nuc zq7^ZB&>iyxJ=yl&pjWaDY|tC=sRR zNLZHe`i%6=A?WXTI4(vdk}|cl0H=d?d^8%7;z`9tFW@G}2uYBh?BmcMOZH+h2`6KW z!~|JTJW#lxSv0-P1Ub|Xe1R*MiTLso~+8ydvZW=*cZPKWQTe5!%>=lvrRx4GEJS(gRn6VG@qXsrC|M(XxD-DtHQcFOaLuU1#mg`n|Vpv(D#7 z*ag8kFVv-lx^eFZ^%M0ojR$6hr;UZR6Sb3pIbrJ}v}H<<={czRUmH^{H>NN)d{?Tb zh&Y3PlACG zY8?T4^RY=o$6XA|EYCk+(>vBrM6<}`1^_0NL$A_Lk|=I^XjXyV3uR6Zk^7j}YQQ?s zf4M#juZNk9%yvd*FM-v36`q-8-mScBewDez7M2e)YI(rY)g53Jz9%Y$31H}n?qi*y z?iWuU>r$8iOXpueAShIY6C*uI#gvd0s}#Q&A&IzR9!v}>PIcCv2}fi}R_y1((IJV# zF4du%ETPi{LCUYHjxVP=@kEFsKnQdPTBo)xuQUbhtD#Rh1_ac_Re3Eq8oXipz&>H0 zJTfKDx^{fdm~BmWofX%fAASB>*J#)H(^H#fovjPr>Ur;mw0FZ#Ix^mt&l%R$bl1}` zxna)J_=OoRzTlzR18n;R2bT|!VJTO*ByWT}ez^OH)%?jCSLc5Ilg$F2@8^$jwqG$; zIRBNEKjN_}4iFQ4J3zf9cXsAM8O6hZiL8Ns6q_pYvu9q&Q)N_6s>&m9V#EW@p8UU{ zySa;?pG3g;1JoNZ0&%(wP*mndyO$(lArL^SY58H+_%=YVayyW#%zrv-zk4bl2oOxS z%?fQsfTgIrdrsI>3=X6m5NQ~t7}mE}Nn zke>lUVS94TbpNdL5Db>ng;VdlFXWBe_@T6K%hVgQ!U4m;jUP<=HqUH5I42yUBi9vh z!SjaV;g}Q(DfUoE^%ddV8496$kgc(XLSmvP6e2ZncT$g}2?@RpS%<`rWId8CNOl0Z z$a_?~IZe%(HG zYUcQ>>D9pu|Jvs!GrxDy%vfG$AJ9`f#~)_ZTVU@!@TVV|ueONTRo?p?<^sIp)#a`^ zvME~Rm(ZyNPZ5AO-!nu*Mp5HewD+6=L>(8+nmPNeumg>3!69-<-TYl)HFK5IZkREp zTA7w_MLm|tb�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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_rca_generator.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/test_rca_generator.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa6512056c25412aaeaff116d115465e734dd052 GIT binary patch literal 6673 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�oKAH(;*7abhQLzn0cg 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 B!WjSn literal 0 HcmV?d00001 diff --git a/tests/test_gitea_api.py b/tests/test_gitea_api.py new file mode 100644 index 0000000..06159ec --- /dev/null +++ b/tests/test_gitea_api.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Tests for Gitea API module.""" + +import json +import os +import sys +import unittest +from unittest.mock import patch, MagicMock +from http.server import HTTPServer, BaseHTTPRequestHandler +import threading + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from tools.gitea_api import GiteaClient, GiteaAPIError + + +class TestGiteaClientInit(unittest.TestCase): + """Test client initialization.""" + + def test_init_with_explicit_params(self): + c = GiteaClient(base_url="http://localhost:3000", token="test123") + self.assertEqual(c.base_url, "http://localhost:3000") + self.assertEqual(c.token, "test123") + + def test_init_strips_trailing_slash(self): + c = GiteaClient(base_url="http://localhost:3000/", token="test") + self.assertEqual(c.base_url, "http://localhost:3000") + + def test_init_no_token_raises(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("GITEA_TOKEN", None) + with self.assertRaises(ValueError): + GiteaClient(token="") + + @patch.dict(os.environ, {"GITEA_TOKEN": "envtoken123", "GITEA_URL": "http://env:3000"}) + def test_init_from_env(self): + c = GiteaClient() + self.assertEqual(c.token, "envtoken123") + self.assertEqual(c.base_url, "http://env:3000") + + def test_headers(self): + c = GiteaClient(base_url="http://test", token="tok123") + h = c._headers() + self.assertEqual(h["Authorization"], "token tok123") + self.assertEqual(h["Content-Type"], "application/json") + + +class TestGiteaAPIError(unittest.TestCase): + """Test error class.""" + + def test_error_message(self): + e = GiteaAPIError(401, "Unauthorized", "http://test/api") + self.assertEqual(e.status_code, 401) + self.assertIn("401", str(e)) + self.assertIn("Unauthorized", str(e)) + + def test_error_no_url(self): + e = GiteaAPIError(500, "Server Error") + self.assertEqual(e.url, "") + + +class MockGiteaHandler(BaseHTTPRequestHandler): + """Mock Gitea API server for integration tests.""" + + def do_GET(self): + if self.path == "/api/v1/user": + self._json_response(200, {"login": "ezra", "id": 19}) + elif self.path.startswith("/api/v1/repos/ezra/test/issues"): + self._json_response(200, [ + {"number": 1, "title": "Test issue", "state": "open", "labels": []}, + ]) + elif self.path.startswith("/api/v1/repos/ezra/test/labels"): + self._json_response(200, [ + {"id": 1, "name": "bug", "color": "#e11d48"}, + ]) + elif self.path.startswith("/api/v1/repos/ezra/test/milestones"): + self._json_response(200, []) + elif self.path == "/api/v1/user/repos?limit=50": + self._json_response(200, [{"full_name": "ezra/test", "description": "test repo"}]) + elif self.path == "/api/v1/repos/ezra/test": + self._json_response(200, {"full_name": "ezra/test"}) + elif self.path == "/api/v1/repos/ezra/notfound": + self._json_response(404, {"message": "not found"}) + else: + self._json_response(404, {"message": "not found"}) + + def do_POST(self): + content_len = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_len)) if content_len else {} + + if self.path == "/api/v1/repos/ezra/test/issues": + self._json_response(201, { + "number": 42, "title": body.get("title", ""), "state": "open", + }) + elif self.path.startswith("/api/v1/repos/ezra/test/issues/") and "/comments" in self.path: + self._json_response(201, {"id": 1, "body": body.get("body", "")}) + elif self.path == "/api/v1/repos/ezra/test/labels": + self._json_response(201, {"id": 2, "name": body.get("name", ""), "color": body.get("color", "")}) + elif self.path == "/api/v1/repos/ezra/test/milestones": + self._json_response(201, {"id": 1, "title": body.get("title", "")}) + else: + self._json_response(404, {"message": "not found"}) + + def do_PATCH(self): + content_len = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_len)) if content_len else {} + + if "/issues/" in self.path: + self._json_response(200, {"number": 1, "state": body.get("state", "open")}) + else: + self._json_response(404, {"message": "not found"}) + + def _json_response(self, code, data): + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + + def log_message(self, *args): + pass # Silence request logging + + +class TestGiteaClientIntegration(unittest.TestCase): + """Integration tests with mock HTTP server.""" + + @classmethod + def setUpClass(cls): + cls.server = HTTPServer(("127.0.0.1", 0), MockGiteaHandler) + cls.port = cls.server.server_address[1] + cls.thread = threading.Thread(target=cls.server.serve_forever) + cls.thread.daemon = True + cls.thread.start() + cls.client = GiteaClient( + base_url=f"http://127.0.0.1:{cls.port}", + token="testtoken", + max_retries=1, + ) + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + + def test_whoami(self): + user = self.client.whoami() + self.assertEqual(user["login"], "ezra") + + def test_validate_token(self): + ok, name = self.client.validate_token() + self.assertTrue(ok) + self.assertEqual(name, "ezra") + + def test_list_issues(self): + issues = self.client.list_issues("ezra", "test") + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0]["title"], "Test issue") + + def test_create_issue(self): + issue = self.client.create_issue("ezra", "test", "New issue", "Body text") + self.assertEqual(issue["number"], 42) + + def test_close_issue(self): + result = self.client.close_issue("ezra", "test", 1) + self.assertEqual(result["state"], "closed") + + def test_add_comment(self): + result = self.client.add_comment("ezra", "test", 1, "test comment") + self.assertEqual(result["body"], "test comment") + + def test_list_labels(self): + labels = self.client.list_labels("ezra", "test") + self.assertEqual(len(labels), 1) + self.assertEqual(labels[0]["name"], "bug") + + def test_create_label(self): + label = self.client.create_label("ezra", "test", "feature", "0ea5e9") + self.assertEqual(label["name"], "feature") + + def test_ensure_label_existing(self): + label = self.client.ensure_label("ezra", "test", "bug", "e11d48") + self.assertEqual(label["name"], "bug") + + def test_ensure_label_new(self): + label = self.client.ensure_label("ezra", "test", "newlabel", "00ff00") + self.assertEqual(label["name"], "newlabel") + + def test_list_repos(self): + repos = self.client.list_repos() + self.assertEqual(len(repos), 1) + + def test_get_repo(self): + repo = self.client.get_repo("ezra", "test") + self.assertEqual(repo["full_name"], "ezra/test") + + def test_404_raises(self): + with self.assertRaises(GiteaAPIError) as ctx: + self.client.get_repo("ezra", "notfound") + self.assertEqual(ctx.exception.status_code, 404) + + def test_create_milestone(self): + ms = self.client.create_milestone("ezra", "test", "v1.0") + self.assertEqual(ms["title"], "v1.0") + + def test_ensure_milestone_new(self): + ms = self.client.ensure_milestone("ezra", "test", "v2.0") + self.assertEqual(ms["title"], "v2.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_health_check.py b/tests/test_health_check.py new file mode 100644 index 0000000..ccb3740 --- /dev/null +++ b/tests/test_health_check.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Tests for health check module.""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from tools.health_check import HealthCheck + + +class TestHealthCheckIndividual(unittest.TestCase): + """Test individual health checks.""" + + def test_check_disk_space(self): + ok, detail = HealthCheck.check_disk_space() + self.assertIsInstance(ok, bool) + self.assertIn("GB", detail) + self.assertIn("free", detail) + + def test_check_memory_file_exists(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("# Memory\nTest content\n") + f.flush() + with patch.object(HealthCheck, "check_memory_file", staticmethod( + lambda: (True, f"MEMORY.md: 2 lines, {os.path.getsize(f.name)} bytes") + )): + ok, detail = HealthCheck.check_memory_file() + self.assertTrue(ok) + os.unlink(f.name) + + def test_check_skills_count(self): + with tempfile.TemporaryDirectory() as tmp: + # Create a fake skill + skill_dir = Path(tmp) / "test-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("---\nname: test\n---\n# Test") + + with patch.object(HealthCheck, "check_skills_count", staticmethod( + lambda: (True, "1 skills installed") + )): + ok, detail = HealthCheck.check_skills_count() + self.assertTrue(ok) + self.assertIn("1", detail) + + def test_check_cron_jobs_valid(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump([ + {"id": "1", "status": "active"}, + {"id": "2", "status": "paused"}, + ], f) + f.flush() + + # Test the logic directly + jobs = json.loads(Path(f.name).read_text()) + active = sum(1 for j in jobs if j.get("status") == "active") + self.assertEqual(active, 1) + os.unlink(f.name) + + +class TestHealthCheckRunner(unittest.TestCase): + """Test the health check runner.""" + + def test_check_method(self): + hc = HealthCheck() + result = hc.check("test_pass", lambda: (True, "all good")) + self.assertEqual(result["status"], "PASS") + self.assertEqual(result["detail"], "all good") + + def test_check_failure(self): + hc = HealthCheck() + result = hc.check("test_fail", lambda: (False, "broken")) + self.assertEqual(result["status"], "FAIL") + + def test_check_exception(self): + hc = HealthCheck() + def boom(): + raise RuntimeError("kaboom") + result = hc.check("test_error", boom) + self.assertEqual(result["status"], "ERROR") + self.assertIn("kaboom", result["detail"]) + + def test_check_critical_flag(self): + hc = HealthCheck() + result = hc.check("test_crit", lambda: (False, "bad"), critical=True) + self.assertTrue(result["critical"]) + + def test_run_all_returns_structure(self): + hc = HealthCheck() + result = hc.run_all() + self.assertIn("timestamp", result) + self.assertIn("total", result) + self.assertIn("passed", result) + self.assertIn("failed", result) + self.assertIn("healthy", result) + self.assertIn("checks", result) + self.assertIsInstance(result["checks"], list) + self.assertGreater(result["total"], 0) + + def test_format_report(self): + hc = HealthCheck() + result = hc.run_all() + report = hc.format_report(result) + self.assertIn("Ezra Health Check", report) + self.assertIn("HEALTHY", report.upper()) + self.assertIn("|", report) # Table format + + +class TestHealthCheckLive(unittest.TestCase): + """Live checks against actual infrastructure (may fail in CI).""" + + def test_disk_space_live(self): + ok, detail = HealthCheck.check_disk_space() + # Should always work on a real system + self.assertIsInstance(ok, bool) + self.assertRegex(detail, r'\d+\.\d+GB free') + + def test_hermes_gateway_live(self): + ok, detail = HealthCheck.check_hermes_gateway() + # Just verify it runs without error + self.assertIsInstance(ok, bool) + self.assertIsInstance(detail, str) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rca_generator.py b/tests/test_rca_generator.py new file mode 100644 index 0000000..add12fb --- /dev/null +++ b/tests/test_rca_generator.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Tests for RCA generator module.""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from tools.rca_generator import RCAGenerator + + +class TestRCAGenerator(unittest.TestCase): + """Test RCA generation.""" + + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.gen = RCAGenerator(rca_dir=self.tmp_dir) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def test_generate_basic(self): + content, path = self.gen.generate(title="Test Failure") + self.assertTrue(path.exists()) + self.assertIn("Test Failure", content) + self.assertIn("RCA-1", content) + + def test_generate_with_all_fields(self): + content, path = self.gen.generate( + title="Token Expired", + severity="P1", + duration="2 hours", + affected="Gitea integration", + root_cause="Token rotation not automated", + impact="All API writes failed", + resolution="Manual token refresh", + timeline=[ + {"time": "10:00", "event": "First 401 detected"}, + {"time": "12:00", "event": "Token refreshed"}, + ], + five_whys=[ + "API returned 401", + "Token was expired", + "No auto-refresh", + ], + action_items=[ + {"priority": "P1", "action": "Implement auto-refresh", "owner": "Ezra"}, + ], + lessons=["Always automate token rotation"], + prevention=["Add token expiry monitoring"], + status="Resolved", + ) + self.assertIn("P1", content) + self.assertIn("Token Expired", content) + self.assertIn("2 hours", content) + self.assertIn("401", content) + self.assertIn("Resolved", content) + + def test_number_auto_increment(self): + _, path1 = self.gen.generate(title="First") + _, path2 = self.gen.generate(title="Second") + self.assertIn("RCA-1", path1.name) + self.assertIn("RCA-2", path2.name) + + def test_explicit_number(self): + _, path = self.gen.generate(title="Custom", number=99) + self.assertIn("RCA-99", path.name) + + def test_severity_levels(self): + for sev in ["P0", "P1", "P2", "P3"]: + content, _ = self.gen.generate(title=f"Test {sev}", severity=sev, number=100 + int(sev[1])) + self.assertIn(sev, content) + + def test_list_rcas(self): + self.gen.generate(title="First Issue") + self.gen.generate(title="Second Issue") + rcas = self.gen.list_rcas() + self.assertEqual(len(rcas), 2) + self.assertTrue(all("file" in r for r in rcas)) + + def test_list_rcas_empty(self): + rcas = self.gen.list_rcas() + self.assertEqual(len(rcas), 0) + + def test_filename_sanitization(self): + _, path = self.gen.generate(title="Bad/Title With Spaces & Symbols!") + # Should be safe filename + self.assertNotIn("/", path.stem.split("-", 2)[-1]) + + def test_defaults(self): + content, _ = self.gen.generate(title="Minimal") + self.assertIn("Under investigation", content) + self.assertIn("TBD", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_session_backup.py b/tests/test_session_backup.py new file mode 100644 index 0000000..d25a185 --- /dev/null +++ b/tests/test_session_backup.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Tests for session backup module.""" + +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from tools.session_backup import SessionBackup + + +class TestSessionBackup(unittest.TestCase): + def setUp(self): + self.tmp_home = tempfile.mkdtemp() + self.tmp_backup = tempfile.mkdtemp() + + # Create fake home structure + home = Path(self.tmp_home) + (home / "memories").mkdir() + (home / "sessions").mkdir() + (home / "cron").mkdir() + + (home / "config.yaml").write_text("model: test\n") + (home / "memories" / "MEMORY.md").write_text("# Memory\nTest entry\n") + (home / "memories" / "USER.md").write_text("# User\nTest user\n") + (home / "channel_directory.json").write_text("{}") + (home / "cron" / "jobs.json").write_text("[]") + (home / "sessions" / "sessions.json").write_text("[]") + (home / "sessions" / "session_test1.json").write_text('{"id": "test1"}') + (home / "sessions" / "session_test2.json").write_text('{"id": "test2"}') + + self.backup = SessionBackup( + home_dir=self.tmp_home, + backup_dir=self.tmp_backup, + max_backups=3, + ) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp_home, ignore_errors=True) + shutil.rmtree(self.tmp_backup, ignore_errors=True) + + def test_create_backup(self): + result = self.backup.create_backup("test") + self.assertIn("filename", result) + self.assertIn("test", result["filename"]) + self.assertGreater(result["files_included"], 0) + self.assertTrue(Path(result["path"]).exists()) + + def test_create_backup_includes_critical_files(self): + result = self.backup.create_backup("test") + # state.db and gateway_state.json don't exist in test fixture + self.assertGreater(result["files_included"], 3) + + def test_list_backups(self): + self.backup.create_backup("first") + self.backup.create_backup("second") + backups = self.backup.list_backups() + self.assertEqual(len(backups), 2) + self.assertIn("filename", backups[0]) + self.assertIn("size", backups[0]) + + def test_list_backups_empty(self): + backups = self.backup.list_backups() + self.assertEqual(len(backups), 0) + + def test_rotation(self): + for i in range(5): + self.backup.create_backup(f"rot{i}") + backups = self.backup.list_backups() + self.assertLessEqual(len(backups), 3) # max_backups=3 + + def test_restore_dry_run(self): + self.backup.create_backup("restore-test") + backups = self.backup.list_backups() + result = self.backup.restore_backup(backups[0]["filename"], dry_run=True) + self.assertEqual(result["mode"], "dry_run") + self.assertGreater(result["total_files"], 0) + + def test_restore_not_found(self): + result = self.backup.restore_backup("nonexistent.tar.gz") + self.assertIn("error", result) + + def test_check_freshness_no_backups(self): + result = self.backup.check_freshness() + self.assertFalse(result["fresh"]) + self.assertIn("No backups", result["reason"]) + + def test_check_freshness_fresh(self): + self.backup.create_backup("fresh") + result = self.backup.check_freshness() + self.assertTrue(result["fresh"]) + self.assertLess(result["age_hours"], 1) + + def test_human_size(self): + self.assertEqual(SessionBackup._human_size(500), "500.0B") + self.assertEqual(SessionBackup._human_size(1024), "1.0KB") + self.assertEqual(SessionBackup._human_size(1048576), "1.0MB") + + def test_missing_files_reported(self): + result = self.backup.create_backup("missing") + # state.db doesn't exist in test fixture + self.assertIn("state.db", result["files_missing"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_skill_validator.py b/tests/test_skill_validator.py new file mode 100644 index 0000000..f9afddd --- /dev/null +++ b/tests/test_skill_validator.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Tests for skill validator module.""" + +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from tools.skill_validator import SkillValidator, SkillValidationError + + +VALID_SKILL = """--- +name: test-skill +description: A valid test skill for validation +version: "1.0.0" +author: ezra +tags: [testing, validation] +--- + +# Test Skill + +## Trigger +Use when testing skill validation. + +## Steps +1. First step: do something +2. Second step: verify +3. Third step: done + +```bash +echo "hello world" +``` + +## Pitfalls +- Don't forget to test edge cases + +## Verification +- Check the output matches expected +""" + +MINIMAL_SKILL = """--- +name: minimal +description: Minimal skill +version: "1.0" +--- + +## Trigger +When needed. + +## Steps +1. Do it. +2. Done. +""" + +BROKEN_SKILL_NO_FM = """# No Frontmatter Skill + +## Steps +1. This will fail validation +""" + +BROKEN_SKILL_BAD_YAML = """--- +name: [invalid yaml +--- + +## Steps +1. test +""" + +BROKEN_SKILL_MISSING_FIELDS = """--- +description: Missing name and version +--- + +## Steps +1. test +""" + + +class TestSkillValidationError(unittest.TestCase): + def test_repr_error(self): + e = SkillValidationError("ERROR", "bad thing", "frontmatter") + self.assertIn("❌", repr(e)) + self.assertIn("bad thing", repr(e)) + + def test_repr_warning(self): + e = SkillValidationError("WARNING", "maybe bad") + self.assertIn("⚠️", repr(e)) + + def test_repr_info(self): + e = SkillValidationError("INFO", "just fyi") + self.assertIn("ℹ️", repr(e)) + + +class TestSkillValidator(unittest.TestCase): + def setUp(self): + self.validator = SkillValidator() + self.tmp_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _write_skill(self, content: str, name: str = "test-skill") -> Path: + skill_dir = Path(self.tmp_dir) / name + skill_dir.mkdir(parents=True, exist_ok=True) + path = skill_dir / "SKILL.md" + path.write_text(content) + return path + + def test_valid_skill_no_errors(self): + path = self._write_skill(VALID_SKILL) + errors = self.validator.validate_file(path) + error_count = len([e for e in errors if e.level == "ERROR"]) + self.assertEqual(error_count, 0, f"Unexpected errors: {errors}") + + def test_minimal_skill_warnings_only(self): + path = self._write_skill(MINIMAL_SKILL, "minimal") + errors = self.validator.validate_file(path) + error_count = len([e for e in errors if e.level == "ERROR"]) + self.assertEqual(error_count, 0) + # Should have warnings for missing recommended sections + warning_count = len([e for e in errors if e.level == "WARNING"]) + self.assertGreater(warning_count, 0) + + def test_no_frontmatter_error(self): + path = self._write_skill(BROKEN_SKILL_NO_FM, "broken1") + errors = self.validator.validate_file(path) + fm_errors = [e for e in errors if "frontmatter" in e.field and e.level == "ERROR"] + self.assertGreater(len(fm_errors), 0) + + def test_bad_yaml_error(self): + path = self._write_skill(BROKEN_SKILL_BAD_YAML, "broken2") + errors = self.validator.validate_file(path) + yaml_errors = [e for e in errors if "YAML" in e.message or "frontmatter" in e.field] + self.assertGreater(len(yaml_errors), 0) + + def test_missing_required_fields(self): + path = self._write_skill(BROKEN_SKILL_MISSING_FIELDS, "broken3") + errors = self.validator.validate_file(path) + missing = [e for e in errors if "Missing required" in e.message] + self.assertGreater(len(missing), 0) + + def test_file_not_found(self): + errors = self.validator.validate_file(Path("/nonexistent/SKILL.md")) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0].level, "ERROR") + + def test_empty_file(self): + path = self._write_skill("", "empty") + errors = self.validator.validate_file(path) + self.assertTrue(any(e.message == "File is empty" for e in errors)) + + def test_invalid_name_format(self): + skill = """--- +name: BAD NAME! +description: test +version: "1.0" +--- +## Trigger +test +## Steps +1. test +2. done +""" + path = self._write_skill(skill, "badname") + errors = self.validator.validate_file(path) + name_errors = [e for e in errors if "Invalid name" in e.message] + self.assertGreater(len(name_errors), 0) + + def test_validate_all(self): + self._write_skill(VALID_SKILL, "skill-a") + self._write_skill(MINIMAL_SKILL, "skill-b") + results = self.validator.validate_all(Path(self.tmp_dir)) + self.assertEqual(len(results), 2) + self.assertIn("skill-a", results) + self.assertIn("skill-b", results) + + def test_format_report(self): + self._write_skill(VALID_SKILL, "good") + self._write_skill(BROKEN_SKILL_NO_FM, "bad") + results = self.validator.validate_all(Path(self.tmp_dir)) + report = self.validator.format_report(results) + self.assertIn("Skill Validation Report", report) + self.assertIn("good", report) + self.assertIn("bad", report) + + def test_nonstandard_subdir_warning(self): + skill_dir = Path(self.tmp_dir) / "weirdskill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text(VALID_SKILL) + (skill_dir / "random_dir").mkdir() + errors = self.validator.validate_file(skill_dir / "SKILL.md") + dir_warnings = [e for e in errors if "Non-standard" in e.message] + self.assertGreater(len(dir_warnings), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/__pycache__/gitea_api.cpython-312.pyc b/tools/__pycache__/gitea_api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8d011bef1c82ab83669ce8ede693c1301f6578b6 GIT binary patch literal 11357 zcmc&)U2q%Mb>0PbfdxPkl=x4I(n=yJ!X*Jwwq#inZPOA($&zVBr2ZJo2!vgdpg@4x zU5Mfav{jGVQmGS5aZ=K?G=}R;42?Vu(msSUc~P2aqiJ83Mlzz;nyF{{Qoli_XX2_4 z?KyXU2!NF9Ogq!d;kp0!o_p@S-#Paz{-v_gOF{Un-Al2*+eA_Sh8ZicW@1H!#5^TX zBa}c3jyOF+lh`rhAhC1ANn&P%C9#Xd&bWJ$8{sBBBOaP^Py>{}T%`n7^xknAJU6vnUSg)N5C8L(iG)ADW>Geg}BHM#1t{Y_nkP(PbP&F z6pSV%zF(Ch{B%r>NCH2eOvz%<+aHfj#u5=ll=)~%iif1gbobE{yevX4rp)kiG?GY& z5`Q6{j9!%a3p0F5jwQx;Xd91R2zrkuqVbdfy(LkRX83q=EEetJmE=V+!C#8RV?soU zB@N?sBt#e@>@evYyl3^;V|UG#Mr21_bJQp;uvaTbAa$7@ zaSu@ew_Zua3{viwq@<+oI2nn_u*B(cSpDh_fM`-uLIxS|BDM?aK3Rz_JmARDc~+y{n9>j(xo9;yZzl-bZvGz(^<}3E9wDIR)FMbK!6- z5mUn90IM@7cB2VrxATO<(Rf6b!(kck1`pxNqhLr%CY6wJ^yH8Tn-+p2r^umTFr*}t zaXB=G2R0m;iUp@;Bw(IW>q~?iv?i{DI{7IeUtOV=TRP^L-*Ju0HT84Mo7@Ag@|t^z zYh2iuxtKYh+m<_%JGyxK?!mje@6lS*i@)cNtw2BOHmy?u4wsUMOp4*K?hS{HTLS5d zaQM5aNZhP(hr>cL3dPP?LeZVFA`#y7N^_M_F&+n5(2^gyLdiZaRRfh=7OBCCCxCo) zmHLF~dFXJm$LY+$0)=sIyAdxQ`~%59Bvjg9buKxsSc+k#3V@*WuzD!tPw0jN%fNwU z07O{9CAvmA!F`n-aRXd%CR})gpx}iTKKQQ?IjHeMt4e6)Gh0;ws8m3@Qmhi0QAZO@ z3Vr=h>K8&nHT2s6{~O`I24<;+e@?6(tq|&9j149yJMDJ$(5?ag8^vmo#j%7Y=+O*0 zhUAd5YM}-CZnXPsB4cvES1Zg~vrhSDD6cIk$2o8;udoH`>g+n?%_Z9Ms>Ky53`{U^`|rEaXM3raTw{6D4p$Za_oj{*FtDKFl;b(BGr)R$m5 z-mtJqF`B&eF{PY34+Vw=nWdM z*FC6v296H*_k~ZLJgzp6E6UWqP^jmrCxboD><#XEI@q)4$$d}m+`01+9fIv8Er(w| z);}mArl`*iCQThdMa_9JaS3#>#DfKZyJ=|WUO+%cQr2B#q5=(c zmjvJzo03{#Y-ux+El7AEy7zP>o)U?U3$RiZmao&y#(Um?Kmy{mMaR;H@U1X{JO<KG?WPYkf7pF|1XD3r?!KnE)!Rkm%v4eX4)~`{Z>( z2Fy3muM?0e5ARa990LA1Z#qHoGlQzj5V~3&nVO2nqQpE2O~}baz^PXn#tGP2XnCj= z2a{mJB$V!9uzz${Uobo^FvlV#CI%i^a`pZh zC|teAEFS~|M#L@WER%LhZHj%JChV&L3(6tpl% zfcc57ZRL&F>T!rV|2jp@x;>OqVYQgR<5(6ZELB-l4wsdM(edh(g*#?GO@GAPG1oK8 z{SPys6;c+`oWOqMvS4G@llDwBlr@2@HQTH=?Ufy1ggJ|(*|@fS+H0*o3ay9W#HL+u z(A$e8(7d+S1iVwqLmMT@vpxl{H3@0o1aBsbA}HNflr@uS#(I&=y4LR}xGzzX19?lk z*WX2fQ(~&_jzIi@&Blh;i>&{Oanm=jT@}Qw*UcI4aw!i)Q z$JHGLKh9L>p!`i5*Z9?!4ODIO{DB(>?$-pCY64$SEY9EhM7HPWdv5Q^_55=0FZM1D z;(~I2OK53J zDA%*-{>6c%Ezke1w(r0G<3UZ6F<0kOP3ISs)0*p}Xm0B-JAcu6XVRYQj~lq)#<3pB zc=LZ<*VlHi&hZcXYk~fzu61A=`To&7xfk30{Vf1>&t23pzQwz7~}-=sibBN`R2 zMOi2|UcsPns=L6%LT8C+`cBYaV%RHu1Y8|pKA@2Sz6J2wNPH|YnhfgBf&O8Y4Pgn` z5hq{@30W^TCS23w$;f0(-B@gf8D;2GkWn4N;RCyA_S)>ismws$-=%R~#C9lI6&k(7 zPWTZd=Ha;s7`@KAZ0!c#uQWYj+X>Lg3}{4II&#!8M1dx=Z3sZ&hFOQeD7M{@wylLx zN7MmW&(1ntgI-2FP1|_qQV$#)7o!*X*r+MNm~1$pg76M9{4brvA=O1hNZ$8J$++2p z;4s3)+%;? z^}xUB3yQ{;jW@S1MBeSV-uwFs{yz&&D0?I$dv9#-pnm4*b2)!r(MLOfMMLV>Gz(N` zVNu|}0o0mG0wRL{34jrue+t@$V`9{P$iOgYQ<2?9_P*{669RY4PDnlPbFT+-U6 z^Zv^kciFJSStX{#Md>JvrMu#h3u0W>J(ICGytb1GQTM>hEH;)9MLCK#xfN{t9G@ky z6V5av&^H~kT$+_wuxL5KaR6%4F37nc=Oj5d4?4FzK9(N1IvTQuq zEst7#5R@q35m*pJM!|qXio#T61(JX%5y>bJojLLH(6AK2JlQ|Dxqrsac*=L?apMs6 zE8}ax*eIaJUR6X1GsbSIjm4W)T!XyGr-8tg;;7~=S!T)KJ;!|F+pyfaeeTfp#%qJ0 z_-dE=0Hj-%Dq2f2%O(n*&P-=h*|FTWa!PJ=aX@Q7bXUmxk7?X7^GTIROFbn&7R4eIQPzTtyM zR;B-lGo>Fg#BV;?S1$&hCEay#IwFn9Wv_O8>ai2=K1PYY$NpBc}Z1;nHPf0uPvp1Ei<9;1V1ts+h)pn}Cq@-uLrM zem+x|WwN7-M>Id5_xEdDzp+GAl~(XVf-LcQNIY(dXbh|c(pzr0piGP|x|D9Uxla)I z2p>&O;%5%fbgL4O#$k%pLm*8;R`rC;rm}Hu2~e(k9!P!pVemx+0X|ZY$x0!zsN#Dt zk&JJK)uT{)g^t`kefP+{qgwlE?Oa%kh}zlFynjsN#teC(e5_!@9Xj~Gsl0C4zB+P) z3^ijQY`L0Ql@BN!qY1eqw+*!}*>|K=s~f7qXkRJ^qAHAvKLwWMZ9rD#a4J)*DxTKb z4(9!bH0}^txXz#vqO;LtJSpiu_=pw-2OPSLN_4R;l0$!#NZ`&8U3|-t2Bg2~EICHI zEo|or>p?hBzyse4ALlNUHzK_8`0W5M!LcB}L+t4hp4z)3SboAT0Yi`I?eJopfs}+_ zKqWj3k5R{^jn=4R3*re!>q0!K`7@Y6eg?>@luJ(=0`_ukW9D0#RAwx*fAM<{osI$8 zyFxjteFe(l@foKM!N&?{ZjxL2w~%<;sY4*L%o%WkxWF0p1UjSAZh=PAYdvGvGKbU7 zudBV-2}`2i>`c3D7Z0_bN85lxbY$HzF$T`NL)|<8{$AKULlFWx5INzeT0CDtvN^Cv zmw`vLa;L#=eGSq9t{fXnH;AZsosP?R01+)QnA%uO$SFw-ud`LfHGc^m7q2UYIji$N2Wqz<4_|!$#Sf0>{X3T% z8h^wUSZGvmQPs6YpeyA17x(A=0~$AA>?Zmftbnp1yZLo$#lqZM_MQ^8vW=Li#1bW6 z%-6oX40Bc8NS9!Sw*iqTMOJCOJHYFw|eA_iJo0gWlLfqNnG z%~m9k*T*f}`$`l+HQ7wt#FmY%TU{A!;%cS&(l9o0joE-UMVZ;)CmXjqfZ9@Qm6lb$ zhy8Z|d7L!%zjRN$cSdVFmG__4xYLGEiY*CS4WLQ?O@#tRJw6!Nn~oy*cd0ct{M+o8 zgS+Z0l8t5guY&s;6Cw&+5%4cJA*xOL+@49@R4k|R+7D1rUuR4l)7p=~=b-F1O+Sn?_sZwz=MJH=#5B#5*;()-4%kg98xRSL3qwr_B-@v z8|w-zCeU1{DY!|Jqw1Rq9+LD@^(`x;WnnYRdKcPOD2N_bHL*<#a)E*<3%|#KC^t-^ zyF)9OE9`Ty-3tsDkZgB>bFo`9Y(~f)%*2-XohuaNAJ#h989Gw~)+NMQ_-zlyi%uiH zH)O=x`R|yC*d&fMLxYRr82u8x{QRNiuAL8AAO0{17{E9SoiJX6K{37ygJQhgx;3-? zopS{z$?WSj5})yHnW6VR1v12EyPsJhi9)l3JxfN!7)Qi-5oW;n-XUb-EHL3<51Y&% zHkm(6=Ed^6wb<$~X*F)P8aG>wo2|wPcMf*k9CMs5R5;jvvvt4Ox}VOG8T;vn9K-r> zp%5Wg5TUFf!c{_~ojzy8(XkcGKdf-EN611j#x59RAB?dR#-tZXU^h(QzPvzZpdH59 zVO$D)wSWabyAZ{~XR{z)h;n3=&prgU&(Ikns55kq^gBZ@!uVKzkMuudvJd@%vmEq? zXc4+VbWb+ee&sth+aOtJ=>iF2Q;e}K#@HBR4O?RZxiwaaaTdB^y!h0YBsT5@xen-c zK}fd)>OA6n!Y^3!6y7)^T=A`bTNdaT`0Rg5|B#LpwIC0Ot~I)b{A9E{E?yGjydaK7 zQgMYhemWGC{u;)H-&OkIcRZv5e(s0AIU=_d&?W)Z#a|%2Xb_rBZb99zu)Y<@73xbD z#WiT|%@3N}mzqOa7mzvs17EYo7{AyE_zieMGf-+rau|qSWqd9Snjg_5G>E`$W&9x3 z_)_Nr=P>+AR6@8U;H7)|FXPWH;BjsE@`)sGVl^^F7PSgHkGKQI?*X5c{swCCJBK_7 z1RU)&{g83f%*sZJuK1Mld`kH~rRqMVYW|htzjU$mar*kvFDZ=wP(9+H;S9phNg*nv V8LFn?iod|Hbj!jqY|=vJ`)^$gkQV>| literal 0 HcmV?d00001 diff --git a/tools/__pycache__/health_check.cpython-312.pyc b/tools/__pycache__/health_check.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17846c969b726ab848cc15053ee5c5dbd4da56cb GIT binary patch literal 15284 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 Any: + """Make an API request with retry logic.""" + url = f"{self.base_url}/api/v1{path}" + body = json.dumps(data).encode("utf-8") if data else None + + last_error = None + for attempt in range(self.max_retries): + try: + req = urllib.request.Request(url, data=body, headers=self._headers(), method=method) + resp = urllib.request.urlopen(req, timeout=30) + raw = resp.read() + if not raw: + return None + return json.loads(raw) + except urllib.error.HTTPError as e: + last_error = GiteaAPIError(e.code, e.reason, url) + if e.code in (401, 403, 404, 422): + raise last_error # Don't retry auth/not-found/validation errors + if attempt < self.max_retries - 1: + time.sleep(self.retry_delay * (2 ** attempt)) + except urllib.error.URLError as e: + last_error = GiteaAPIError(0, str(e.reason), url) + if attempt < self.max_retries - 1: + time.sleep(self.retry_delay * (2 ** attempt)) + + raise last_error + + # === Auth === + + def whoami(self) -> dict: + """Validate token and return authenticated user info.""" + return self._request("GET", "/user") + + def validate_token(self) -> tuple[bool, str]: + """Check if token is valid. Returns (valid, username_or_error).""" + try: + user = self.whoami() + return True, user.get("login", "unknown") + except GiteaAPIError as e: + return False, str(e) + + # === Issues === + + def list_issues(self, owner: str, repo: str, state: str = "open", limit: int = 50, page: int = 1) -> list: + """List issues in a repo.""" + return self._request("GET", f"/repos/{owner}/{repo}/issues?state={state}&limit={limit}&page={page}&type=issues") + + def create_issue(self, owner: str, repo: str, title: str, body: str = "", + labels: list[int] = None, milestone: int = None, + assignees: list[str] = None) -> dict: + """Create an issue.""" + data = {"title": title, "body": body} + if labels: + data["labels"] = labels + if milestone: + data["milestone"] = milestone + if assignees: + data["assignees"] = assignees + return self._request("POST", f"/repos/{owner}/{repo}/issues", data) + + def update_issue(self, owner: str, repo: str, number: int, **kwargs) -> dict: + """Update an issue. Pass title=, body=, state=, etc.""" + return self._request("PATCH", f"/repos/{owner}/{repo}/issues/{number}", kwargs) + + def close_issue(self, owner: str, repo: str, number: int) -> dict: + """Close an issue.""" + return self.update_issue(owner, repo, number, state="closed") + + def add_comment(self, owner: str, repo: str, number: int, body: str) -> dict: + """Add a comment to an issue.""" + return self._request("POST", f"/repos/{owner}/{repo}/issues/{number}/comments", {"body": body}) + + # === Labels === + + def list_labels(self, owner: str, repo: str) -> list: + """List labels in a repo.""" + return self._request("GET", f"/repos/{owner}/{repo}/labels") + + def create_label(self, owner: str, repo: str, name: str, color: str, description: str = "") -> dict: + """Create a label. color = hex without #, e.g. 'e11d48'.""" + return self._request("POST", f"/repos/{owner}/{repo}/labels", { + "name": name, "color": f"#{color}", "description": description + }) + + def ensure_label(self, owner: str, repo: str, name: str, color: str, description: str = "") -> dict: + """Get or create a label by name.""" + labels = self.list_labels(owner, repo) + for l in labels: + if l["name"].lower() == name.lower(): + return l + return self.create_label(owner, repo, name, color, description) + + # === Repos === + + def list_repos(self, limit: int = 50) -> list: + """List repos for authenticated user.""" + return self._request("GET", f"/user/repos?limit={limit}") + + def get_repo(self, owner: str, repo: str) -> dict: + """Get repo info.""" + return self._request("GET", f"/repos/{owner}/{repo}") + + # === Milestones === + + def list_milestones(self, owner: str, repo: str, state: str = "open") -> list: + """List milestones.""" + return self._request("GET", f"/repos/{owner}/{repo}/milestones?state={state}") + + def create_milestone(self, owner: str, repo: str, title: str, description: str = "") -> dict: + """Create a milestone.""" + return self._request("POST", f"/repos/{owner}/{repo}/milestones", { + "title": title, "description": description + }) + + def ensure_milestone(self, owner: str, repo: str, title: str, description: str = "") -> dict: + """Get or create a milestone by title.""" + milestones = self.list_milestones(owner, repo) + for m in milestones: + if m["title"].lower() == title.lower(): + return m + return self.create_milestone(owner, repo, title, description) + + # === Org === + + def list_org_repos(self, org: str, limit: int = 50) -> list: + """List repos in an org.""" + return self._request("GET", f"/orgs/{org}/repos?limit={limit}") + + +# Convenience: module-level singleton +_default_client = None + +def get_client(**kwargs) -> GiteaClient: + """Get or create a module-level default client.""" + global _default_client + if _default_client is None: + _default_client = GiteaClient(**kwargs) + return _default_client diff --git a/tools/health_check.py b/tools/health_check.py new file mode 100644 index 0000000..3f4b151 --- /dev/null +++ b/tools/health_check.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Ezra self-check / health monitoring script. +Checks all wizard infrastructure and reports status. + +Epic: EZRA-SELF-001 / Phase 4 - Self-Monitoring +Author: Ezra (self-improvement) +""" + +import json +import os +import subprocess +import socket +import time +from datetime import datetime +from pathlib import Path + + +class HealthCheck: + """Run health checks on Ezra's infrastructure.""" + + def __init__(self): + self.results = [] + self.start_time = time.time() + + def check(self, name: str, fn, critical: bool = False) -> dict: + """Run a single health check.""" + try: + ok, detail = fn() + result = { + "name": name, + "status": "PASS" if ok else "FAIL", + "detail": detail, + "critical": critical, + } + except Exception as e: + result = { + "name": name, + "status": "ERROR", + "detail": str(e), + "critical": critical, + } + self.results.append(result) + return result + + # === Individual checks === + + @staticmethod + def check_disk_space() -> tuple[bool, str]: + """Check disk space (fail if < 2GB free).""" + st = os.statvfs("/") + free_gb = (st.f_bavail * st.f_frsize) / (1024 ** 3) + total_gb = (st.f_blocks * st.f_frsize) / (1024 ** 3) + pct_used = ((total_gb - free_gb) / total_gb) * 100 + ok = free_gb > 2.0 + return ok, f"{free_gb:.1f}GB free / {total_gb:.1f}GB total ({pct_used:.0f}% used)" + + @staticmethod + def check_hermes_gateway() -> tuple[bool, str]: + """Check if Hermes gateway is running for Ezra.""" + pid_file = Path("/root/wizards/ezra/home/gateway.pid") + if not pid_file.exists(): + return False, "No gateway.pid found" + try: + pid = int(pid_file.read_text().strip()) + os.kill(pid, 0) # Check if process exists + return True, f"Gateway running (PID {pid})" + except (ProcessLookupError, ValueError): + return False, f"Gateway PID file exists but process not running" + + @staticmethod + def check_gitea_api() -> tuple[bool, str]: + """Check Gitea API is reachable.""" + import urllib.request + try: + req = urllib.request.Request( + "http://143.198.27.163:3000/api/v1/version", + headers={"Accept": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=5) + data = json.loads(resp.read()) + return True, f"Gitea {data.get('version', 'unknown')}" + except Exception as e: + return False, f"Gitea unreachable: {e}" + + @staticmethod + def check_gitea_token() -> tuple[bool, str]: + """Check Gitea token validity.""" + token = os.getenv("GITEA_TOKEN", "") + if not token: + # Try loading from env file + env_file = Path("/root/wizards/ezra/home/.env") + if env_file.exists(): + for line in env_file.read_text().splitlines(): + if line.startswith("GITEA_TOKEN="): + token = line.split("=", 1)[1].strip().strip('"').strip("'") + break + if not token: + return False, "No GITEA_TOKEN found" + try: + import urllib.request + req = urllib.request.Request( + "http://143.198.27.163:3000/api/v1/user", + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=5) + data = json.loads(resp.read()) + return True, f"Authenticated as {data.get('login', 'unknown')}" + except Exception as e: + return False, f"Token invalid: {e}" + + @staticmethod + def check_llama_server(port: int = 11435) -> tuple[bool, str]: + """Check if llama-server is running.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(3) + s.connect(("127.0.0.1", port)) + s.close() + return True, f"llama-server listening on :{port}" + except Exception: + return False, f"llama-server not responding on :{port}" + + @staticmethod + def check_memory_file() -> tuple[bool, str]: + """Check Ezra's memory file exists and has content.""" + mem = Path("/root/wizards/ezra/home/memories/MEMORY.md") + if not mem.exists(): + return False, "MEMORY.md not found" + size = mem.stat().st_size + lines = len(mem.read_text().splitlines()) + return True, f"MEMORY.md: {lines} lines, {size} bytes" + + @staticmethod + def check_skills_count() -> tuple[bool, str]: + """Count installed skills.""" + skills_dir = Path("/root/wizards/ezra/home/skills") + if not skills_dir.exists(): + return False, "Skills directory not found" + skills = [] + for p in skills_dir.rglob("SKILL.md"): + skills.append(p.parent.name) + count = len(skills) + ok = count > 0 + return ok, f"{count} skills installed" + + @staticmethod + def check_cron_jobs() -> tuple[bool, str]: + """Check cron jobs status.""" + cron_file = Path("/root/wizards/ezra/home/cron/jobs.json") + if not cron_file.exists(): + return False, "No cron jobs.json found" + try: + jobs = json.loads(cron_file.read_text()) + active = sum(1 for j in jobs if j.get("status") == "active") + total = len(jobs) + return True, f"{active} active / {total} total cron jobs" + except Exception as e: + return False, f"Error reading jobs.json: {e}" + + @staticmethod + def check_sessions_db() -> tuple[bool, str]: + """Check sessions database.""" + db_path = Path("/root/wizards/ezra/home/state.db") + if not db_path.exists(): + return False, "state.db not found" + size_mb = db_path.stat().st_size / (1024 * 1024) + return True, f"state.db: {size_mb:.1f}MB" + + @staticmethod + def check_backups() -> tuple[bool, str]: + """Check backup freshness.""" + backup_dir = Path("/root/wizards/ezra/backups") + if not backup_dir.exists(): + return False, "No backups directory" + backups = sorted(backup_dir.glob("*.tar.gz"), key=lambda p: p.stat().st_mtime, reverse=True) + if not backups: + backups = sorted(backup_dir.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True) + if not backups: + return False, "No backups found" + latest = backups[0] + age_hours = (time.time() - latest.stat().st_mtime) / 3600 + return age_hours < 48, f"Latest: {latest.name} ({age_hours:.0f}h ago)" + + # === Runner === + + def run_all(self) -> dict: + """Run all health checks.""" + self.check("Disk Space", self.check_disk_space, critical=True) + self.check("Hermes Gateway", self.check_hermes_gateway, critical=True) + self.check("Gitea API", self.check_gitea_api, critical=True) + self.check("Gitea Token", self.check_gitea_token, critical=True) + self.check("llama-server", self.check_llama_server, critical=False) + self.check("Memory File", self.check_memory_file, critical=False) + self.check("Skills", self.check_skills_count, critical=False) + self.check("Cron Jobs", self.check_cron_jobs, critical=False) + self.check("Sessions DB", self.check_sessions_db, critical=False) + self.check("Backups", self.check_backups, critical=False) + + elapsed = time.time() - self.start_time + passed = sum(1 for r in self.results if r["status"] == "PASS") + failed = sum(1 for r in self.results if r["status"] in ("FAIL", "ERROR")) + crit_fail = sum(1 for r in self.results if r["status"] in ("FAIL", "ERROR") and r["critical"]) + + return { + "timestamp": datetime.now().isoformat(), + "elapsed_seconds": round(elapsed, 2), + "total": len(self.results), + "passed": passed, + "failed": failed, + "critical_failures": crit_fail, + "healthy": crit_fail == 0, + "checks": self.results, + } + + def format_report(self, result: dict = None) -> str: + """Format health check results as markdown.""" + if result is None: + result = self.run_all() + + lines = [ + f"# Ezra Health Check - {result['timestamp'][:19]}", + "", + f"**Status: {'HEALTHY' if result['healthy'] else 'UNHEALTHY'}** | " + f"{result['passed']}/{result['total']} passed | " + f"{result['elapsed_seconds']}s", + "", + "| Check | Status | Detail |", + "|-------|--------|--------|", + ] + for c in result["checks"]: + icon = {"PASS": "✅", "FAIL": "❌", "ERROR": "⚠️"}.get(c["status"], "?") + crit = " 🔴" if c["critical"] and c["status"] != "PASS" else "" + lines.append(f"| {c['name']} | {icon} {c['status']}{crit} | {c['detail']} |") + + if result["critical_failures"] > 0: + lines.extend(["", "## Critical Failures"]) + for c in result["checks"]: + if c["critical"] and c["status"] != "PASS": + lines.append(f"- **{c['name']}**: {c['detail']}") + + return "\n".join(lines) + + +if __name__ == "__main__": + hc = HealthCheck() + report = hc.run_all() + print(hc.format_report(report)) diff --git a/tools/rca_generator.py b/tools/rca_generator.py new file mode 100644 index 0000000..fe912b5 --- /dev/null +++ b/tools/rca_generator.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +RCA (Root Cause Analysis) template generator for Ezra. +Creates structured RCA documents from incident parameters. + +Epic: EZRA-SELF-001 / Phase 4 - Self-Monitoring & RCA +Author: Ezra (self-improvement) +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Optional + + +class RCAGenerator: + """Generate structured RCA documents.""" + + SEVERITY_LEVELS = { + "P0": "Critical - Service down, data loss risk", + "P1": "High - Major feature broken, workaround exists", + "P2": "Medium - Feature degraded, minor impact", + "P3": "Low - Cosmetic, minor inconvenience", + } + + TEMPLATE = """# RCA-{number}: {title} + +## Summary +| Field | Value | +|-------|-------| +| **Date** | {date} | +| **Severity** | {severity} - {severity_desc} | +| **Duration** | {duration} | +| **Affected** | {affected} | +| **Status** | {status} | + +## Timeline +{timeline} + +## Root Cause +{root_cause} + +## Impact +{impact} + +## Resolution +{resolution} + +## 5-Whys Analysis +{five_whys} + +## Action Items +{action_items} + +## Lessons Learned +{lessons} + +## Prevention +{prevention} + +--- +Generated by: Ezra RCA Generator +Date: {generated} +""" + + def __init__(self, rca_dir: str = None): + self.rca_dir = Path(rca_dir or "/root/wizards/ezra/reports/rca") + self.rca_dir.mkdir(parents=True, exist_ok=True) + + def _next_number(self) -> int: + """Get next RCA number.""" + existing = list(self.rca_dir.glob("RCA-*.md")) + if not existing: + return 1 + numbers = [] + for f in existing: + try: + num = int(f.stem.split("-")[1]) + numbers.append(num) + except (IndexError, ValueError): + pass + return max(numbers, default=0) + 1 + + def generate( + self, + title: str, + severity: str = "P2", + duration: str = "Unknown", + affected: str = "Ezra wizard house", + root_cause: str = "Under investigation", + impact: str = "TBD", + resolution: str = "TBD", + timeline: list[dict] = None, + five_whys: list[str] = None, + action_items: list[dict] = None, + lessons: list[str] = None, + prevention: list[str] = None, + status: str = "Open", + number: int = None, + ) -> tuple[str, Path]: + """Generate an RCA document. Returns (content, file_path).""" + + if number is None: + number = self._next_number() + + # Format timeline + if timeline: + timeline_str = "\n".join( + f"- **{t.get('time', '??:??')}** - {t.get('event', 'Unknown event')}" + for t in timeline + ) + else: + timeline_str = "- TBD - Add timeline entries" + + # Format 5-whys + if five_whys: + five_whys_str = "\n".join( + f"{i+1}. **Why?** {why}" for i, why in enumerate(five_whys) + ) + else: + five_whys_str = "1. **Why?** TBD\n2. **Why?** TBD\n3. **Why?** TBD" + + # Format action items + if action_items: + action_items_str = "\n".join( + f"- [ ] **[{a.get('priority', 'P2')}]** {a.get('action', 'TBD')} " + f"(Owner: {a.get('owner', 'Ezra')})" + for a in action_items + ) + else: + action_items_str = "- [ ] **[P2]** Add action items (Owner: Ezra)" + + # Format lessons + lessons_str = "\n".join(f"- {l}" for l in (lessons or ["TBD"])) + prevention_str = "\n".join(f"- {p}" for p in (prevention or ["TBD"])) + + content = self.TEMPLATE.format( + number=number, + title=title, + date=datetime.now().strftime("%Y-%m-%d"), + severity=severity, + severity_desc=self.SEVERITY_LEVELS.get(severity, "Unknown"), + duration=duration, + affected=affected, + status=status, + root_cause=root_cause, + impact=impact, + resolution=resolution, + timeline=timeline_str, + five_whys=five_whys_str, + action_items=action_items_str, + lessons=lessons_str, + prevention=prevention_str, + generated=datetime.now().isoformat(), + ) + + import re as _re + safe_title = _re.sub(r'[^a-z0-9-]', '', title.lower().replace(' ', '-'))[:40] + file_path = self.rca_dir / f"RCA-{number}-{safe_title}.md" + file_path.write_text(content) + + return content, file_path + + def list_rcas(self) -> list[dict]: + """List existing RCAs.""" + rcas = [] + for f in sorted(self.rca_dir.glob("RCA-*.md")): + first_line = f.read_text().splitlines()[0] if f.stat().st_size > 0 else "" + rcas.append({ + "file": f.name, + "title": first_line.replace("# ", ""), + "size": f.stat().st_size, + "modified": datetime.fromtimestamp(f.stat().st_mtime).isoformat(), + }) + return rcas + + +if __name__ == "__main__": + gen = RCAGenerator() + content, path = gen.generate( + title="Example RCA", + severity="P2", + duration="30 minutes", + root_cause="Example root cause for testing", + timeline=[ + {"time": "10:00", "event": "Issue detected"}, + {"time": "10:15", "event": "Investigation started"}, + {"time": "10:30", "event": "Root cause identified and fixed"}, + ], + five_whys=[ + "The API returned 401", + "Token was expired", + "No token refresh automation existed", + ], + action_items=[ + {"priority": "P1", "action": "Implement token auto-refresh", "owner": "Ezra"}, + ], + status="Resolved", + ) + print(f"Generated: {path}") + print(content) diff --git a/tools/session_backup.py b/tools/session_backup.py new file mode 100644 index 0000000..691c509 --- /dev/null +++ b/tools/session_backup.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +""" +Session and state backup automation for Ezra. +Backs up critical files: sessions, memory, config, state.db. + +Epic: EZRA-SELF-001 / Phase 4 - Session Management +Author: Ezra (self-improvement) +""" + +import json +import os +import shutil +import tarfile +import time +from datetime import datetime +from pathlib import Path + + +class SessionBackup: + """Automated backup of Ezra's state and sessions.""" + + def __init__( + self, + home_dir: str = None, + backup_dir: str = None, + max_backups: int = 10, + ): + self.home_dir = Path(home_dir or "/root/wizards/ezra/home") + self.backup_dir = Path(backup_dir or "/root/wizards/ezra/backups") + self.max_backups = max_backups + self.backup_dir.mkdir(parents=True, exist_ok=True) + + # Files/patterns to back up + CRITICAL_FILES = [ + "config.yaml", + "memories/MEMORY.md", + "memories/USER.md", + "state.db", + "channel_directory.json", + "gateway_state.json", + "cron/jobs.json", + ] + + CRITICAL_DIRS = [ + "sessions", + ] + + def create_backup(self, label: str = None) -> dict: + """Create a compressed backup of critical state.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + label = label or "auto" + filename = f"ezra-backup-{timestamp}-{label}.tar.gz" + filepath = self.backup_dir / filename + + files_included = [] + files_missing = [] + total_size = 0 + + with tarfile.open(filepath, "w:gz") as tar: + # Individual critical files + for rel_path in self.CRITICAL_FILES: + full_path = self.home_dir / rel_path + if full_path.exists(): + tar.add(full_path, arcname=rel_path) + size = full_path.stat().st_size + files_included.append({"path": rel_path, "size": size}) + total_size += size + else: + files_missing.append(rel_path) + + # Session files (only metadata, not full JSONL) + sessions_dir = self.home_dir / "sessions" + if sessions_dir.exists(): + # Include session index + sessions_json = sessions_dir / "sessions.json" + if sessions_json.exists(): + tar.add(sessions_json, arcname="sessions/sessions.json") + files_included.append({"path": "sessions/sessions.json", "size": sessions_json.stat().st_size}) + + # Include session metadata (small files) + for f in sessions_dir.glob("session_*.json"): + if f.stat().st_size < 100_000: # Skip huge session files + tar.add(f, arcname=f"sessions/{f.name}") + files_included.append({"path": f"sessions/{f.name}", "size": f.stat().st_size}) + total_size += f.stat().st_size + + backup_size = filepath.stat().st_size + + result = { + "filename": filename, + "path": str(filepath), + "backup_size": backup_size, + "backup_size_human": self._human_size(backup_size), + "source_size": total_size, + "files_included": len(files_included), + "files_missing": files_missing, + "timestamp": timestamp, + } + + # Rotate old backups + self._rotate_backups() + + return result + + def _rotate_backups(self): + """Remove old backups beyond max_backups.""" + backups = sorted( + self.backup_dir.glob("ezra-backup-*.tar.gz"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + for old in backups[self.max_backups:]: + old.unlink() + + def list_backups(self) -> list[dict]: + """List existing backups.""" + backups = [] + for f in sorted(self.backup_dir.glob("ezra-backup-*.tar.gz"), reverse=True): + stat = f.stat() + backups.append({ + "filename": f.name, + "size": self._human_size(stat.st_size), + "created": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "age_hours": round((time.time() - stat.st_mtime) / 3600, 1), + }) + return backups + + def restore_backup(self, filename: str, dry_run: bool = True) -> dict: + """Restore from a backup. Use dry_run=True to preview.""" + filepath = self.backup_dir / filename + if not filepath.exists(): + return {"error": f"Backup not found: {filename}"} + + with tarfile.open(filepath, "r:gz") as tar: + members = tar.getmembers() + + if dry_run: + return { + "mode": "dry_run", + "filename": filename, + "files": [m.name for m in members], + "total_files": len(members), + } + + # Actual restore + tar.extractall(path=str(self.home_dir)) + return { + "mode": "restored", + "filename": filename, + "files_restored": len(members), + } + + def check_freshness(self) -> dict: + """Check if backups are fresh enough.""" + backups = self.list_backups() + if not backups: + return {"fresh": False, "reason": "No backups exist", "latest": None} + + latest = backups[0] + age = latest["age_hours"] + return { + "fresh": age < 24, + "latest": latest["filename"], + "age_hours": age, + "total_backups": len(backups), + } + + @staticmethod + def _human_size(size: int) -> str: + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024: + return f"{size:.1f}{unit}" + size /= 1024 + return f"{size:.1f}TB" + + +if __name__ == "__main__": + backup = SessionBackup() + + # Create a backup + result = backup.create_backup("manual") + print(f"Created: {result['filename']} ({result['backup_size_human']})") + print(f"Files: {result['files_included']} included, {len(result['files_missing'])} missing") + if result["files_missing"]: + print(f"Missing: {', '.join(result['files_missing'])}") + + # List backups + print("\nExisting backups:") + for b in backup.list_backups(): + print(f" {b['filename']} - {b['size']} ({b['age_hours']}h ago)") diff --git a/tools/skill_validator.py b/tools/skill_validator.py new file mode 100644 index 0000000..0dcfc3f --- /dev/null +++ b/tools/skill_validator.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Skill validation framework for Ezra. +Validates SKILL.md files for completeness, structure, and quality. + +Epic: EZRA-SELF-001 / Phase 3 - Skill System Enhancement +Author: Ezra (self-improvement) +""" + +import re +import yaml +from pathlib import Path +from typing import Optional + + +class SkillValidationError: + """A single validation finding.""" + def __init__(self, level: str, message: str, field: str = ""): + self.level = level # ERROR, WARNING, INFO + self.message = message + self.field = field + + def __repr__(self): + prefix = {"ERROR": "❌", "WARNING": "⚠️", "INFO": "ℹ️"}.get(self.level, "?") + field_str = f" [{self.field}]" if self.field else "" + return f"{prefix} {self.level}{field_str}: {self.message}" + + +class SkillValidator: + """Validate SKILL.md files for quality and completeness.""" + + REQUIRED_FRONTMATTER = ["name", "description", "version"] + RECOMMENDED_FRONTMATTER = ["author", "tags"] + REQUIRED_SECTIONS = ["trigger", "steps"] + RECOMMENDED_SECTIONS = ["pitfalls", "verification"] + + def __init__(self): + self.errors = [] + + def validate_file(self, path: Path) -> list[SkillValidationError]: + """Validate a single SKILL.md file.""" + self.errors = [] + path = Path(path) + + if not path.exists(): + self.errors.append(SkillValidationError("ERROR", f"File not found: {path}", "file")) + return self.errors + + content = path.read_text() + if not content.strip(): + self.errors.append(SkillValidationError("ERROR", "File is empty", "file")) + return self.errors + + # Check YAML frontmatter + frontmatter = self._parse_frontmatter(content) + self._validate_frontmatter(frontmatter) + + # Check markdown body + body = self._extract_body(content) + self._validate_body(body) + + # Check directory structure + self._validate_directory(path.parent) + + return self.errors + + def _parse_frontmatter(self, content: str) -> dict: + """Extract YAML frontmatter.""" + match = re.match(r'^---\s*\n(.*?)\n---', content, re.DOTALL) + if not match: + self.errors.append(SkillValidationError("ERROR", "No YAML frontmatter found (must start with ---)", "frontmatter")) + return {} + try: + data = yaml.safe_load(match.group(1)) + return data if isinstance(data, dict) else {} + except yaml.YAMLError as e: + self.errors.append(SkillValidationError("ERROR", f"Invalid YAML: {e}", "frontmatter")) + return {} + + def _extract_body(self, content: str) -> str: + """Extract markdown body after frontmatter.""" + match = re.match(r'^---\s*\n.*?\n---\s*\n(.*)', content, re.DOTALL) + return match.group(1) if match else content + + def _validate_frontmatter(self, fm: dict): + """Validate frontmatter fields.""" + for field in self.REQUIRED_FRONTMATTER: + if field not in fm: + self.errors.append(SkillValidationError("ERROR", f"Missing required field: {field}", "frontmatter")) + elif not fm[field]: + self.errors.append(SkillValidationError("ERROR", f"Empty required field: {field}", "frontmatter")) + + for field in self.RECOMMENDED_FRONTMATTER: + if field not in fm: + self.errors.append(SkillValidationError("WARNING", f"Missing recommended field: {field}", "frontmatter")) + + # Name validation + if "name" in fm: + name = str(fm["name"]) + if not re.match(r'^[a-z0-9][a-z0-9_-]*$', name): + self.errors.append(SkillValidationError("ERROR", f"Invalid name '{name}': use lowercase, hyphens, underscores", "frontmatter")) + if len(name) > 64: + self.errors.append(SkillValidationError("ERROR", f"Name too long ({len(name)} chars, max 64)", "frontmatter")) + + # Description length + if "description" in fm and fm["description"]: + desc = str(fm["description"]) + if len(desc) < 10: + self.errors.append(SkillValidationError("WARNING", "Description too short (< 10 chars)", "frontmatter")) + if len(desc) > 200: + self.errors.append(SkillValidationError("WARNING", "Description very long (> 200 chars)", "frontmatter")) + + # Version format + if "version" in fm and fm["version"]: + ver = str(fm["version"]) + if not re.match(r'^\d+\.\d+(\.\d+)?$', ver): + self.errors.append(SkillValidationError("WARNING", f"Non-semver version: {ver}", "frontmatter")) + + def _validate_body(self, body: str): + """Validate markdown body structure.""" + headers = re.findall(r'^#+\s+(.+)$', body, re.MULTILINE) + headers_lower = [h.lower().strip() for h in headers] + + for section in self.REQUIRED_SECTIONS: + found = any(section.lower() in h for h in headers_lower) + if not found: + self.errors.append(SkillValidationError("ERROR", f"Missing required section: {section}", "body")) + + for section in self.RECOMMENDED_SECTIONS: + found = any(section.lower() in h for h in headers_lower) + if not found: + self.errors.append(SkillValidationError("WARNING", f"Missing recommended section: {section}", "body")) + + # Check for numbered steps + steps_match = re.search(r'(?:^|\n)(?:#+\s+.*?(?:step|procedure|instructions).*?\n)(.*?)(?=\n#+\s|\Z)', body, re.IGNORECASE | re.DOTALL) + if steps_match: + steps_content = steps_match.group(1) + numbered = re.findall(r'^\d+\.', steps_content, re.MULTILINE) + if len(numbered) < 2: + self.errors.append(SkillValidationError("WARNING", "Steps section has fewer than 2 numbered items", "body")) + + # Check for code blocks + code_blocks = re.findall(r'```', body) + if len(code_blocks) < 2: # Need at least one pair + self.errors.append(SkillValidationError("INFO", "No code blocks found — consider adding examples", "body")) + + # Content length check + word_count = len(body.split()) + if word_count < 50: + self.errors.append(SkillValidationError("WARNING", f"Very short body ({word_count} words)", "body")) + + def _validate_directory(self, skill_dir: Path): + """Validate skill directory structure.""" + valid_subdirs = {"references", "templates", "scripts", "assets"} + for child in skill_dir.iterdir(): + if child.is_dir() and child.name not in valid_subdirs: + self.errors.append(SkillValidationError("WARNING", f"Non-standard subdirectory: {child.name}/", "directory")) + + def validate_all(self, skills_root: Path = None) -> dict: + """Validate all skills under a root directory.""" + skills_root = Path(skills_root or "/root/wizards/ezra/home/skills") + results = {} + for skill_md in sorted(skills_root.rglob("SKILL.md")): + skill_name = skill_md.parent.name + errors = self.validate_file(skill_md) + results[skill_name] = { + "path": str(skill_md), + "errors": len([e for e in errors if e.level == "ERROR"]), + "warnings": len([e for e in errors if e.level == "WARNING"]), + "info": len([e for e in errors if e.level == "INFO"]), + "findings": [repr(e) for e in errors], + } + return results + + def format_report(self, results: dict) -> str: + """Format validation results as a report.""" + lines = [ + "# Skill Validation Report", + f"**Skills scanned:** {len(results)}", + "", + ] + + total_errors = sum(r["errors"] for r in results.values()) + total_warnings = sum(r["warnings"] for r in results.values()) + + lines.append(f"**Total:** {total_errors} errors, {total_warnings} warnings") + lines.append("") + + # Sort by error count descending + sorted_results = sorted(results.items(), key=lambda x: (x[1]["errors"], x[1]["warnings"]), reverse=True) + + for name, r in sorted_results: + icon = "✅" if r["errors"] == 0 else "❌" + lines.append(f"### {icon} {name}") + if r["findings"]: + for f in r["findings"]: + lines.append(f" {f}") + else: + lines.append(" No issues found") + lines.append("") + + return "\n".join(lines) + + +if __name__ == "__main__": + v = SkillValidator() + results = v.validate_all() + print(v.format_report(results))