opencode-skills-collection 3.1.4 → 3.1.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 (59) hide show
  1. package/bundled-skills/.antigravity-install-manifest.json +1 -1
  2. package/bundled-skills/007/scripts/full_audit.py +10 -3
  3. package/bundled-skills/2slides-ppt-generator/requirements.txt +3 -1
  4. package/bundled-skills/2slides-ppt-generator/scripts/download_slides_pages_voices.py +24 -0
  5. package/bundled-skills/2slides-ppt-generator/scripts/get_job_status.py +18 -1
  6. package/bundled-skills/agent-creator/SKILL.md +15 -1
  7. package/bundled-skills/agent-orchestrator/scripts/scan_registry.py +4 -4
  8. package/bundled-skills/android-dev/references/hybrid.md +3 -3
  9. package/bundled-skills/android-dev/references/react-native.md +14 -8
  10. package/bundled-skills/competitor-analysis/scripts/compile_report.mjs +82 -20
  11. package/bundled-skills/diary/requirements.txt +3 -1
  12. package/bundled-skills/docs/users/getting-started.md +1 -1
  13. package/bundled-skills/docx-official/ooxml/scripts/validation/base.py +19 -1
  14. package/bundled-skills/docx-official/ooxml/scripts/validation/docx.py +20 -1
  15. package/bundled-skills/docx-official/ooxml/scripts/validation/redlining.py +21 -5
  16. package/bundled-skills/ecl-harness-engineer/references/environment-detection-guide.md +1 -1
  17. package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +44 -23
  18. package/bundled-skills/instagram/scripts/db.py +120 -18
  19. package/bundled-skills/instagram/scripts/export.py +41 -8
  20. package/bundled-skills/instagram/scripts/publish.py +7 -7
  21. package/bundled-skills/instagram/scripts/run_all.py +2 -2
  22. package/bundled-skills/instagram/scripts/schedule.py +6 -5
  23. package/bundled-skills/instagram/static/dashboard.html +63 -16
  24. package/bundled-skills/junta-leiloeiros/scripts/requirements.txt +1 -1
  25. package/bundled-skills/k8s-manifest-generator/assets/deployment-template.yaml +20 -8
  26. package/bundled-skills/k8s-manifest-generator/assets/service-template.yaml +2 -3
  27. package/bundled-skills/last30days/scripts/lib/reddit_enrich.py +3 -1
  28. package/bundled-skills/loki-mode/benchmarks/results/2026-01-05-00-49-17/humaneval-solutions/162.py +1 -1
  29. package/bundled-skills/loki-mode/benchmarks/results/humaneval-loki-solutions/162.py +1 -1
  30. package/bundled-skills/loki-mode/examples/todo-app-generated/backend/src/index.ts +1 -0
  31. package/bundled-skills/loop-library/SKILL.md +11 -11
  32. package/bundled-skills/mcp-builder/scripts/evaluation.py +6 -2
  33. package/bundled-skills/notebooklm/scripts/run.py +23 -8
  34. package/bundled-skills/playwright-skill/lib/helpers.js +15 -17
  35. package/bundled-skills/pptx-official/ooxml/scripts/validation/base.py +19 -1
  36. package/bundled-skills/pptx-official/ooxml/scripts/validation/docx.py +20 -1
  37. package/bundled-skills/pptx-official/ooxml/scripts/validation/redlining.py +21 -5
  38. package/bundled-skills/remote-gpu-trainer/profiles/runpod.md +2 -2
  39. package/bundled-skills/senior-frontend/scripts/component_generator.py +67 -10
  40. package/bundled-skills/shopify-development/scripts/requirements.txt +1 -0
  41. package/bundled-skills/shopify-development/scripts/tests/test_shopify_init.py +13 -9
  42. package/bundled-skills/skill-installer/scripts/install_skill.py +73 -34
  43. package/bundled-skills/skill-installer/scripts/package_skill.py +36 -8
  44. package/bundled-skills/skill-installer/scripts/validate_skill.py +22 -7
  45. package/bundled-skills/skill-sentinel/scripts/db.py +69 -15
  46. package/bundled-skills/slack-gif-creator/requirements.txt +3 -2
  47. package/bundled-skills/stability-ai/scripts/requirements.txt +1 -1
  48. package/bundled-skills/telegram/assets/boilerplate/nodejs/src/bot-client.ts +1 -0
  49. package/bundled-skills/telegram/assets/boilerplate/python/requirements.txt +3 -1
  50. package/bundled-skills/telegram/scripts/send_message.py +39 -9
  51. package/bundled-skills/webapp-testing/scripts/with_server.py +105 -8
  52. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/src/index.ts +1 -0
  53. package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/python/requirements.txt +1 -0
  54. package/bundled-skills/whatsapp-cloud-api/scripts/setup_project.py +31 -3
  55. package/bundled-skills/writing-skills/render-graphs.js +30 -5
  56. package/bundled-skills/youtube-notetaker/reference/artifact.html +29 -18
  57. package/bundled-skills/youtube-notetaker/scripts/serve.py +49 -8
  58. package/package.json +2 -2
  59. package/skills_index.json +27 -3
