pattyeng 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +98 -44
  2. package/bin/pattyeng.js +20 -14
  3. package/lib/skills.js +170 -0
  4. package/lib/upgrade.js +46 -0
  5. package/package.json +5 -3
  6. package/skills/.system/.codex-system-skills.marker +1 -0
  7. package/skills/.system/imagegen/LICENSE.txt +201 -0
  8. package/skills/.system/imagegen/SKILL.md +356 -0
  9. package/skills/.system/imagegen/agents/openai.yaml +6 -0
  10. package/skills/.system/imagegen/assets/imagegen-small.svg +5 -0
  11. package/skills/.system/imagegen/assets/imagegen.png +0 -0
  12. package/skills/.system/imagegen/references/cli.md +242 -0
  13. package/skills/.system/imagegen/references/codex-network.md +33 -0
  14. package/skills/.system/imagegen/references/image-api.md +90 -0
  15. package/skills/.system/imagegen/references/prompting.md +118 -0
  16. package/skills/.system/imagegen/references/sample-prompts.md +433 -0
  17. package/skills/.system/imagegen/scripts/image_gen.py +995 -0
  18. package/skills/.system/imagegen/scripts/remove_chroma_key.py +440 -0
  19. package/skills/.system/openai-docs/LICENSE.txt +201 -0
  20. package/skills/.system/openai-docs/SKILL.md +167 -0
  21. package/skills/.system/openai-docs/agents/openai.yaml +14 -0
  22. package/skills/.system/openai-docs/assets/openai-small.svg +3 -0
  23. package/skills/.system/openai-docs/assets/openai.png +0 -0
  24. package/skills/.system/openai-docs/references/latest-model.md +37 -0
  25. package/skills/.system/openai-docs/references/prompting-guide.md +244 -0
  26. package/skills/.system/openai-docs/references/upgrade-guide.md +181 -0
  27. package/skills/.system/openai-docs/scripts/fetch-codex-manual.mjs +598 -0
  28. package/skills/.system/openai-docs/scripts/resolve-latest-model-info.js +147 -0
  29. package/skills/.system/plugin-creator/SKILL.md +243 -0
  30. package/skills/.system/plugin-creator/agents/openai.yaml +6 -0
  31. package/skills/.system/plugin-creator/assets/plugin-creator-small.svg +3 -0
  32. package/skills/.system/plugin-creator/assets/plugin-creator.png +0 -0
  33. package/skills/.system/plugin-creator/references/installing-and-updating.md +143 -0
  34. package/skills/.system/plugin-creator/references/plugin-json-spec.md +194 -0
  35. package/skills/.system/plugin-creator/scripts/create_basic_plugin.py +324 -0
  36. package/skills/.system/plugin-creator/scripts/read_marketplace_name.py +48 -0
  37. package/skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py +78 -0
  38. package/skills/.system/plugin-creator/scripts/validate_plugin.py +586 -0
  39. package/skills/.system/skill-creator/SKILL.md +416 -0
  40. package/skills/.system/skill-creator/agents/openai.yaml +5 -0
  41. package/skills/.system/skill-creator/assets/skill-creator-small.svg +3 -0
  42. package/skills/.system/skill-creator/assets/skill-creator.png +0 -0
  43. package/skills/.system/skill-creator/license.txt +202 -0
  44. package/skills/.system/skill-creator/references/openai_yaml.md +49 -0
  45. package/skills/.system/skill-creator/scripts/generate_openai_yaml.py +226 -0
  46. package/skills/.system/skill-creator/scripts/init_skill.py +400 -0
  47. package/skills/.system/skill-creator/scripts/quick_validate.py +101 -0
  48. package/skills/.system/skill-installer/LICENSE.txt +202 -0
  49. package/skills/.system/skill-installer/SKILL.md +58 -0
  50. package/skills/.system/skill-installer/agents/openai.yaml +5 -0
  51. package/skills/.system/skill-installer/assets/skill-installer-small.svg +3 -0
  52. package/skills/.system/skill-installer/assets/skill-installer.png +0 -0
  53. package/skills/.system/skill-installer/scripts/github_utils.py +21 -0
  54. package/skills/.system/skill-installer/scripts/install-skill-from-github.py +308 -0
  55. package/skills/.system/skill-installer/scripts/list-skills.py +107 -0
  56. package/skills/cap/SKILL.md +77 -0
  57. package/skills/caveman/SKILL.md +49 -0
  58. package/skills/code-review/SKILL.md +46 -0
  59. package/skills/codebook/SKILL.md +184 -0
  60. package/skills/doc-review/SKILL.md +147 -0
  61. package/skills/feature-research/SKILL.md +34 -0
  62. package/skills/file-op/SKILL.md +116 -0
  63. package/skills/grill-me/SKILL.md +10 -0
  64. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  65. package/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  66. package/skills/grill-with-docs/SKILL.md +88 -0
  67. package/skills/handoff/SKILL.md +17 -0
  68. package/skills/improve-codebase/SKILL.md +79 -0
  69. package/skills/linear/SKILL.md +107 -0
  70. package/skills/next-step/SKILL.md +61 -0
  71. package/skills/no-reinvent/SKILL.md +131 -0
  72. package/skills/no-wall/SKILL.md +146 -0
  73. package/skills/online/SKILL.md +54 -0
  74. package/skills/plan-review/SKILL.md +55 -0
  75. package/skills/professional/SKILL.md +111 -0
  76. package/skills/promise-tracking/SKILL.md +131 -0
  77. package/skills/prove/SKILL.md +135 -0
  78. package/skills/read-before-write/SKILL.md +123 -0
  79. package/skills/review/SKILL.md +78 -0
  80. package/skills/review-mp/SKILL.md +78 -0
  81. package/skills/scope-boundary/SKILL.md +121 -0
  82. package/skills/semble-search/SKILL.md +40 -0
  83. package/skills/sitrep/SKILL.md +126 -0
  84. package/skills/structure-code/SKILL.md +116 -0
  85. package/skills/surface-assumptions/SKILL.md +139 -0
  86. package/skills/teach/GLOSSARY-FORMAT.md +35 -0
  87. package/skills/teach/LEARNING-RECORD-FORMAT.md +46 -0
  88. package/skills/teach/MISSION-FORMAT.md +31 -0
  89. package/skills/teach/RESOURCES-FORMAT.md +32 -0
  90. package/skills/teach/SKILL.md +131 -0
  91. package/skills/team-protocol/SKILL.md +172 -0
  92. package/skills/tech-lead/SKILL.md +332 -0
  93. package/skills/to-issues/SKILL.md +83 -0
  94. package/skills/to-prd/SKILL.md +74 -0
  95. package/skills/vectorbt-expert/SKILL.md +253 -0
  96. package/skills/verify-done/SKILL.md +128 -0
  97. package/skills/work-log/SKILL.md +141 -0
  98. package/skills/work-log/template.html +496 -0
  99. package/skills/zoom-out/SKILL.md +7 -0
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a generated plugin against the plugin ingestion contract."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ from pathlib import Path, PurePosixPath
10
+ from typing import Any
11
+ from urllib.parse import urlparse
12
+
13
+ import yaml
14
+
15
+
16
+ TODO_MARKER = "[TODO:"
17
+ SEMVER_RE = re.compile(
18
+ r"^(0|[1-9]\d*)\."
19
+ r"(0|[1-9]\d*)\."
20
+ r"(0|[1-9]\d*)"
21
+ r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\."
22
+ r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
23
+ r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
24
+ )
25
+ HEX_COLOR_RE = re.compile(r"^#[0-9A-F]{6}$", re.IGNORECASE)
26
+
27
+
28
+ def parse_args() -> argparse.Namespace:
29
+ parser = argparse.ArgumentParser(description="Validate a local Codex plugin.")
30
+ parser.add_argument("plugin_path", help="Path to the plugin root directory")
31
+ return parser.parse_args()
32
+
33
+
34
+ def main() -> None:
35
+ args = parse_args()
36
+ plugin_root = Path(args.plugin_path).expanduser().resolve()
37
+ errors = validate_plugin(plugin_root)
38
+ if errors:
39
+ print("Plugin validation failed:")
40
+ for error in errors:
41
+ print(f"- {error}")
42
+ raise SystemExit(1)
43
+ print(f"Plugin validation passed: {plugin_root}")
44
+
45
+
46
+ def validate_plugin(plugin_root: Path) -> list[str]:
47
+ errors: list[str] = []
48
+ manifest_path = plugin_root / ".codex-plugin" / "plugin.json"
49
+ manifest = load_json_object(manifest_path, errors)
50
+ if manifest is None:
51
+ return errors
52
+
53
+ reject_todo_markers(manifest, "$", errors)
54
+ validate_manifest_shape(plugin_root, manifest, errors)
55
+ return errors
56
+
57
+
58
+ def load_json_object(path: Path, errors: list[str]) -> dict[str, Any] | None:
59
+ if not path.is_file():
60
+ errors.append("missing `.codex-plugin/plugin.json`")
61
+ return None
62
+ try:
63
+ payload = json.loads(path.read_text(encoding="utf-8"))
64
+ except OSError:
65
+ errors.append("unable to read `.codex-plugin/plugin.json`")
66
+ return None
67
+ except json.JSONDecodeError:
68
+ errors.append("`.codex-plugin/plugin.json` must be valid JSON")
69
+ return None
70
+ if not isinstance(payload, dict):
71
+ errors.append("`.codex-plugin/plugin.json` must contain a JSON object")
72
+ return None
73
+ return payload
74
+
75
+
76
+ def reject_todo_markers(value: Any, path: str, errors: list[str]) -> None:
77
+ if isinstance(value, str):
78
+ if TODO_MARKER in value:
79
+ errors.append(f"{path} still contains a `[TODO: ...]` placeholder")
80
+ return
81
+ if isinstance(value, list):
82
+ for index, item in enumerate(value):
83
+ reject_todo_markers(item, f"{path}[{index}]", errors)
84
+ return
85
+ if isinstance(value, dict):
86
+ for key, item in value.items():
87
+ reject_todo_markers(item, f"{path}.{key}", errors)
88
+
89
+
90
+ def validate_manifest_shape(
91
+ plugin_root: Path,
92
+ manifest: dict[str, Any],
93
+ errors: list[str],
94
+ ) -> None:
95
+ allowed_keys = {
96
+ "id",
97
+ "name",
98
+ "version",
99
+ "description",
100
+ "skills",
101
+ "apps",
102
+ "mcpServers",
103
+ "interface",
104
+ "author",
105
+ "homepage",
106
+ "repository",
107
+ "license",
108
+ "keywords",
109
+ }
110
+ for key in sorted(set(manifest) - allowed_keys):
111
+ errors.append(f"plugin.json field `{key}` is not accepted by plugin validation")
112
+
113
+ validate_optional_non_empty_string(manifest, "id", errors)
114
+ require_non_empty_string(manifest, "name", errors)
115
+ version = require_non_empty_string(manifest, "version", errors)
116
+ if version is not None and SEMVER_RE.fullmatch(version) is None:
117
+ errors.append("plugin.json field `version` must be strict semver")
118
+ require_non_empty_string(manifest, "description", errors)
119
+
120
+ author = require_object(manifest, "author", errors)
121
+ if author is not None:
122
+ reject_unknown_fields(author, {"name", "email", "url"}, "author", errors)
123
+ require_non_empty_string(author, "name", errors, prefix="author")
124
+ validate_optional_non_empty_string(author, "email", errors, prefix="author")
125
+ validate_optional_https_url(author, "url", errors, prefix="author")
126
+
127
+ validate_optional_contract_path(manifest, "skills", "skills", errors)
128
+ validate_optional_contract_path(manifest, "apps", ".app.json", errors)
129
+ validate_optional_contract_path(manifest, "mcpServers", ".mcp.json", errors)
130
+
131
+ if manifest.get("apps") is not None:
132
+ validate_app_manifest(
133
+ plugin_root / ".app.json",
134
+ errors,
135
+ )
136
+ if manifest.get("mcpServers") is not None:
137
+ validate_mcp_manifest(
138
+ plugin_root / ".mcp.json",
139
+ errors,
140
+ )
141
+ validate_skill_manifests(plugin_root, errors)
142
+
143
+ interface = require_object(manifest, "interface", errors)
144
+ if interface is None:
145
+ return
146
+ reject_unknown_fields(
147
+ interface,
148
+ {
149
+ "displayName",
150
+ "shortDescription",
151
+ "longDescription",
152
+ "developerName",
153
+ "category",
154
+ "capabilities",
155
+ "websiteURL",
156
+ "privacyPolicyURL",
157
+ "termsOfServiceURL",
158
+ "brandColor",
159
+ "composerIcon",
160
+ "logo",
161
+ "screenshots",
162
+ "defaultPrompt",
163
+ "default_prompt",
164
+ },
165
+ "interface",
166
+ errors,
167
+ )
168
+ for field in (
169
+ "displayName",
170
+ "shortDescription",
171
+ "longDescription",
172
+ "developerName",
173
+ "category",
174
+ ):
175
+ require_non_empty_string(interface, field, errors, prefix="interface")
176
+ if "defaultPrompt" not in interface and "default_prompt" not in interface:
177
+ errors.append(
178
+ "plugin.json field `interface.defaultPrompt` or `interface.default_prompt` is required"
179
+ )
180
+ capabilities = interface.get("capabilities")
181
+ if not isinstance(capabilities, list) or not all(
182
+ isinstance(value, str) and value.strip() for value in capabilities
183
+ ):
184
+ errors.append("plugin.json field `interface.capabilities` must be an array of strings")
185
+ for field in ("websiteURL", "privacyPolicyURL", "termsOfServiceURL"):
186
+ validate_optional_https_url(interface, field, errors, prefix="interface")
187
+ brand_color = interface.get("brandColor")
188
+ if brand_color is not None and (
189
+ not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
190
+ ):
191
+ errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")
192
+ for field in ("composerIcon", "logo"):
193
+ validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)
194
+ screenshots = interface.get("screenshots", [])
195
+ if not isinstance(screenshots, list):
196
+ errors.append("plugin.json field `interface.screenshots` must be an array")
197
+ else:
198
+ for index, raw_path in enumerate(screenshots):
199
+ validate_asset_path(
200
+ plugin_root,
201
+ plugin_root,
202
+ raw_path,
203
+ f"interface.screenshots[{index}]",
204
+ errors,
205
+ )
206
+
207
+
208
+ def require_object(
209
+ payload: dict[str, Any],
210
+ key: str,
211
+ errors: list[str],
212
+ ) -> dict[str, Any] | None:
213
+ value = payload.get(key)
214
+ if not isinstance(value, dict):
215
+ errors.append(f"plugin.json field `{key}` must be an object")
216
+ return None
217
+ return value
218
+
219
+
220
+ def require_non_empty_string(
221
+ payload: dict[str, Any],
222
+ key: str,
223
+ errors: list[str],
224
+ *,
225
+ prefix: str | None = None,
226
+ ) -> str | None:
227
+ value = payload.get(key)
228
+ field = f"{prefix}.{key}" if prefix is not None else key
229
+ if not isinstance(value, str) or not value.strip():
230
+ errors.append(f"plugin.json field `{field}` must be a non-empty string")
231
+ return None
232
+ return value
233
+
234
+
235
+ def validate_optional_non_empty_string(
236
+ payload: dict[str, Any],
237
+ key: str,
238
+ errors: list[str],
239
+ *,
240
+ prefix: str | None = None,
241
+ ) -> None:
242
+ value = payload.get(key)
243
+ if value is None:
244
+ return
245
+ field = f"{prefix}.{key}" if prefix is not None else key
246
+ if not isinstance(value, str) or not value.strip():
247
+ errors.append(f"plugin.json field `{field}` must be a non-empty string")
248
+
249
+
250
+ def reject_unknown_fields(
251
+ payload: dict[str, Any],
252
+ allowed_keys: set[str],
253
+ prefix: str,
254
+ errors: list[str],
255
+ ) -> None:
256
+ for key in sorted(set(payload) - allowed_keys):
257
+ errors.append(f"plugin.json field `{prefix}.{key}` is not accepted by plugin validation")
258
+
259
+
260
+ def validate_optional_https_url(
261
+ payload: dict[str, Any],
262
+ key: str,
263
+ errors: list[str],
264
+ *,
265
+ prefix: str,
266
+ ) -> None:
267
+ value = payload.get(key)
268
+ if value is None:
269
+ return
270
+ parsed = urlparse(value) if isinstance(value, str) else None
271
+ if parsed is None or parsed.scheme != "https" or not parsed.netloc:
272
+ errors.append(f"plugin.json field `{prefix}.{key}` must be an absolute `https://` URL")
273
+
274
+
275
+ def validate_optional_contract_path(
276
+ payload: dict[str, Any],
277
+ key: str,
278
+ expected: str,
279
+ errors: list[str],
280
+ ) -> None:
281
+ value = payload.get(key)
282
+ if value is None:
283
+ return
284
+ normalized = normalize_contract_path(value) if isinstance(value, str) else None
285
+ if normalized != expected:
286
+ errors.append(f"plugin.json field `{key}` must resolve to `{expected}`")
287
+
288
+
289
+ def normalize_contract_path(raw_path: str) -> str | None:
290
+ path = Path(raw_path)
291
+ if path.is_absolute():
292
+ return None
293
+ normalized = path.as_posix().rstrip("/")
294
+ return normalized or None
295
+
296
+
297
+ def validate_app_manifest(path: Path, errors: list[str]) -> None:
298
+ payload = load_companion_json_object(path, "`.app.json`", errors)
299
+ if payload is None:
300
+ return
301
+ reject_companion_unknown_fields(payload, {"apps"}, "`.app.json`", errors)
302
+ apps = payload.get("apps")
303
+ if not isinstance(apps, dict):
304
+ errors.append("`.app.json` field `apps` must be an object")
305
+ return
306
+ for key, value in apps.items():
307
+ if not isinstance(value, dict):
308
+ errors.append(f"`.app.json` app `{key}` must be an object")
309
+ continue
310
+ reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors)
311
+ app_id = value.get("id")
312
+ if not isinstance(app_id, str) or not app_id.strip():
313
+ errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string")
314
+
315
+
316
+ def validate_mcp_manifest(path: Path, errors: list[str]) -> None:
317
+ payload = load_companion_json_object(path, "`.mcp.json`", errors)
318
+ if payload is None:
319
+ return
320
+ reject_companion_unknown_fields(payload, {"mcpServers"}, "`.mcp.json`", errors)
321
+ servers = payload.get("mcpServers")
322
+ if not isinstance(servers, dict):
323
+ errors.append("`.mcp.json` field `mcpServers` must be an object")
324
+ return
325
+ for key, value in servers.items():
326
+ if not isinstance(key, str) or not key.strip():
327
+ errors.append("`.mcp.json` server names must be non-empty strings")
328
+ if not isinstance(value, dict):
329
+ errors.append(f"`.mcp.json` server `{key}` must be an object")
330
+
331
+
332
+ def load_companion_json_object(
333
+ path: Path,
334
+ label: str,
335
+ errors: list[str],
336
+ ) -> dict[str, Any] | None:
337
+ if not path.is_file():
338
+ errors.append(f"{label} is required when its plugin.json field is present")
339
+ return None
340
+ try:
341
+ payload = json.loads(path.read_text(encoding="utf-8"))
342
+ except (OSError, json.JSONDecodeError):
343
+ errors.append(f"{label} must contain valid JSON")
344
+ return None
345
+ if not isinstance(payload, dict):
346
+ errors.append(f"{label} must contain a JSON object")
347
+ return None
348
+ return payload
349
+
350
+
351
+ def reject_companion_unknown_fields(
352
+ payload: dict[str, Any],
353
+ allowed_keys: set[str],
354
+ prefix: str,
355
+ errors: list[str],
356
+ ) -> None:
357
+ for key in sorted(set(payload) - allowed_keys):
358
+ errors.append(f"{prefix} field `{key}` is not accepted by plugin validation")
359
+
360
+
361
+ def validate_skill_manifests(plugin_root: Path, errors: list[str]) -> None:
362
+ skills_root = plugin_root / "skills"
363
+ if not skills_root.is_dir():
364
+ return
365
+ for skill_root in sorted(skills_root.iterdir(), key=lambda path: path.name):
366
+ if skill_root.name.startswith(".") or not skill_root.is_dir():
367
+ continue
368
+ validate_skill_manifest(skill_root, errors)
369
+
370
+
371
+ def validate_skill_manifest(skill_root: Path, errors: list[str]) -> None:
372
+ skill_md_path = skill_root / "SKILL.md"
373
+ if not skill_md_path.is_file():
374
+ errors.append(f"skill `{skill_root.name}` is missing `SKILL.md`")
375
+ return
376
+ try:
377
+ contents = skill_md_path.read_text(encoding="utf-8")
378
+ except OSError:
379
+ errors.append(f"unable to read skill `{skill_root.name}`")
380
+ return
381
+ if not contents.startswith("---\n"):
382
+ errors.append(f"skill `{skill_root.name}` must start with YAML frontmatter")
383
+ return
384
+ frontmatter_end = contents.find("\n---", 4)
385
+ if frontmatter_end == -1:
386
+ errors.append(f"skill `{skill_root.name}` frontmatter is not closed")
387
+ return
388
+ try:
389
+ frontmatter = yaml.safe_load(contents[4:frontmatter_end])
390
+ except yaml.YAMLError:
391
+ errors.append(f"skill `{skill_root.name}` frontmatter must be valid YAML")
392
+ return
393
+ if not isinstance(frontmatter, dict):
394
+ errors.append(f"skill `{skill_root.name}` frontmatter must be an object")
395
+ return
396
+ skill_name = frontmatter.get("name")
397
+ if not isinstance(skill_name, str) or not skill_name.strip():
398
+ errors.append(f"skill `{skill_root.name}` frontmatter field `name` must be non-empty")
399
+ description = frontmatter.get("description")
400
+ if not isinstance(description, str) or not description.strip():
401
+ errors.append(
402
+ f"skill `{skill_root.name}` frontmatter field `description` must be non-empty"
403
+ )
404
+ disable_model_invocation = frontmatter.get("disable-model-invocation")
405
+ if disable_model_invocation is None:
406
+ disable_model_invocation = frontmatter.get("disable_model_invocation")
407
+ if disable_model_invocation not in (None, False):
408
+ errors.append(
409
+ f"skill `{skill_root.name}` frontmatter field `disable-model-invocation` must be false"
410
+ )
411
+ agent_yaml_path = skill_root / "agents" / "openai.yaml"
412
+ if agent_yaml_path.is_file():
413
+ validate_skill_agent_manifest(
414
+ plugin_root=skill_root.parent.parent,
415
+ skill_root=skill_root,
416
+ agent_yaml_path=agent_yaml_path,
417
+ errors=errors,
418
+ )
419
+
420
+
421
+ def validate_skill_agent_manifest(
422
+ *,
423
+ plugin_root: Path,
424
+ skill_root: Path,
425
+ agent_yaml_path: Path,
426
+ errors: list[str],
427
+ ) -> None:
428
+ try:
429
+ payload = yaml.safe_load(agent_yaml_path.read_text(encoding="utf-8"))
430
+ except OSError:
431
+ errors.append(f"unable to read skill `{skill_root.name}` agent YAML")
432
+ return
433
+ except yaml.YAMLError:
434
+ errors.append(f"skill `{skill_root.name}` agent YAML must be valid YAML")
435
+ return
436
+ if not isinstance(payload, dict):
437
+ errors.append(f"skill `{skill_root.name}` agent YAML must be an object")
438
+ return
439
+
440
+ reject_skill_agent_unknown_fields(
441
+ payload,
442
+ {"interface", "policy", "dependencies"},
443
+ skill_root,
444
+ errors,
445
+ )
446
+ interface = payload.get("interface")
447
+ if not isinstance(interface, dict):
448
+ errors.append(f"skill `{skill_root.name}` agent field `interface` must be an object")
449
+ return
450
+ reject_skill_agent_unknown_fields(
451
+ interface,
452
+ {
453
+ "display_name",
454
+ "short_description",
455
+ "icon_small",
456
+ "icon_large",
457
+ "brand_color",
458
+ "default_prompt",
459
+ },
460
+ skill_root,
461
+ errors,
462
+ prefix="interface",
463
+ )
464
+ for field in ("display_name", "short_description"):
465
+ value = interface.get(field)
466
+ if not isinstance(value, str) or not value.strip():
467
+ errors.append(
468
+ f"skill `{skill_root.name}` agent field `interface.{field}` must be non-empty"
469
+ )
470
+ for field in ("icon_small", "icon_large"):
471
+ validate_optional_asset_path(
472
+ skill_root,
473
+ plugin_root,
474
+ interface,
475
+ field,
476
+ errors,
477
+ prefix=f"skill `{skill_root.name}` agent field `interface",
478
+ )
479
+ brand_color = interface.get("brand_color")
480
+ if brand_color is not None and (
481
+ not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
482
+ ):
483
+ errors.append(
484
+ f"skill `{skill_root.name}` agent field `interface.brand_color` must use `#RRGGBB`"
485
+ )
486
+ default_prompt = interface.get("default_prompt")
487
+ if default_prompt is not None and (
488
+ not isinstance(default_prompt, str) or not default_prompt.strip()
489
+ ):
490
+ errors.append(
491
+ f"skill `{skill_root.name}` agent field `interface.default_prompt` must be non-empty"
492
+ )
493
+
494
+ policy = payload.get("policy")
495
+ if policy is not None:
496
+ if not isinstance(policy, dict):
497
+ errors.append(f"skill `{skill_root.name}` agent field `policy` must be an object")
498
+ else:
499
+ reject_skill_agent_unknown_fields(
500
+ policy,
501
+ {"allow_implicit_invocation"},
502
+ skill_root,
503
+ errors,
504
+ prefix="policy",
505
+ )
506
+ allow_implicit_invocation = policy.get("allow_implicit_invocation")
507
+ if allow_implicit_invocation is not None and not isinstance(
508
+ allow_implicit_invocation,
509
+ bool,
510
+ ):
511
+ errors.append(
512
+ f"skill `{skill_root.name}` agent field "
513
+ "`policy.allow_implicit_invocation` must be a boolean"
514
+ )
515
+
516
+ dependencies = payload.get("dependencies")
517
+ if dependencies is not None:
518
+ if not isinstance(dependencies, dict):
519
+ errors.append(
520
+ f"skill `{skill_root.name}` agent field `dependencies` must be an object"
521
+ )
522
+ else:
523
+ reject_skill_agent_unknown_fields(
524
+ dependencies,
525
+ {"tools"},
526
+ skill_root,
527
+ errors,
528
+ prefix="dependencies",
529
+ )
530
+
531
+
532
+ def reject_skill_agent_unknown_fields(
533
+ payload: dict[str, Any],
534
+ allowed_keys: set[str],
535
+ skill_root: Path,
536
+ errors: list[str],
537
+ *,
538
+ prefix: str | None = None,
539
+ ) -> None:
540
+ for key in sorted(set(payload) - allowed_keys):
541
+ field = f"{prefix}.{key}" if prefix is not None else key
542
+ errors.append(
543
+ f"skill `{skill_root.name}` agent field `{field}` is not accepted by plugin validation"
544
+ )
545
+
546
+
547
+ def validate_optional_asset_path(
548
+ base_dir: Path,
549
+ allowed_root: Path,
550
+ payload: dict[str, Any],
551
+ key: str,
552
+ errors: list[str],
553
+ *,
554
+ prefix: str = "interface",
555
+ ) -> None:
556
+ raw_path = payload.get(key)
557
+ if raw_path is None:
558
+ return
559
+ validate_asset_path(base_dir, allowed_root, raw_path, f"{prefix}.{key}", errors)
560
+
561
+
562
+ def validate_asset_path(
563
+ base_dir: Path,
564
+ allowed_root: Path,
565
+ raw_path: Any,
566
+ field: str,
567
+ errors: list[str],
568
+ ) -> None:
569
+ label = field if field.startswith("skill `") else f"plugin.json field `{field}`"
570
+ if not isinstance(raw_path, str) or not raw_path.strip():
571
+ errors.append(f"{label} must be a non-empty relative path")
572
+ return
573
+ candidate = PurePosixPath(raw_path.replace("\\", "/"))
574
+ if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
575
+ errors.append(f"{label} must stay inside the plugin archive")
576
+ return
577
+ resolved_path = (base_dir / candidate.as_posix()).resolve()
578
+ if not resolved_path.is_relative_to(allowed_root.resolve()):
579
+ errors.append(f"{label} must stay inside the plugin archive")
580
+ return
581
+ if not resolved_path.is_file():
582
+ errors.append(f"{label} points to a missing file")
583
+
584
+
585
+ if __name__ == "__main__":
586
+ main()