@raishin/vanguard-frontier-agentic 2.5.0 → 2.6.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.
Files changed (37) hide show
  1. package/.agents/tasks/task-dynamic-kiro-powers/2025-01-24-120000-review.md +92 -0
  2. package/.agents/tasks/task-dynamic-kiro-powers/context.json +22 -0
  3. package/.agents/tasks/task-dynamic-kiro-powers/features/FEAT-001.json +34 -0
  4. package/.agents/tasks/task-dynamic-kiro-powers/task.json +14 -0
  5. package/.claude-plugin/marketplace.json +1 -1
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.github/plugin/marketplace.json +1 -1
  9. package/README.md +2 -0
  10. package/catalog/asset-integrity.json +129 -29
  11. package/package.json +3 -1
  12. package/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json +3 -2
  13. package/plugins/vanguard-frontier-agentic/skills/vanguard-frontier-agentic-install/SKILL.md +37 -0
  14. package/powers/README.md +28 -10
  15. package/powers/vanguard-argocd/POWER.md +40 -0
  16. package/powers/vanguard-backstage/POWER.md +40 -0
  17. package/powers/vanguard-cert-manager/POWER.md +40 -0
  18. package/powers/vanguard-cilium/POWER.md +40 -0
  19. package/powers/vanguard-dotnet/POWER.md +41 -0
  20. package/powers/vanguard-falco/POWER.md +40 -0
  21. package/powers/vanguard-fluxcd/POWER.md +40 -0
  22. package/powers/vanguard-generic/POWER.md +40 -0
  23. package/powers/vanguard-hr/POWER.md +41 -0
  24. package/powers/vanguard-istio/POWER.md +40 -0
  25. package/powers/vanguard-kyverno/POWER.md +40 -0
  26. package/powers/vanguard-legal/POWER.md +41 -0
  27. package/powers/vanguard-marketing/POWER.md +41 -0
  28. package/powers/vanguard-multi-cloud/POWER.md +41 -0
  29. package/powers/vanguard-opentelemetry/POWER.md +40 -0
  30. package/powers/vanguard-prometheus/POWER.md +40 -0
  31. package/powers/vanguard-sigstore/POWER.md +40 -0
  32. package/scripts/export-marketplace-agents.mjs +26 -0
  33. package/scripts/generate-kiro-powers.mjs +360 -5
  34. package/scripts/install-codex-home.mjs +95 -0
  35. package/tests/test-codex-plugin-marketplace-install.test.mjs +132 -0
  36. package/tests/test-vfa-export-coverage.test.mjs +108 -0
  37. package/tests/validate-codex-marketplace.py +23 -1
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Reliable two-stage installer for Vanguard Frontier Agentic on Codex.
4
+ *
5
+ * Stage 1: register/refresh the Codex plugin marketplace.
6
+ * Stage 2: export all Codex-capable agents and companion skills into a Codex home.
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
+ const exporter = path.join(repoRoot, "scripts", "export-marketplace-agents.mjs");
16
+
17
+ const args = process.argv.slice(2);
18
+ const opts = {
19
+ marketplace: "Raishin/vanguard-frontier-agentic",
20
+ repo: os.homedir(),
21
+ force: true,
22
+ skipMarketplace: false,
23
+ dryRun: false,
24
+ };
25
+
26
+ function usage(exitCode = 0) {
27
+ const out = exitCode === 0 ? console.log : console.error;
28
+ out(`Usage: node scripts/install-codex-home.mjs [options]\n\nOptions:\n --marketplace <source> Codex marketplace source (default: Raishin/vanguard-frontier-agentic)\n --repo <path> Target home/repo path whose .codex folder receives agents/skills (default: $HOME)\n --dry-run Do not write agents/skills; pass --dry-run to exporter\n --skip-marketplace Skip codex plugin marketplace add/upgrade\n --no-force Do not pass --force to exporter\n -h, --help Show this help\n`);
29
+ process.exit(exitCode);
30
+ }
31
+
32
+ for (let i = 0; i < args.length; i++) {
33
+ const arg = args[i];
34
+ if (arg === "-h" || arg === "--help") usage(0);
35
+ if (arg === "--marketplace") {
36
+ const val = args[++i];
37
+ if (!val || val.startsWith("-")) { console.error("--marketplace requires a non-flag value"); usage(1); }
38
+ opts.marketplace = val;
39
+ } else if (arg === "--repo") {
40
+ const val = args[++i];
41
+ if (!val || val.startsWith("-")) { console.error("--repo requires a non-flag value"); usage(1); }
42
+ opts.repo = val;
43
+ }
44
+ else if (arg === "--dry-run") opts.dryRun = true;
45
+ else if (arg === "--skip-marketplace") opts.skipMarketplace = true;
46
+ else if (arg === "--no-force") opts.force = false;
47
+ else {
48
+ console.error(`Unknown option: ${arg}`);
49
+ usage(1);
50
+ }
51
+ }
52
+
53
+ if (!opts.marketplace) {
54
+ console.error("--marketplace cannot be empty");
55
+ process.exit(1);
56
+ }
57
+ if (!opts.repo) {
58
+ console.error("--repo cannot be empty");
59
+ process.exit(1);
60
+ }
61
+
62
+ function run(label, command, commandArgs, options = {}) {
63
+ console.error(`\n[${label}] ${command} ${commandArgs.join(" ")}`);
64
+ const result = spawnSync(command, commandArgs, {
65
+ cwd: repoRoot,
66
+ stdio: "inherit",
67
+ ...options,
68
+ });
69
+ if (result.error) {
70
+ console.error(`[${label}] failed to start: ${result.error.message}`);
71
+ process.exit(1);
72
+ }
73
+ if (result.status !== 0) {
74
+ console.error(`[${label}] exited ${result.status}`);
75
+ process.exit(result.status ?? 1);
76
+ }
77
+ }
78
+
79
+ if (!opts.skipMarketplace) {
80
+ run("marketplace-add", "codex", ["plugin", "marketplace", "add", opts.marketplace]);
81
+ const marketplaceName = opts.marketplace
82
+ .split("/").pop()
83
+ ?.replace(/\.git$/, "")
84
+ ?.replace(/@.+$/, "");
85
+ if (marketplaceName) {
86
+ run("marketplace-upgrade", "codex", ["plugin", "marketplace", "upgrade", marketplaceName]);
87
+ }
88
+ }
89
+
90
+ const exportArgs = ["--platform", "codex", "--all", "--repo", opts.repo];
91
+ if (opts.force) exportArgs.push("--force");
92
+ if (opts.dryRun) exportArgs.push("--dry-run");
93
+ run("export-agents-and-skills", process.execPath, [exporter, ...exportArgs]);
94
+
95
+ console.error("\nOK: two-stage Codex install completed");
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Optional E2E check for the real Codex marketplace-add command.
4
+ *
5
+ * This test is intentionally opt-in because it runs the installed `codex` CLI
6
+ * and may hit the network when CODEX_PLUGIN_MARKETPLACE_SOURCE is a GitHub
7
+ * shorthand. It uses an isolated CODEX_HOME and never writes to ~/.codex.
8
+ *
9
+ * What it proves:
10
+ * - `codex plugin marketplace add <source>` exits successfully.
11
+ * - Codex tracks the marketplace in the isolated CODEX_HOME/config.toml.
12
+ * - Codex materializes the marketplace source under CODEX_HOME/.tmp/marketplaces.
13
+ * - The materialized marketplace contains the repo's Codex marketplace and plugin manifests.
14
+ *
15
+ * What it does NOT prove:
16
+ * - It does not prove a plugin was installed into CODEX_HOME/plugins/cache/... .
17
+ * OpenAI docs describe that as plugin installation through a marketplace;
18
+ * the current CLI command under test is marketplace-add, not plugin-install.
19
+ *
20
+ * Run:
21
+ * RUN_CODEX_PLUGIN_MARKETPLACE_E2E=1 node tests/test-codex-plugin-marketplace-install.test.mjs
22
+ *
23
+ * Optional override:
24
+ * CODEX_PLUGIN_MARKETPLACE_SOURCE=Raishin/vanguard-frontier-agentic@main \
25
+ * RUN_CODEX_PLUGIN_MARKETPLACE_E2E=1 node tests/test-codex-plugin-marketplace-install.test.mjs
26
+ *
27
+ * Strict cache assertion, expected to fail for marketplace-add-only on the
28
+ * current Codex CLI unless a separate plugin install path populates cache:
29
+ * EXPECT_CODEX_PLUGIN_CACHE=1 RUN_CODEX_PLUGIN_MARKETPLACE_E2E=1 \
30
+ * node tests/test-codex-plugin-marketplace-install.test.mjs
31
+ */
32
+
33
+ import { spawnSync } from "node:child_process";
34
+ import fs from "node:fs";
35
+ import os from "node:os";
36
+ import path from "node:path";
37
+
38
+ const enabled = process.env.RUN_CODEX_PLUGIN_MARKETPLACE_E2E === "1";
39
+ if (!enabled) {
40
+ console.log("SKIP codex marketplace E2E; set RUN_CODEX_PLUGIN_MARKETPLACE_E2E=1 to run it");
41
+ process.exit(0);
42
+ }
43
+
44
+ const source = process.env.CODEX_PLUGIN_MARKETPLACE_SOURCE || "Raishin/vanguard-frontier-agentic";
45
+ const marketplaceName = process.env.CODEX_PLUGIN_MARKETPLACE_NAME || "vanguard-frontier-agentic";
46
+ const expectPluginCache = process.env.EXPECT_CODEX_PLUGIN_CACHE === "1";
47
+ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "vfa-codex-home-"));
48
+
49
+ let failures = 0;
50
+ const ok = (msg) => console.log(`OK ${msg}`);
51
+ const fail = (msg) => {
52
+ console.log(`FAIL ${msg}`);
53
+ failures += 1;
54
+ };
55
+
56
+ function exists(rel) {
57
+ return fs.existsSync(path.join(codexHome, rel));
58
+ }
59
+
60
+ try {
61
+ const result = spawnSync(
62
+ "codex",
63
+ ["plugin", "marketplace", "add", source],
64
+ {
65
+ encoding: "utf8",
66
+ env: {
67
+ ...process.env,
68
+ CODEX_HOME: codexHome,
69
+ },
70
+ timeout: 120000,
71
+ },
72
+ );
73
+
74
+ if (result.error?.code === "ENOENT") {
75
+ console.log("SKIP codex marketplace E2E; `codex` executable not found on PATH");
76
+ process.exit(0);
77
+ }
78
+ if (result.signal === "SIGTERM") {
79
+ fail("codex marketplace add timed out after 120s");
80
+ }
81
+ if (result.status === 0) {
82
+ ok(`codex plugin marketplace add ${source} exits 0`);
83
+ } else {
84
+ fail(`codex marketplace add exited ${result.status}; stderr=${(result.stderr || "").slice(0, 1000)}`);
85
+ }
86
+
87
+ const configPath = path.join(codexHome, "config.toml");
88
+ const config = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
89
+ if (config.includes(`[marketplaces.${marketplaceName}]`)) {
90
+ ok(`config.toml tracks marketplace ${marketplaceName}`);
91
+ } else {
92
+ fail(`config.toml missing [marketplaces.${marketplaceName}]`);
93
+ }
94
+
95
+ const installedRoot = path.join(codexHome, ".tmp", "marketplaces", marketplaceName);
96
+ if (fs.existsSync(installedRoot)) {
97
+ ok(`marketplace source materialized at ${installedRoot}`);
98
+ } else {
99
+ fail(`marketplace source missing at ${installedRoot}`);
100
+ }
101
+
102
+ const requiredFiles = [
103
+ `.tmp/marketplaces/${marketplaceName}/.agents/plugins/marketplace.json`,
104
+ `.tmp/marketplaces/${marketplaceName}/plugins/vanguard-frontier-agentic/.codex-plugin/plugin.json`,
105
+ `.tmp/marketplaces/${marketplaceName}/plugins/cross-platform-agent-template/.codex-plugin/plugin.json`,
106
+ ];
107
+ for (const rel of requiredFiles) {
108
+ if (exists(rel)) ok(`${rel} exists`);
109
+ else fail(`${rel} missing`);
110
+ }
111
+
112
+ const cacheRoot = path.join(codexHome, "plugins", "cache", marketplaceName);
113
+ if (fs.existsSync(cacheRoot)) {
114
+ ok(`plugin cache exists at ${cacheRoot}`);
115
+ } else if (expectPluginCache) {
116
+ fail(`plugin cache missing at ${cacheRoot}`);
117
+ } else {
118
+ console.log(`INFO plugin cache not created by marketplace-add alone: ${cacheRoot}`);
119
+ }
120
+ } finally {
121
+ if (process.env.KEEP_CODEX_MARKETPLACE_E2E_HOME !== "1") {
122
+ fs.rmSync(codexHome, { recursive: true, force: true });
123
+ } else {
124
+ console.log(`INFO kept isolated CODEX_HOME at ${codexHome}`);
125
+ }
126
+ }
127
+
128
+ if (failures > 0) {
129
+ console.error(`\n${failures} check(s) failed`);
130
+ process.exit(1);
131
+ }
132
+ console.log("\nOK: codex marketplace add E2E checks passed");
@@ -35,6 +35,8 @@
35
35
  * 16. --dry-run --no-skills omits skill lines.