@@ -19,6 +19,52 @@ import socket
19
19
  import time
20
20
  import sys
21
21
  import argparse
22
+ import shlex
23
+ import shutil
24
+ from pathlib import Path
25
+ from tempfile import TemporaryDirectory
26
+
27
+ ALLOWED_EXECUTABLES = {
28
+ "npm", "npx", "pnpm", "yarn", "node", "python", "python3",
29
+ "uv", "pytest", "vitest", "playwright",
30
+ }
31
+ SHELL_METACHARS = {";", "&&", "||", "|", "`", "$(", ">", "<"}
32
+
33
+
34
+ def safe_working_directory(raw_path):
35
+ root = Path.cwd().resolve()
36
+ path = Path(raw_path).expanduser()
37
+ resolved = (path if path.is_absolute() else root / path).resolve()
38
+ try:
39
+ resolved.relative_to(root)
40
+ except ValueError as exc:
41
+ raise ValueError(f"working directory escapes current project: {raw_path}") from exc
42
+ if not resolved.is_dir():
43
+ raise ValueError(f"working directory not found: {resolved}")
44
+ return resolved
45
+
46
+
47
+ def resolve_allowed_executable(executable):
48
+ if Path(executable).name != executable:
49
+ raise ValueError(f"executable must be a bare command name: {executable}")
50
+ if executable not in ALLOWED_EXECUTABLES:
51
+ raise ValueError(f"unsupported executable: {executable}")
52
+ resolved = shutil.which(executable)
53
+ if not resolved:
54
+ raise ValueError(f"executable not found on PATH: {executable}")
55
+ return resolved
56
+
57
+
58
+ def validate_argv(parts):
59
+ if not parts:
60
+ raise ValueError("empty command")
61
+ exe = Path(parts[0]).name
62
+ resolved_exe = resolve_allowed_executable(exe)
63
+ for part in parts:
64
+ if any(token in part for token in SHELL_METACHARS):
65
+ raise ValueError(f"unsupported shell metacharacter in argument: {part}")
66
+ return [resolved_exe, *parts[1:]]
67
+
22
68
 
23
69
  def is_server_ready(port, timeout=30):
24
70
  """Wait for server to be ready by polling the port."""
@@ -32,14 +78,64 @@ def is_server_ready(port, timeout=30):
32
78
  return False
33
79
 
34
80
 
81
+ def parse_server_command(command):
82
+ """Parse a server command without invoking a shell."""
83
+ parts = shlex.split(command)
84
+ cwd = None
85
+ if len(parts) >= 4 and parts[0] == "cd" and parts[2] == "&&":
86
+ cwd = safe_working_directory(parts[1])
87
+ parts = parts[3:]
88
+ if not parts:
89
+ raise ValueError("empty server command")
90
+ return validate_argv(parts), cwd
91
+
92
+
93
+ def self_test():
94
+ npm_path = shutil.which("npm")
95
+ python_path = shutil.which("python") or shutil.which("python3")
96
+ assert npm_path, "npm required for self-test"
97
+ assert python_path, "python required for self-test"
98
+ with TemporaryDirectory() as tmp:
99
+ previous_cwd = Path.cwd()
100
+ try:
101
+ import os
102
+ os.chdir(tmp)
103
+ assert parse_server_command("npm run dev") == ([npm_path, "run", "dev"], None)
104
+ Path("backend").mkdir()
105
+ cmd, cwd = parse_server_command("cd backend && python server.py")
106
+ assert cmd == [python_path, "server.py"]
107
+ assert cwd == (Path(tmp) / "backend").resolve()
108
+ try:
109
+ validate_argv(["sh", "-c", "npm run dev"])
110
+ except ValueError:
111
+ pass
112
+ else:
113
+ raise AssertionError("shell launcher should be rejected")
114
+ try:
115
+ parse_server_command("cd ../outside && python server.py")
116
+ except ValueError:
117
+ pass
118
+ else:
119
+ raise AssertionError("escaping working directory should be rejected")
120
+ finally:
121
+ os.chdir(previous_cwd)
122
+
123
+
35
124
  def main():
