pattyeng 1.0.4 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -43
- package/bin/pattyeng.js +25 -14
- package/lib/skills.js +198 -0
- package/lib/upgrade.js +46 -0
- package/package.json +5 -3
- package/skills/.system/.codex-system-skills.marker +1 -0
- package/skills/.system/imagegen/LICENSE.txt +201 -0
- package/skills/.system/imagegen/SKILL.md +356 -0
- package/skills/.system/imagegen/agents/openai.yaml +6 -0
- package/skills/.system/imagegen/assets/imagegen-small.svg +5 -0
- package/skills/.system/imagegen/assets/imagegen.png +0 -0
- package/skills/.system/imagegen/references/cli.md +242 -0
- package/skills/.system/imagegen/references/codex-network.md +33 -0
- package/skills/.system/imagegen/references/image-api.md +90 -0
- package/skills/.system/imagegen/references/prompting.md +118 -0
- package/skills/.system/imagegen/references/sample-prompts.md +433 -0
- package/skills/.system/imagegen/scripts/image_gen.py +995 -0
- package/skills/.system/imagegen/scripts/remove_chroma_key.py +440 -0
- package/skills/.system/openai-docs/LICENSE.txt +201 -0
- package/skills/.system/openai-docs/SKILL.md +167 -0
- package/skills/.system/openai-docs/agents/openai.yaml +14 -0
- package/skills/.system/openai-docs/assets/openai-small.svg +3 -0
- package/skills/.system/openai-docs/assets/openai.png +0 -0
- package/skills/.system/openai-docs/references/latest-model.md +37 -0
- package/skills/.system/openai-docs/references/prompting-guide.md +244 -0
- package/skills/.system/openai-docs/references/upgrade-guide.md +181 -0
- package/skills/.system/openai-docs/scripts/fetch-codex-manual.mjs +598 -0
- package/skills/.system/openai-docs/scripts/resolve-latest-model-info.js +147 -0
- package/skills/.system/plugin-creator/SKILL.md +243 -0
- package/skills/.system/plugin-creator/agents/openai.yaml +6 -0
- package/skills/.system/plugin-creator/assets/plugin-creator-small.svg +3 -0
- package/skills/.system/plugin-creator/assets/plugin-creator.png +0 -0
- package/skills/.system/plugin-creator/references/installing-and-updating.md +143 -0
- package/skills/.system/plugin-creator/references/plugin-json-spec.md +194 -0
- package/skills/.system/plugin-creator/scripts/create_basic_plugin.py +324 -0
- package/skills/.system/plugin-creator/scripts/read_marketplace_name.py +48 -0
- package/skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py +78 -0
- package/skills/.system/plugin-creator/scripts/validate_plugin.py +586 -0
- package/skills/.system/skill-creator/SKILL.md +416 -0
- package/skills/.system/skill-creator/agents/openai.yaml +5 -0
- package/skills/.system/skill-creator/assets/skill-creator-small.svg +3 -0
- package/skills/.system/skill-creator/assets/skill-creator.png +0 -0
- package/skills/.system/skill-creator/license.txt +202 -0
- package/skills/.system/skill-creator/references/openai_yaml.md +49 -0
- package/skills/.system/skill-creator/scripts/generate_openai_yaml.py +226 -0
- package/skills/.system/skill-creator/scripts/init_skill.py +400 -0
- package/skills/.system/skill-creator/scripts/quick_validate.py +101 -0
- package/skills/.system/skill-installer/LICENSE.txt +202 -0
- package/skills/.system/skill-installer/SKILL.md +58 -0
- package/skills/.system/skill-installer/agents/openai.yaml +5 -0
- package/skills/.system/skill-installer/assets/skill-installer-small.svg +3 -0
- package/skills/.system/skill-installer/assets/skill-installer.png +0 -0
- package/skills/.system/skill-installer/scripts/github_utils.py +21 -0
- package/skills/.system/skill-installer/scripts/install-skill-from-github.py +308 -0
- package/skills/.system/skill-installer/scripts/list-skills.py +107 -0
- package/skills/cap/SKILL.md +77 -0
- package/skills/caveman/SKILL.md +49 -0
- package/skills/code-review/SKILL.md +46 -0
- package/skills/codebook/SKILL.md +184 -0
- package/skills/doc-review/SKILL.md +147 -0
- package/skills/feature-research/SKILL.md +34 -0
- package/skills/file-op/SKILL.md +116 -0
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/skills/grill-with-docs/SKILL.md +88 -0
- package/skills/handoff/SKILL.md +17 -0
- package/skills/improve-codebase/SKILL.md +79 -0
- package/skills/linear/SKILL.md +107 -0
- package/skills/next-step/SKILL.md +61 -0
- package/skills/no-reinvent/SKILL.md +131 -0
- package/skills/no-wall/SKILL.md +146 -0
- package/skills/online/SKILL.md +54 -0
- package/skills/plan-review/SKILL.md +55 -0
- package/skills/professional/SKILL.md +111 -0
- package/skills/promise-tracking/SKILL.md +131 -0
- package/skills/prove/SKILL.md +135 -0
- package/skills/read-before-write/SKILL.md +123 -0
- package/skills/review/SKILL.md +78 -0
- package/skills/review-mp/SKILL.md +78 -0
- package/skills/scope-boundary/SKILL.md +121 -0
- package/skills/semble-search/SKILL.md +40 -0
- package/skills/sitrep/SKILL.md +126 -0
- package/skills/structure-code/SKILL.md +116 -0
- package/skills/surface-assumptions/SKILL.md +139 -0
- package/skills/teach/GLOSSARY-FORMAT.md +35 -0
- package/skills/teach/LEARNING-RECORD-FORMAT.md +46 -0
- package/skills/teach/MISSION-FORMAT.md +31 -0
- package/skills/teach/RESOURCES-FORMAT.md +32 -0
- package/skills/teach/SKILL.md +131 -0
- package/skills/team-protocol/SKILL.md +172 -0
- package/skills/tech-lead/SKILL.md +332 -0
- package/skills/to-issues/SKILL.md +83 -0
- package/skills/to-prd/SKILL.md +74 -0
- package/skills/vectorbt-expert/SKILL.md +253 -0
- package/skills/verify-done/SKILL.md +128 -0
- package/skills/work-log/SKILL.md +141 -0
- package/skills/work-log/template.html +496 -0
- 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()
|