36
36
  * 17. cursor --dry-run emits agent lines but no skill lines (unsupported platform).
37
37
  * 18. --dry-run stderr summary reports skill count on skill-capable platform.
38
+ * 19. codex --all --dry-run emits both agent and skill lines.
39
+ * 20. codex --dry-run --no-skills omits skill lines.
38
40
  *
39
41
  * F. Full CLI flag surface
40
42
  * 19. --list exits 0 and prints all agents.
@@ -46,6 +48,8 @@
46
48
  * 25. --platform claude (alias) resolves to claude-code.
47
49
  * 26. --no-skills writes agent file but no skills directory.
48
50
  * 27. --force overwrites existing agent files without error.
51
+ * 28. codex export writes agent + companion skill and rewrites skill path.
52
+ * 29. two-stage Codex installer dry-run plans all agents and skills.
49
53
  *
50
54
  * G. Error / rejection cases
51
55
  * 28. No args → usage text printed, non-zero exit.
@@ -65,6 +69,7 @@ import { fileURLToPath } from "node:url";
65
69
 
66
70
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
67
71
  const exporter = path.join(repoRoot, "scripts", "export-marketplace-agents.mjs");
72
+ const installer = path.join(repoRoot, "scripts", "install-codex-home.mjs");
68
73
 