36
125
  parser = argparse.ArgumentParser(description='Run command with one or more servers')
37
- parser.add_argument('--server', action='append', dest='servers', required=True, help='Server command (can be repeated)')
38
- parser.add_argument('--port', action='append', dest='ports', type=int, required=True, help='Port for each server (must match --server count)')
126
+ parser.add_argument('--self-test', action='store_true', help='Run parser self-test and exit')
127
+ parser.add_argument('--server', action='append', dest='servers', help='Server command (can be repeated)')
128
+ parser.add_argument('--port', action='append', dest='ports', type=int, help='Port for each server (must match --server count)')
39
129
  parser.add_argument('--timeout', type=int, default=30, help='Timeout in seconds per server (default: 30)')
40
130
  parser.add_argument('command', nargs=argparse.REMAINDER, help='Command to run after server(s) ready')
41
131
 
42
132
  args = parser.parse_args()
133
+ if args.self_test:
134
+ self_test()
135
+ return
136
+ if not args.servers or not args.ports:
137
+ print("Error: --server and --port are required")
138
+ sys.exit(1)
43
139
 
44
140
  # Remove the '--' separator if present
45
141
  if args.command and args.command[0] == '--':
@@ -65,10 +161,10 @@ def main():
65
161
  for i, server in enumerate(servers):
66
162
  print(f"Starting server {i+1}/{len(servers)}: {server['cmd']}")
67
163
 
68
- # Use shell=True to support commands with cd and &&
164
+ server_cmd, server_cwd = parse_server_command(server['cmd'])
69
165
  process = subprocess.Popen(
70
- server['cmd'],
71
- shell=True,
166
+ server_cmd,
167
+ cwd=server_cwd,
72
168
  stdout=subprocess.PIPE,
73
169
  stderr=subprocess.PIPE
74
170
  )
@@ -84,8 +180,9 @@ def main():
84
180
  print(f"\nAll {len(servers)} server(s) ready")
85
181
 
86
182
  # Run the command
87
- print(f"Running: {' '.join(args.command)}\n")
88
- result = subprocess.run(args.command)
183
+ test_command = validate_argv(args.command)
184
+ print(f"Running: {' '.join(test_command)}\n")
185
+ result = subprocess.run(test_command)
89
186
  sys.exit(result.returncode)
90
187
 
91
188
  finally:
@@ -103,4 +200,4 @@ def main():
103
200
 
104
201
 
105
202
  if __name__ == '__main__':
106
- main()
203
+ main()
@@ -29,6 +29,7 @@ const templates = new TemplateManager(config);
29
29
  // === Express Setup ===
30
30
 
31
31
  const app = express();
32
+ app.disable('x-powered-by');
32
33
  const PORT = process.env.PORT || 3000;
33
34
 
34
35
  // Raw body capture MUST come before express.json()
@@ -2,3 +2,4 @@ flask>=3.0.0
2
2
  httpx>=0.27.0
3
3
  python-dotenv>=1.0.0
4
4
  gunicorn>=22.0.0
5
+ zipp>=3.19.1
@@ -18,6 +18,24 @@ def get_skill_dir() -> str:
18
18
  return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
19
 
20
20
 
21
+ def _safe_target_path(path: str, skill_dir: str) -> str:
22
+ target_path = os.path.abspath(path)
23
+ skill_root = os.path.abspath(skill_dir)
24
+ if os.path.commonpath([target_path, skill_root]) == skill_root:
25
+ raise ValueError("Refusing to create a project inside the skill source directory")
26
+ return target_path
27
+
28
+
29
+ def self_test() -> None:
30
+ skill_dir = get_skill_dir()
31
+ _safe_target_path(os.path.join(os.path.dirname(skill_dir), "my-whatsapp-project"), skill_dir)
32
+ try:
33
+ _safe_target_path(os.path.join(skill_dir, "assets", "x"), skill_dir)
34
+ except ValueError:
35
+ return
36
+ raise AssertionError("accepted target inside skill source directory")
37
+
38
+
21
39
  def setup_project(language: str, path: str, name: str | None = None) -> None:
