skill-automation-package 0.2.0

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.
@@ -0,0 +1,508 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ try:
13
+ from datetime import UTC
14
+ except ImportError:
15
+ from datetime import timezone
16
+
17
+ UTC = timezone.utc
18
+
19
+
20
+ sys.dont_write_bytecode = True
21
+ REPO_ROOT = Path(__file__).resolve().parents[2]
22
+ SCRIPT_PATH = REPO_ROOT / ".claude" / "tools" / "skill_agent.py"
23
+
24
+ SPEC = importlib.util.spec_from_file_location("skill_agent", SCRIPT_PATH)
25
+ assert SPEC and SPEC.loader
26
+ skill_agent = importlib.util.module_from_spec(SPEC)
27
+ sys.modules[SPEC.name] = skill_agent
28
+ SPEC.loader.exec_module(skill_agent)
29
+
30
+
31
+ class SkillAgentTests(unittest.TestCase):
32
+ def setUp(self) -> None:
33
+ self.tempdir = tempfile.TemporaryDirectory()
34
+ self.repo_root = Path(self.tempdir.name)
35
+ self.skills_dir = self.repo_root / ".claude" / "skills"
36
+ self.skills_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ def tearDown(self) -> None:
39
+ self.tempdir.cleanup()
40
+
41
+ def test_discover_skills_reads_companion_metadata(self) -> None:
42
+ self.write_skill(
43
+ "pr-review",
44
+ description=(
45
+ "Review pull request feedback and summarize requested changes. "
46
+ "Use when handling review comments."
47
+ ),
48
+ metadata={
49
+ "category": "github",
50
+ "summary": "Triage pull request review feedback.",
51
+ "tags": ["pull-request", "reviews"],
52
+ "triggers": ["address PR comments"],
53
+ "steps": ["Read the latest review comments first."],
54
+ "related_skills": ["project-skill-router"],
55
+ },
56
+ )
57
+
58
+ records = skill_agent.discover_skills(self.skills_dir)
59
+
60
+ self.assertEqual(len(records), 1)
61
+ record = records[0]
62
+ self.assertEqual(record.name, "pr-review")
63
+ self.assertEqual(record.category, "github")
64
+ self.assertEqual(record.tags, ["pull-request", "reviews"])
65
+ self.assertEqual(record.related_skills, ["project-skill-router"])
66
+ self.assertEqual(record.validation, [])
67
+ self.assertEqual(record.examples, [])
68
+
69
+ def test_search_prefers_trigger_overlap(self) -> None:
70
+ self.write_skill(
71
+ "ocr-debug",
72
+ description="Debug OCR extraction issues. Use when OCR parsing fails.",
73
+ metadata={
74
+ "category": "ios",
75
+ "summary": "Investigate OCR pipeline regressions.",
76
+ "triggers": ["ocr parsing fails", "vision extraction error"],
77
+ },
78
+ )
79
+ self.write_skill(
80
+ "cloudkit-sync",
81
+ description="Inspect CloudKit sync behavior. Use when sync is inconsistent.",
82
+ metadata={
83
+ "category": "ios",
84
+ "summary": "Investigate CloudKit sync state.",
85
+ "triggers": ["cloudkit sync bug"],
86
+ },
87
+ )
88
+
89
+ records = skill_agent.discover_skills(self.skills_dir)
90
+ matches = skill_agent.search_records(records, "vision extraction error", limit=2)
91
+
92
+ self.assertGreaterEqual(len(matches), 1)
93
+ self.assertEqual(matches[0][2].name, "ocr-debug")
94
+
95
+ def test_cli_create_refresh_and_search(self) -> None:
96
+ subprocess.run(
97
+ [
98
+ sys.executable,
99
+ str(SCRIPT_PATH),
100
+ "create",
101
+ "Skill Router",
102
+ "--summary",
103
+ "Route agents to the right reusable workflow",
104
+ "--when",
105
+ "an agent needs to find or create a repeatable local skill",
106
+ "--category",
107
+ "workflow",
108
+ "--tag",
109
+ "skills",
110
+ "--trigger",
111
+ "find the right skill",
112
+ "--repo-root",
113
+ str(self.repo_root),
114
+ ],
115
+ check=True,
116
+ capture_output=True,
117
+ text=True,
118
+ )
119
+
120
+ registry_path = self.skills_dir / "registry.json"
121
+ self.assertTrue(registry_path.exists())
122
+ registry = json.loads(registry_path.read_text(encoding="utf-8"))
123
+ self.assertEqual(registry["skills"][0]["name"], "skill-router")
124
+
125
+ search = subprocess.run(
126
+ [
127
+ sys.executable,
128
+ str(SCRIPT_PATH),
129
+ "search",
130
+ "find the right skill",
131
+ "--repo-root",
132
+ str(self.repo_root),
133
+ "--json",
134
+ ],
135
+ check=True,
136
+ capture_output=True,
137
+ text=True,
138
+ )
139
+ payload = json.loads(search.stdout)
140
+ self.assertEqual(payload[0]["name"], "skill-router")
141
+
142
+ def test_bootstrap_dry_run_infers_rich_ios_skill(self) -> None:
143
+ preview = subprocess.run(
144
+ [
145
+ sys.executable,
146
+ str(SCRIPT_PATH),
147
+ "bootstrap",
148
+ "debug CloudKit sync regressions in the iOS app",
149
+ "--repo-root",
150
+ str(self.repo_root),
151
+ "--dry-run",
152
+ "--json",
153
+ ],
154
+ check=True,
155
+ capture_output=True,
156
+ text=True,
157
+ )
158
+
159
+ payload = json.loads(preview.stdout)
160
+ self.assertEqual(payload["category"], "ios")
161
+ self.assertIn("cloudkit", payload["tags"])
162
+ self.assertIn("## Validation", payload["markdown"])
163
+ self.assertIn("## Example Requests", payload["markdown"])
164
+ self.assertIn('auto "<sub-task>" --json', payload["markdown"])
165
+ self.assertFalse((self.skills_dir / payload["name"]).exists())
166
+
167
+ def test_bootstrap_blueprint_adds_nested_skill_routing_step(self) -> None:
168
+ blueprint = skill_agent.build_bootstrap_blueprint(
169
+ task="debug CloudKit sync regressions in the iOS app",
170
+ raw_name=None,
171
+ category="auto",
172
+ extra_tags=[],
173
+ existing_records=[],
174
+ )
175
+
176
+ self.assertTrue(
177
+ any('auto "<sub-task>" --json' in step for step in blueprint.steps)
178
+ )
179
+
180
+ def test_auto_reuses_existing_skill_for_future_sessions(self) -> None:
181
+ self.write_skill(
182
+ "ocr-debug",
183
+ description="Debug OCR extraction issues. Use when OCR parsing fails.",
184
+ metadata={
185
+ "category": "ios",
186
+ "summary": "Investigate OCR pipeline regressions.",
187
+ "tags": ["ocr", "vision"],
188
+ "triggers": ["ocr parsing fails", "vision extraction error"],
189
+ },
190
+ )
191
+
192
+ result = subprocess.run(
193
+ [
194
+ sys.executable,
195
+ str(SCRIPT_PATH),
196
+ "auto",
197
+ "vision extraction error on the OCR screen",
198
+ "--repo-root",
199
+ str(self.repo_root),
200
+ "--json",
201
+ ],
202
+ check=True,
203
+ capture_output=True,
204
+ text=True,
205
+ )
206
+
207
+ payload = json.loads(result.stdout)
208
+ self.assertEqual(payload["action"], "reuse")
209
+ self.assertEqual(payload["match"]["name"], "ocr-debug")
210
+ usage = json.loads((self.skills_dir / "usage.json").read_text(encoding="utf-8"))
211
+ self.assertEqual(usage["skills"]["ocr-debug"]["reuse_count"], 1)
212
+ self.assertEqual(usage["skills"]["ocr-debug"]["auto_hits"], 1)
213
+
214
+ def test_auto_reuse_can_patch_sparse_skill_metadata(self) -> None:
215
+ self.write_skill(
216
+ "ocr-debug",
217
+ description="Debug OCR extraction issues. Use when OCR parsing fails.",
218
+ metadata={
219
+ "category": "ios",
220
+ "summary": "Investigate OCR pipeline regressions.",
221
+ "tags": ["ocr"],
222
+ "triggers": ["ocr parsing fails"],
223
+ },
224
+ )
225
+
226
+ result = subprocess.run(
227
+ [
228
+ sys.executable,
229
+ str(SCRIPT_PATH),
230
+ "auto",
231
+ "vision extraction error on the OCR screen",
232
+ "--repo-root",
233
+ str(self.repo_root),
234
+ "--json",
235
+ ],
236
+ check=True,
237
+ capture_output=True,
238
+ text=True,
239
+ )
240
+
241
+ payload = json.loads(result.stdout)
242
+ self.assertEqual(payload["action"], "reuse")
243
+ self.assertEqual(payload["skill_update"]["name"], "ocr-debug")
244
+ self.assertIn("examples", payload["skill_update"]["updated_fields"])
245
+
246
+ metadata = json.loads(
247
+ (self.skills_dir / "ocr-debug" / "skill.json").read_text(encoding="utf-8")
248
+ )
249
+ self.assertIn(
250
+ "Vision extraction error on the OCR screen.",
251
+ metadata["examples"],
252
+ )
253
+ self.assertGreaterEqual(len(metadata["triggers"]), 2)
254
+
255
+ usage = json.loads((self.skills_dir / "usage.json").read_text(encoding="utf-8"))
256
+ self.assertEqual(usage["skills"]["ocr-debug"]["update_count"], 1)
257
+
258
+ def test_auto_creates_new_skill_and_refreshes_registry(self) -> None:
259
+ result = subprocess.run(
260
+ [
261
+ sys.executable,
262
+ str(SCRIPT_PATH),
263
+ "auto",
264
+ "draft a reusable privacy policy update workflow",
265
+ "--repo-root",
266
+ str(self.repo_root),
267
+ "--json",
268
+ ],
269
+ check=True,
270
+ capture_output=True,
271
+ text=True,
272
+ )
273
+
274
+ payload = json.loads(result.stdout)
275
+ self.assertEqual(payload["action"], "created")
276
+ created_name = payload["created_skill"]["name"]
277
+ self.assertTrue((self.skills_dir / created_name / "SKILL.md").exists())
278
+
279
+ registry = json.loads((self.skills_dir / "registry.json").read_text(encoding="utf-8"))
280
+ registry_names = [item["name"] for item in registry["skills"]]
281
+ self.assertIn(created_name, registry_names)
282
+ usage = json.loads((self.skills_dir / "usage.json").read_text(encoding="utf-8"))
283
+ self.assertEqual(usage["skills"][created_name]["create_count"], 1)
284
+
285
+ def test_auto_does_not_reuse_router_for_domain_task(self) -> None:
286
+ self.write_skill(
287
+ "project-skill-router",
288
+ description=(
289
+ "Search, rank, scaffold, and refresh repo-local skills. "
290
+ "Use when an agent is managing skills."
291
+ ),
292
+ metadata={
293
+ "category": "workflow",
294
+ "summary": "Route agents to the right local skill.",
295
+ "tags": ["skills", "automation"],
296
+ "triggers": ["automatically resolve or create a local skill"],
297
+ },
298
+ )
299
+
300
+ result = subprocess.run(
301
+ [
302
+ sys.executable,
303
+ str(SCRIPT_PATH),
304
+ "auto",
305
+ "draft a reusable privacy policy update workflow",
306
+ "--repo-root",
307
+ str(self.repo_root),
308
+ "--json",
309
+ ],
310
+ check=True,
311
+ capture_output=True,
312
+ text=True,
313
+ )
314
+
315
+ payload = json.loads(result.stdout)
316
+ self.assertEqual(payload["action"], "created")
317
+ self.assertNotEqual(payload["created_skill"]["name"], "project-skill-router")
318
+
319
+ def test_review_reports_candidate_when_auto_update_is_skipped(self) -> None:
320
+ self.write_skill(
321
+ "ocr-debug",
322
+ description="Debug OCR extraction issues. Use when OCR parsing fails.",
323
+ metadata={
324
+ "category": "ios",
325
+ "summary": "Investigate OCR pipeline regressions.",
326
+ "tags": ["ocr"],
327
+ "triggers": ["ocr parsing fails"],
328
+ },
329
+ )
330
+
331
+ subprocess.run(
332
+ [
333
+ sys.executable,
334
+ str(SCRIPT_PATH),
335
+ "auto",
336
+ "vision extraction error on the OCR screen",
337
+ "--repo-root",
338
+ str(self.repo_root),
339
+ "--skip-update",
340
+ "--json",
341
+ ],
342
+ check=True,
343
+ capture_output=True,
344
+ text=True,
345
+ )
346
+
347
+ review = subprocess.run(
348
+ [
349
+ sys.executable,
350
+ str(SCRIPT_PATH),
351
+ "review",
352
+ "--repo-root",
353
+ str(self.repo_root),
354
+ "--json",
355
+ ],
356
+ check=True,
357
+ capture_output=True,
358
+ text=True,
359
+ )
360
+
361
+ payload = json.loads(review.stdout)
362
+ self.assertEqual(payload[0]["name"], "ocr-debug")
363
+ self.assertEqual(payload[0]["status"], "candidate")
364
+ self.assertIn("examples", payload[0]["updated_fields"])
365
+
366
+ def test_update_apply_refreshes_candidate_skill(self) -> None:
367
+ self.write_skill(
368
+ "ocr-debug",
369
+ description="Debug OCR extraction issues. Use when OCR parsing fails.",
370
+ metadata={
371
+ "category": "ios",
372
+ "summary": "Investigate OCR pipeline regressions.",
373
+ "tags": ["ocr"],
374
+ "triggers": ["ocr parsing fails"],
375
+ },
376
+ )
377
+
378
+ subprocess.run(
379
+ [
380
+ sys.executable,
381
+ str(SCRIPT_PATH),
382
+ "auto",
383
+ "vision extraction error on the OCR screen",
384
+ "--repo-root",
385
+ str(self.repo_root),
386
+ "--skip-update",
387
+ "--json",
388
+ ],
389
+ check=True,
390
+ capture_output=True,
391
+ text=True,
392
+ )
393
+
394
+ result = subprocess.run(
395
+ [
396
+ sys.executable,
397
+ str(SCRIPT_PATH),
398
+ "update",
399
+ "ocr-debug",
400
+ "--repo-root",
401
+ str(self.repo_root),
402
+ "--apply",
403
+ "--json",
404
+ ],
405
+ check=True,
406
+ capture_output=True,
407
+ text=True,
408
+ )
409
+
410
+ payload = json.loads(result.stdout)
411
+ self.assertEqual(payload[0]["name"], "ocr-debug")
412
+ self.assertIn("examples", payload[0]["updated_fields"])
413
+
414
+ metadata = json.loads(
415
+ (self.skills_dir / "ocr-debug" / "skill.json").read_text(encoding="utf-8")
416
+ )
417
+ self.assertIn(
418
+ "Vision extraction error on the OCR screen.",
419
+ metadata["examples"],
420
+ )
421
+
422
+ def test_usage_reports_candidate_for_old_unused_skill(self) -> None:
423
+ old_timestamp = (datetime.now(UTC) - timedelta(days=60)).replace(microsecond=0).isoformat()
424
+ self.write_skill(
425
+ "dusty-skill",
426
+ description="Handle a dusty workflow. Use when needed.",
427
+ metadata={
428
+ "category": "workflow",
429
+ "summary": "Handle a dusty workflow.",
430
+ "created_at": old_timestamp,
431
+ "updated_at": old_timestamp,
432
+ },
433
+ )
434
+
435
+ result = subprocess.run(
436
+ [
437
+ sys.executable,
438
+ str(SCRIPT_PATH),
439
+ "usage",
440
+ "--repo-root",
441
+ str(self.repo_root),
442
+ "--status",
443
+ "candidate",
444
+ "--json",
445
+ ],
446
+ check=True,
447
+ capture_output=True,
448
+ text=True,
449
+ )
450
+
451
+ payload = json.loads(result.stdout)
452
+ self.assertEqual(payload[0]["name"], "dusty-skill")
453
+ self.assertEqual(payload[0]["status"], "candidate")
454
+
455
+ def test_prune_apply_archives_candidate_skill(self) -> None:
456
+ old_timestamp = (datetime.now(UTC) - timedelta(days=60)).replace(microsecond=0).isoformat()
457
+ self.write_skill(
458
+ "dusty-skill",
459
+ description="Handle a dusty workflow. Use when needed.",
460
+ metadata={
461
+ "category": "workflow",
462
+ "summary": "Handle a dusty workflow.",
463
+ "created_at": old_timestamp,
464
+ "updated_at": old_timestamp,
465
+ },
466
+ )
467
+
468
+ result = subprocess.run(
469
+ [
470
+ sys.executable,
471
+ str(SCRIPT_PATH),
472
+ "prune",
473
+ "--repo-root",
474
+ str(self.repo_root),
475
+ "--apply",
476
+ "--json",
477
+ ],
478
+ check=True,
479
+ capture_output=True,
480
+ text=True,
481
+ )
482
+
483
+ payload = json.loads(result.stdout)
484
+ self.assertEqual(payload[0]["name"], "dusty-skill")
485
+ self.assertTrue((self.skills_dir / "_archived" / "dusty-skill" / "SKILL.md").exists())
486
+ self.assertFalse((self.skills_dir / "dusty-skill").exists())
487
+ registry = json.loads((self.skills_dir / "registry.json").read_text(encoding="utf-8"))
488
+ self.assertEqual(registry["skills"], [])
489
+
490
+ def write_skill(self, name: str, *, description: str, metadata: dict[str, object]) -> None:
491
+ skill_dir = self.skills_dir / name
492
+ skill_dir.mkdir(parents=True, exist_ok=True)
493
+ skill_md = (
494
+ "---\n"
495
+ f"name: {name}\n"
496
+ f"description: {description}\n"
497
+ "---\n\n"
498
+ f"# {name.replace('-', ' ').title()}\n"
499
+ )
500
+ (skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
501
+ (skill_dir / "skill.json").write_text(
502
+ json.dumps(metadata, ensure_ascii=False, indent=2) + "\n",
503
+ encoding="utf-8",
504
+ )
505
+
506
+
507
+ if __name__ == "__main__":
508
+ unittest.main()