69
74
  const agents = JSON.parse(fs.readFileSync(path.join(repoRoot, "catalog/agents.json"), "utf8"));
70
75
  const skills = JSON.parse(fs.readFileSync(path.join(repoRoot, "catalog/skills.json"), "utf8"));
@@ -133,6 +138,12 @@ function run(args) {
133
138
  return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.status ?? 0 };
134
139
  }
135
140
 
141
+ function runInstaller(args) {
142
+ const r = spawnSync(process.execPath, [installer, ...args], { encoding: "utf8", timeout: 30000 });
143
+ if (r.signal === "SIGTERM") fail(`installer timed out (30s) for: ${args.join(" ")}`);
144
+ return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", exitCode: r.status ?? 0 };
145
+ }
146
+
136
147
  // 5. --provider <p> --all should list the same count as the catalog says.
137
148
  {
138
149
  const r = run(["--platform", "claude-code", "--provider", "nvidia", "--all", "--dry-run"]);
@@ -518,6 +529,31 @@ function findLeakedSkills(skillNames, expectedProvider) {
518
529
  }
519
530
  }
520
531
 
532
+ // E19: codex --all --dry-run emits both agents and companion skills.
533
+ {
534
+ const r = run(["--platform", "codex", "--all", "--dry-run"]);
535
+ const agentCount = (r.stdout.match(/^export agent:/gm) || []).length;
536
+ const skillCount = (r.stdout.match(/^export skill:/gm) || []).length;
537
+ const codexAgents = agents.filter((a) => Array.isArray(a.harnesses) && a.harnesses.includes("codex"));
538
+ if (r.exitCode === 0 && agentCount === codexAgents.length && skillCount === skills.length && /\d+ skill\(s\)/.test(r.stderr)) {
539
+ ok(`E19 codex --all --dry-run emits agents (${agentCount}) and skills (${skillCount})`);
540
+ } else {
541
+ fail(`E19 codex dry-run expected ${codexAgents.length} agents and ${skills.length} skills, got agents=${agentCount} skills=${skillCount}; exit=${r.exitCode}; stderr=${r.stderr.slice(0, 300)}`);
542
+ }
543
+ }
544
+
545
+ // E20: codex --dry-run --no-skills emits agent lines but no skill lines.
546
+ {
547
+ const r = run(["--platform", "codex", "--agents", "aws-iam-least-privilege-review-agent", "--dry-run", "--no-skills"]);
548
+ const agentCount = (r.stdout.match(/^export agent:/gm) || []).length;
549
+ const skillCount = (r.stdout.match(/^export skill:/gm) || []).length;
550
+ if (r.exitCode === 0 && agentCount === 1 && skillCount === 0) {
551
+ ok("E20 codex --dry-run --no-skills: 1 agent line, 0 skill lines");
552
+ } else {
553
+ fail(`E20 expected 1 agent and 0 skills, got agents=${agentCount} skills=${skillCount}; exit=${r.exitCode}`);
554
+ }
555
+ }
556
+
521
557
  // ── F. Full CLI flag surface ──────────────────────────────────────────────────