22
40
  """Copy boilerplate and configure a new WhatsApp project."""
23
41
  skill_dir = get_skill_dir()
@@ -28,7 +46,7 @@ def setup_project(language: str, path: str, name: str | None = None) -> None:
28
46
  print(f"Available: nodejs, python")
29
47
  sys.exit(1)
30
48
 
31
- target_path = os.path.abspath(path)
49
+ target_path = _safe_target_path(path, skill_dir)
32
50
 
33
51
  if os.path.exists(target_path) and os.listdir(target_path):
34
52
  print(f"Warning: Directory '{target_path}' already exists and is not empty.")
@@ -93,15 +111,20 @@ def setup_project(language: str, path: str, name: str | None = None) -> None:
93
111
 
94
112
  def main():
95
113
  parser = argparse.ArgumentParser(description="Setup a new WhatsApp Cloud API project")
114
+ parser.add_argument(
115
+ "--self-test",
116
+ action="store_true",
117
+ help="Run safety self-checks",
118
+ )
96
119
  parser.add_argument(
97
120
  "--language",
98
121
  choices=["nodejs", "python"],
99
- required=True,
122
+ required=False,
100
123
  help="Project language (nodejs or python)",
101
124
  )
102
125
  parser.add_argument(
103
126
  "--path",
104
- required=True,
127
+ required=False,
105
128
  help="Path where the project will be created",
106
129
  )
107
130
  parser.add_argument(
@@ -111,6 +134,11 @@ def main():
111
134
  )
112
135
 
113
136
  args = parser.parse_args()
137
+ if args.self_test:
138
+ self_test()
139
+ return
140
+ if not args.language or not args.path:
141
+ parser.error("--language and --path are required unless --self-test is used")
114
142
  setup_project(args.language, args.path, args.name)
115
143
 
116
144
 
@@ -17,6 +17,27 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
  const { execSync } = require('child_process');
19
19
 