522
558
 
523
559
  // F19: --list exits 0 and emits one line per agent.
@@ -638,6 +674,78 @@ function findLeakedSkills(skillNames, expectedProvider) {
638
674
  }
639
675
  }
640
676
 
677
+ // F28: codex real write installs agent + companion skill and rewrites skill path.
678
+ {
679
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vfa-test-codex-skills-"));
680
+ try {
681
+ const r = run(["--platform", "codex", "--agents", "aws-iam-least-privilege-review-agent", "--repo", tmpDir]);
682
+ const agentFile = path.join(tmpDir, ".codex", "agents", "aws-iam-least-privilege-review-agent.toml");
683
+ const skillDir = path.join(tmpDir, ".codex", "skills", "aws-iam-least-privilege-review");
684
+ const skillFile = path.join(skillDir, "SKILL.md");
685
+ const agentText = fs.existsSync(agentFile) ? fs.readFileSync(agentFile, "utf8") : "";
686
+ const expectedPathLine = `path = ${JSON.stringify(skillDir)}`;
687
+ if (
688
+ r.exitCode === 0 &&
689
+ fs.existsSync(agentFile) &&
690
+ fs.existsSync(skillFile) &&
691
+ agentText.includes(expectedPathLine) &&
692
+ !agentText.includes('path = "skills/aws/aws-iam-least-privilege-review/SKILL.md"')
693
+ ) {
694
+ ok("F28 codex export writes agent + skill and rewrites skill path to installed folder");
695
+ } else {
696
+ fail(`F28 codex export invalid: exit=${r.exitCode} agent=${fs.existsSync(agentFile)} skill=${fs.existsSync(skillFile)} hasExpectedPath=${agentText.includes(expectedPathLine)} stderr=${r.stderr.slice(0, 300)}`);
697
+ }
698
+ } finally {
699
+ fs.rmSync(tmpDir, { recursive: true, force: true });
700
+ }
701
+ }
702
+
703
+ // F28b: skill bundling refuses destination symlinks even with --force.
704
+ {
705
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vfa-test-skill-symlink-"));
706
+ const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "vfa-test-outside-"));
707
+ const outsideFile = path.join(outsideDir, "OUTSIDE_SENTINEL");
708
+ try {
709
+ const skillDir = path.join(tmpDir, ".codex", "skills", "aws-iam-least-privilege-review");
710
+ fs.mkdirSync(skillDir, { recursive: true });
711
+ fs.writeFileSync(outsideFile, "ORIGINAL_OUTSIDE_SENTINEL\n");
712
+ fs.symlinkSync(outsideFile, path.join(skillDir, "SKILL.md"));
713
+ const r = run([
714
+ "--platform", "codex",
715
+ "--agents", "aws-iam-least-privilege-review-agent",
716
+ "--repo", tmpDir,
717
+ "--force",
718
+ ]);
719
+ const outsideText = fs.readFileSync(outsideFile, "utf8");
720
+ if (r.exitCode !== 0 && /symbolic link destination in skill tree/i.test(r.stderr) && outsideText === "ORIGINAL_OUTSIDE_SENTINEL\n") {
721
+ ok("F28b codex skill export rejects destination symlink and preserves outside file");
722
+ } else {
723
+ fail(`F28b expected symlink rejection without outside overwrite; exit=${r.exitCode} preserved=${outsideText === "ORIGINAL_OUTSIDE_SENTINEL\n"} stderr=${r.stderr.slice(0, 300)}`);
724
+ }
725
+ } finally {
726
+ fs.rmSync(tmpDir, { recursive: true, force: true });
727
+ fs.rmSync(outsideDir, { recursive: true, force: true });
728
+ }
729
+ }
730
+
731
+ // F29: two-stage installer dry-run composes marketplace-safe export path.
732
+ {
733
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vfa-test-two-stage-"));
734
+ try {
735
+ const r = runInstaller(["--dry-run", "--skip-marketplace", "--repo", tmpDir]);
736
+ const combined = `${r.stdout}
737
+ ${r.stderr}`;
738
+ const planMatch = /(\d+) agent\(s\), (\d+) skill\(s\) planned/.exec(combined);
739
+ if (r.exitCode === 0 && planMatch && parseInt(planMatch[1], 10) > 0 && parseInt(planMatch[2], 10) > 0) {
740
+ ok(`F29 two-stage Codex installer dry-run plans all agents and skills (${planMatch[1]} agents, ${planMatch[2]} skills)`);
741
+ } else {
742
+ fail(`F29 installer dry-run did not plan expected agents/skills; exit=${r.exitCode} output=${combined.slice(-500)}`);
743
+ }
744
+ } finally {
745
+ fs.rmSync(tmpDir, { recursive: true, force: true });
746
+ }
747
+ }
748
+
641
749
  // ── G. Error / rejection cases ────────────────────────────────────────────────
642
750
 
643
751
  // G28: No args → usage text printed to stderr, non-zero exit.
@@ -116,12 +116,34 @@ def main() -> int:
116
116
  f"{prefix} ({name}): plugin.json name {manifest.get('name')!r} must equal marketplace entry name {name!r}",
117
117
  )
118
118
 
119
- # Version parity for the primary plugin
119
+ # Version parity and install-surface checks for the primary plugin.
120
120
  if name == "vanguard-frontier-agentic":
121
121
  if manifest.get("version") != pkg.get("version"):
122
122
  errors.append(
123
123
  f"{prefix} ({name}): plugin.json version {manifest.get('version')!r} != package.json {pkg.get('version')!r}",
124
124
  )
125
+ if manifest.get("skills") != "./skills/":
126
+ errors.append(
127
+ f"{prefix} ({name}): plugin.json must declare skills './skills/' for the bundled Codex install skill",
128
+ )
129
+ install_skill = plugin_dir / "skills" / "vanguard-frontier-agentic-install" / "SKILL.md"
130
+ if not install_skill.is_file():
131
+ errors.append(
132
+ f"{prefix} ({name}): bundled install skill is missing at {install_skill.relative_to(REPO)}",
133
+ )
134
+
135
+ skills_path = manifest.get("skills")
136
+ if skills_path is not None:
137
+ if not isinstance(skills_path, str) or not skills_path.startswith("./"):
138
+ errors.append(f"{prefix} ({name}): plugin.json skills path must be a './'-prefixed string")
139
+ else:
140
+ skills_dir = (plugin_dir / skills_path).resolve()
141
+ if not skills_dir.is_relative_to(plugin_dir.resolve()):
142
+ errors.append(f"{prefix} ({name}): plugin.json skills path must stay inside plugin root")
143
+ elif not skills_dir.is_dir():
144
+ errors.append(f"{prefix} ({name}): plugin.json skills directory {skills_path!r} is missing")
145
+ elif not any(skills_dir.glob("*/SKILL.md")):
146
+ errors.append(f"{prefix} ({name}): plugin.json skills directory has no */SKILL.md entries")
125
147
 
126
148
  policy = plugin.get("policy") or {}
127
149
  install = policy.get("installation")