20
+ function safeJoin(base, ...parts) {
21
+ const root = path.resolve(base);
22
+ const target = path.resolve(root, ...parts);
23
+ const rel = path.relative(root, target);
24
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
25
+ throw new Error(`Path escapes skill directory: ${parts.join('/')}`);
26
+ }
27
+ return target;
28
+ }
29
+
30
+ function selfTest() {
31
+ const root = path.resolve('/tmp/skill');
32
+ if (safeJoin(root, 'diagrams', 'a.svg') !== path.resolve(root, 'diagrams', 'a.svg')) {
33
+ throw new Error('safeJoin failed valid path');
34
+ }
35
+ for (const bad of ['../x', 'diagrams/../../x']) {
36
+ try { safeJoin(root, bad); } catch { continue; }
37
+ throw new Error(`safeJoin accepted ${bad}`);
38
+ }
39
+ }
40
+
20
41
  function extractDotBlocks(markdown) {
21
42
  const blocks = [];
22
43
  const regex = /```dot\n([\s\S]*?)```/g;
@@ -83,6 +104,10 @@ function renderToSvg(dotContent) {
83
104
 
84
105
  function main() {
85
106
  const args = process.argv.slice(2);
107
+ if (args.includes('--self-test')) {
108
+ selfTest();
109
+ return;
110
+ }
86
111
  const combine = args.includes('--combine');
87
112
  const skillDirArg = args.find(a => !a.startsWith('--'));
88
113
 
@@ -99,7 +124,7 @@ function main() {
99
124
  }
100
125
 
101
126
  const skillDir = path.resolve(skillDirArg);
102
- const skillFile = path.join(skillDir, 'SKILL.md');
127
+ const skillFile = safeJoin(skillDir, 'SKILL.md');
103
128
  const skillName = path.basename(skillDir).replace(/-/g, '_');
104
129
 
105
130
  if (!fs.existsSync(skillFile)) {
@@ -127,7 +152,7 @@ function main() {
127
152
 
128
153
  console.log(`Found ${blocks.length} diagram(s) in ${path.basename(skillDir)}/SKILL.md`);
129
154
 
130
- const outputDir = path.join(skillDir, 'diagrams');
155
+ const outputDir = safeJoin(skillDir, 'diagrams');
131
156
  if (!fs.existsSync(outputDir)) {
132
157
  fs.mkdirSync(outputDir);
133
158
  }
@@ -137,12 +162,12 @@ function main() {
137
162
  const combined = combineGraphs(blocks, skillName);
138
163
  const svg = renderToSvg(combined);
139
164
  if (svg) {
140
- const outputPath = path.join(outputDir, `${skillName}_combined.svg`);
165
+ const outputPath = safeJoin(outputDir, `${skillName}_combined.svg`);
141
166
  fs.writeFileSync(outputPath, svg);
142
167
  console.log(` Rendered: ${skillName}_combined.svg`);
143
168
 
144
169
  // Also write the dot source for debugging
145
- const dotPath = path.join(outputDir, `${skillName}_combined.dot`);
170
+ const dotPath = safeJoin(outputDir, `${skillName}_combined.dot`);
146
171
  fs.writeFileSync(dotPath, combined);
147
172
  console.log(` Source: ${skillName}_combined.dot`);
148
173
  } else {
@@ -153,7 +178,7 @@ function main() {
153
178
  for (const block of blocks) {
154
179
  const svg = renderToSvg(block.content);
155
180
  if (svg) {
156
- const outputPath = path.join(outputDir, `${block.name}.svg`);
181
+ const outputPath = safeJoin(outputDir, `${block.name}.svg`);
157
182
  fs.writeFileSync(outputPath, svg);
158
183
  console.log(` Rendered: ${block.name}.svg`);
159
184
  } else {
@@ -126,6 +126,9 @@ var CURRENT_ID=null, YTID=null, INDEX=null;
126
126
  function onYouTubeIframeAPIReady(){player=new YT.Player('ytplayer',{events:{'onReady':function(){ready=true;if(pending!=null){doPlay(pending);pending=null;}}}});}
127
127
  function fmt(t){t=Math.floor(t);return String(Math.floor(t/60)).padStart(2,'0')+':'+String(t%60).padStart(2,'0');}
128
128
  function esc(s){var d=document.createElement('div');d.textContent=s==null?'':s;return d.innerHTML;}
129
+ function clear(el){while(el.firstChild)el.removeChild(el.firstChild);}
130
+ function node(tag,cls,text){var el=document.createElement(tag);if(cls)el.className=cls;if(text!=null)el.textContent=text;return el;}
131
+ function safeUrl(url,fallback){try{var u=new URL(url||'',window.location.href);return /^https?:$/.test(u.protocol)?u.href:(fallback||'#');}catch(e){return fallback||'#';}}
129
132
 
130
133
  /* ---------------- Router ---------------- */
131
134
  function route(){
@@ -154,16 +157,19 @@ async function loadIndex(){
154
157
  var r=await fetch(API_URL); if(!r.ok) throw new Error('HTTP '+r.status);
155
158
  var d=await r.json();
156
159
  INDEX=(d.items||[]).filter(function(it){return it.youtube_id;});
157
- var g=document.getElementById('grid'); g.innerHTML='';
158
- if(!INDEX.length){g.innerHTML='<p style="color:#7a6f5d">No videos in the library yet.</p>';return;}
160
+ var g=document.getElementById('grid'); clear(g);
161
+ if(!INDEX.length){var empty=node('p','', 'No videos in the library yet.');empty.style.color='#7a6f5d';g.appendChild(empty);return;}
159
162
  INDEX.forEach(function(it){
160
163
  var slides=it.slides||[]; var thumb=(slides[0]&&slides[0].img)||'';
161
- var tags=(it.tags||[]).slice(0,3).map(function(t){return '<span class="tag">'+esc(t)+'</span>';}).join('');
162
164
  var a=document.createElement('a'); a.className='card'; a.href='#/'+encodeURIComponent(it.id);
163
- a.innerHTML='<div class="thumb">'+(thumb?'<img src="'+esc(thumb)+'" alt="">':'')+'<span class="play">▶</span><span class="badge">'+(it.slide_count||slides.length)+' slides</span></div>'
164
- +'<div class="body"><div class="ct">'+esc(it.title||it.id)+'</div>'
165
- +'<div class="cs">'+esc(it.speaker||'')+'</div>'
166
- +(tags?'<div class="tags">'+tags+'</div>':'')+'</div>';
165
+ var thumbBox=node('div','thumb');
166
+ if(thumb){var img=document.createElement('img');img.src=safeUrl(thumb,'');img.alt='';thumbBox.appendChild(img);}
167
+ thumbBox.appendChild(node('span','play',''));
168
+ thumbBox.appendChild(node('span','badge',(it.slide_count||slides.length)+' slides'));
169
+ var body=node('div','body');body.appendChild(node('div','ct',it.title||it.id));body.appendChild(node('div','cs',it.speaker||''));
170
+ var tagList=(it.tags||[]).slice(0,3);
171
+ if(tagList.length){var tags=node('div','tags');tagList.forEach(function(t){tags.appendChild(node('span','tag',t));});body.appendChild(tags);}
172
+ a.appendChild(thumbBox);a.appendChild(body);
167
173
  g.appendChild(a);
168
174
  });
169
175
  }catch(e){var el=document.getElementById('homeErr');el.style.display='block';el.textContent='Could not load the video library: '+e.message+'. Is the backend running?';}
@@ -183,7 +189,8 @@ async function loadVideo(id){
183
189
  SLIDES=(m.slides||[]).slice().sort(function(a,b){return a.t-b.t;});
184
190
  document.title=m.title||'Video deep-dive';
185
191
  document.getElementById('title').textContent=m.title||'';
186
- document.getElementById('speaker').innerHTML=esc(m.speaker||'')+' · <a target="_blank" href="'+(m.source_url||'#')+'">watch on YouTube ↗</a>';
192
+ var speaker=document.getElementById('speaker');clear(speaker);speaker.appendChild(document.createTextNode((m.speaker||'')+' · '));
193
+ var watch=document.createElement('a');watch.target='_blank';watch.rel='noopener noreferrer';watch.href=safeUrl(m.source_url,'#');watch.textContent='watch on YouTube ↗';speaker.appendChild(watch);
187
194
  document.getElementById('deckcount').textContent=SLIDES.length+' slides · drag the divider ⋮⋮ to resize';
188
195
  document.getElementById('now-t').textContent='--:--';
189
196
  document.getElementById('now-tx').textContent='Click any slide to play the video from that point.';
@@ -198,25 +205,29 @@ function parseTranscript(body){
198
205
  return out;
199
206
  }
200
207
  function renderDeck(){
201
- var deck=document.getElementById('deck');deck.innerHTML='';
208
+ var deck=document.getElementById('deck');clear(deck);
202
209
  SLIDES.forEach(function(s,i){
203
210
  var d=document.createElement('div');d.className='slide';d.id='slide-'+i;d.dataset.t=s.t;
204
- d.innerHTML='<div class="slide-img"><img src="'+esc(s.img)+'" alt="'+esc(s.title)+'"><span class="play-badge">▶</span><span class="slide-t">'+esc(s.mmss||fmt(s.t))+'</span></div>'
205
- +'<div class="slide-meta"><h3>'+esc(s.title)+'</h3><button class="btn">▶ Play '+esc(s.mmss||fmt(s.t))+'</button></div>'
206
- +'<label class="note-lbl">Notes <span class="saved" id="saved-'+i+'"></span></label>'
207
- +'<textarea class="note-area" id="note-'+i+'"></textarea>';
211
+ var imgBox=node('div','slide-img');
212
+ var img=document.createElement('img');img.src=safeUrl(s.img,'');img.alt=s.title||'';imgBox.appendChild(img);
213
+ imgBox.appendChild(node('span','play-badge','▶'));imgBox.appendChild(node('span','slide-t',s.mmss||fmt(s.t)));
214
+ var meta=node('div','slide-meta');meta.appendChild(node('h3','',s.title||''));
215
+ var playBtn=node('button','btn','▶ Play '+(s.mmss||fmt(s.t)));meta.appendChild(playBtn);
216
+ var label=node('label','note-lbl','Notes ');var saved=node('span','saved');saved.id='saved-'+i;label.appendChild(saved);
217
+ var note=document.createElement('textarea');note.className='note-area';note.id='note-'+i;
218
+ d.appendChild(imgBox);d.appendChild(meta);d.appendChild(label);d.appendChild(note);
208
219
  deck.appendChild(d);
209
- d.querySelector('textarea').value=s.note||'';
220
+ note.value=s.note||'';
210
221
  d.querySelector('.slide-img').onclick=function(){play(i);};
211
- d.querySelector('.btn').onclick=function(){play(i);};
212
- d.querySelector('textarea').addEventListener('input',function(){onNote(i,this.value);});
222
+ playBtn.onclick=function(){play(i);};
223
+ note.addEventListener('input',function(){onNote(i,this.value);});
213
224
  });
214
225
  }
215
226
  function renderTranscript(){
216
- var c=document.getElementById('transcript');c.innerHTML='';
227
+ var c=document.getElementById('transcript');clear(c);
217
228
  SEGS.forEach(function(seg){
218
229
  var r=document.createElement('div');r.className='trow';r.dataset.t=seg.t;r.dataset.text=seg.text.toLowerCase();
219
- r.innerHTML='<span class="tt">'+fmt(seg.t)+'</span><span class="tx">'+esc(seg.text)+'</span>';
230
+ r.appendChild(node('span','tt',fmt(seg.t)));r.appendChild(node('span','tx',seg.text));
220
231
  r.onclick=function(){seekOnly(seg.t);};c.appendChild(r);
221
232
  });
222
233
  }
@@ -33,7 +33,12 @@ API = "/api/video-deepdives"
33
33
  FM_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)$", re.DOTALL)
34
34
  SAFE_SLUG_RE = re.compile(r"^[A-Za-z0-9_-]+$")
35
35
  SAFE_MEDIA_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
36
+ SAFE_PATH_PART_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
36
37
  SAFE_CTYPE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*(?:; charset=[A-Za-z0-9._-]+)?$")
38
+ LOCAL_ORIGINS = {
39
+ "http://127.0.0.1:8000": "http://127.0.0.1:8000",
40
+ "http://localhost:8000": "http://localhost:8000",
41
+ }
37
42
 
38
43
 
39
44
  def split_frontmatter(text):
@@ -52,7 +57,13 @@ def dump_file(meta, body):
52
57
 
53
58
  def library_path(lib, *parts):
54
59
  root = Path(lib).resolve()
55
- candidate = root.joinpath(*parts).resolve()
60
+ candidate = root
61
+ for part in parts:
62
+ value = str(part)
63
+ if not SAFE_PATH_PART_RE.fullmatch(value) or value in {".", ".."}:
64
+ return None
65
+ candidate = candidate / value
66
+ candidate = candidate.resolve()
56
67
  try:
57
68
  candidate.relative_to(root)
58
69
  except ValueError:
@@ -60,14 +71,38 @@ def library_path(lib, *parts):
60
71
  return candidate
61
72
 
62
73
 
74
+ def media_path(lib, filename):
75
+ if not SAFE_MEDIA_RE.fullmatch(filename or ""):
76
+ return None
77
+ media_dir = library_path(lib, "_media")
78
+ if not media_dir or not media_dir.is_dir():
79
+ return None
80
+ for path in media_dir.iterdir():
81
+ if path.is_file() and path.name == filename:
82
+ return path
83
+ return None
84
+
85
+
86
+ def item_path(lib, slug):
87
+ if not SAFE_SLUG_RE.fullmatch(slug or ""):
88
+ return None
89
+ target = slug + ".md"
90
+ for path in Path(lib).resolve().iterdir():
91
+ if path.is_file() and path.name == target:
92
+ return path
93
+ return None
94
+
95
+
63
96
  def safe_content_type(ctype):
64
97
  return ctype if isinstance(ctype, str) and SAFE_CTYPE_RE.match(ctype) else "application/octet-stream"
65
98
 
66
99
 
100
+ def safe_local_origin(origin):
101
+ return LOCAL_ORIGINS.get(origin or "")
102
+
103
+
67
104
  def load_item(lib, slug):
68
- if not SAFE_SLUG_RE.match(slug):
69
- return None
70
- path = library_path(lib, slug + ".md")
105
+ path = item_path(lib, slug)
71
106
  if not path or not path.is_file():
72
107
  return None
73
108
  meta, body = split_frontmatter(path.read_text(encoding="utf-8"))
@@ -110,9 +145,12 @@ class Handler(BaseHTTPRequestHandler):
110
145
  self.send_response(code)
111
146
  self.send_header("Content-Type", ctype)
112
147
  self.send_header("Content-Length", str(len(body)))
113
- self.send_header("Access-Control-Allow-Origin", "*")
114
148
  self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
115
149
  self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Video-Library-Token")
150
+ origin = safe_local_origin(self.headers.get("Origin"))
151
+ if origin:
152
+ self.send_header("Access-Control-Allow-Origin", origin)
153
+ self.send_header("Vary", "Origin")
116
154
  self.end_headers()
117
155
  if self.command != "HEAD":
118
156
  self.wfile.write(body)
@@ -134,9 +172,9 @@ class Handler(BaseHTTPRequestHandler):
134
172
 
135
173
  if path.startswith(API + "/_media/"):
136
174
  fn = posixpath.basename(path) # strip any traversal
137
- if not SAFE_MEDIA_RE.match(fn):
175
+ if not SAFE_MEDIA_RE.fullmatch(fn):
138
176
  return self._send(400, {"error": "bad media name"})
139
- fp = library_path(self.lib, "_media", fn)
177
+ fp = media_path(self.lib, fn)
140
178
  if not fp or not fp.is_file():
141
179
  return self._send(404, {"error": "no such media"})
142
180
  ctype = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
@@ -186,9 +224,12 @@ def self_test():
186
224
  (root / "_media" / "video_1-slide-01.jpg").write_bytes(b"x")
187
225
  assert load_item(str(root), "video_1")
188
226
  assert load_item(str(root), "../secret") is None
189
- assert library_path(str(root), "_media", "../video_1.md") == root.resolve() / "video_1.md"
227
+ assert library_path(str(root), "_media", "../video_1.md") is None
190
228
  assert safe_content_type("text/html; charset=utf-8") == "text/html; charset=utf-8"
191
229
  assert safe_content_type("text/html\r\nX-Bad: 1") == "application/octet-stream"
230
+ assert safe_local_origin("http://localhost:8000") == LOCAL_ORIGINS["http://localhost:8000"]
231
+ assert safe_local_origin("http://localhost:3000") is None
232
+ assert safe_local_origin("http://localhost:8000\r\nX-Bad: 1") is None
192
233
 
193
234
 
194
235
  def main():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-collection",
3
- "version": "3.1.4",
3
+ "version": "3.1.6",
4
4
  "description": "OpenCode CLI plugin that automatically downloads and keeps skills up to date.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -40,7 +40,7 @@
40
40
  "devDependencies": {
41
41
  "@opencode-ai/sdk": "^1.15.5",
42
42
  "@types/bun": "*",
43
- "@types/node": "^25.9.1",
43
+ "@types/node": "^26.0.0",
44
44
  "@types/strip-json-comments": "^3.0.0",
45
45
  "typescript": "^6.0.2"
46
46
  }
package/skills_index.json CHANGED
@@ -562,15 +562,17 @@
562
562
  "date_added": "2026-06-20",
563
563
  "plugin": {
564
564
  "targets": {
565
- "codex": "supported",
566
- "claude": "supported"
565
+ "codex": "blocked",
566
+ "claude": "blocked"
567
567
  },
568
568
  "setup": {
569
569
  "type": "none",
570
570
  "summary": "",
571
571
  "docs": null
572
572
  },
573
- "reasons": []
573
+ "reasons": [
574
+ "explicit_target_restriction"
575
+ ]
574
576
  }
575
577
  },
576
578
  {
@@ -18200,6 +18202,28 @@
18200
18202
  "reasons": []
18201
18203
  }
18202
18204
  },
18205
+ {
18206
+ "id": "infinity",
18207
+ "path": "skills/infinity",
18208
+ "category": "uncategorized",
18209
+ "name": "infinity",
18210
+ "description": "Enforces a strict input boundary protocol (detect, classify, filter, verify) to ensure untrusted data never reaches business logic raw.",
18211
+ "risk": "safe",
18212
+ "source": "community",
18213
+ "date_added": "2026-06-23",
18214
+ "plugin": {
18215
+ "targets": {
18216
+ "codex": "supported",
18217
+ "claude": "supported"
18218
+ },
18219
+ "setup": {
18220
+ "type": "none",
18221
+ "summary": "",
18222
+ "docs": null
18223
+ },
18224
+ "reasons": []
18225
+ }
18226
+ },
18203
18227
  {
18204
18228
  "id": "ingest-youtube",
18205
18229
  "path": "skills/ingest-youtube",