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.
- package/bundled-skills/.antigravity-install-manifest.json +1 -1
- package/bundled-skills/007/scripts/full_audit.py +10 -3
- package/bundled-skills/2slides-ppt-generator/requirements.txt +3 -1
- package/bundled-skills/2slides-ppt-generator/scripts/download_slides_pages_voices.py +24 -0
- package/bundled-skills/2slides-ppt-generator/scripts/get_job_status.py +18 -1
- package/bundled-skills/agent-creator/SKILL.md +15 -1
- package/bundled-skills/agent-orchestrator/scripts/scan_registry.py +4 -4
- package/bundled-skills/android-dev/references/hybrid.md +3 -3
- package/bundled-skills/android-dev/references/react-native.md +14 -8
- package/bundled-skills/competitor-analysis/scripts/compile_report.mjs +82 -20
- package/bundled-skills/diary/requirements.txt +3 -1
- package/bundled-skills/docs/users/getting-started.md +1 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/base.py +19 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/docx.py +20 -1
- package/bundled-skills/docx-official/ooxml/scripts/validation/redlining.py +21 -5
- package/bundled-skills/ecl-harness-engineer/references/environment-detection-guide.md +1 -1
- package/bundled-skills/hugging-face-model-trainer/scripts/convert_to_gguf.py +44 -23
- package/bundled-skills/instagram/scripts/db.py +120 -18
- package/bundled-skills/instagram/scripts/export.py +41 -8
- package/bundled-skills/instagram/scripts/publish.py +7 -7
- package/bundled-skills/instagram/scripts/run_all.py +2 -2
- package/bundled-skills/instagram/scripts/schedule.py +6 -5
- package/bundled-skills/instagram/static/dashboard.html +63 -16
- package/bundled-skills/junta-leiloeiros/scripts/requirements.txt +1 -1
- package/bundled-skills/k8s-manifest-generator/assets/deployment-template.yaml +20 -8
- package/bundled-skills/k8s-manifest-generator/assets/service-template.yaml +2 -3
- package/bundled-skills/last30days/scripts/lib/reddit_enrich.py +3 -1
- package/bundled-skills/loki-mode/benchmarks/results/2026-01-05-00-49-17/humaneval-solutions/162.py +1 -1
- package/bundled-skills/loki-mode/benchmarks/results/humaneval-loki-solutions/162.py +1 -1
- package/bundled-skills/loki-mode/examples/todo-app-generated/backend/src/index.ts +1 -0
- package/bundled-skills/loop-library/SKILL.md +11 -11
- package/bundled-skills/mcp-builder/scripts/evaluation.py +6 -2
- package/bundled-skills/notebooklm/scripts/run.py +23 -8
- package/bundled-skills/playwright-skill/lib/helpers.js +15 -17
- package/bundled-skills/pptx-official/ooxml/scripts/validation/base.py +19 -1
- package/bundled-skills/pptx-official/ooxml/scripts/validation/docx.py +20 -1
- package/bundled-skills/pptx-official/ooxml/scripts/validation/redlining.py +21 -5
- package/bundled-skills/remote-gpu-trainer/profiles/runpod.md +2 -2
- package/bundled-skills/senior-frontend/scripts/component_generator.py +67 -10
- package/bundled-skills/shopify-development/scripts/requirements.txt +1 -0
- package/bundled-skills/shopify-development/scripts/tests/test_shopify_init.py +13 -9
- package/bundled-skills/skill-installer/scripts/install_skill.py +73 -34
- package/bundled-skills/skill-installer/scripts/package_skill.py +36 -8
- package/bundled-skills/skill-installer/scripts/validate_skill.py +22 -7
- package/bundled-skills/skill-sentinel/scripts/db.py +69 -15
- package/bundled-skills/slack-gif-creator/requirements.txt +3 -2
- package/bundled-skills/stability-ai/scripts/requirements.txt +1 -1
- package/bundled-skills/telegram/assets/boilerplate/nodejs/src/bot-client.ts +1 -0
- package/bundled-skills/telegram/assets/boilerplate/python/requirements.txt +3 -1
- package/bundled-skills/telegram/scripts/send_message.py +39 -9
- package/bundled-skills/webapp-testing/scripts/with_server.py +105 -8
- package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/nodejs/src/index.ts +1 -0
- package/bundled-skills/whatsapp-cloud-api/assets/boilerplate/python/requirements.txt +1 -0
- package/bundled-skills/whatsapp-cloud-api/scripts/setup_project.py +31 -3
- package/bundled-skills/writing-skills/render-graphs.js +30 -5
- package/bundled-skills/youtube-notetaker/reference/artifact.html +29 -18
- package/bundled-skills/youtube-notetaker/scripts/serve.py +49 -8
- package/package.json +2 -2
- package/skills_index.json +27 -3
|
@@ -853,10 +853,17 @@ def _generate_markdown_report(
|
|
|
853
853
|
lines.append("")
|
|
854
854
|
lines.append("| Check | Status | Details | Scanner |")
|
|
855
855
|
lines.append("|-------|--------|---------|---------|")
|
|
856
|
+
def format_status(status: str) -> str:
|
|
857
|
+
if status == "PASS":
|
|
858
|
+
return "[PASS]"
|
|
859
|
+
if status == "WARN":
|
|
860
|
+
return "[WARN]"
|
|
861
|
+
if status == "FAIL":
|
|
862
|
+
return "[FAIL]"
|
|
863
|
+
return status
|
|
864
|
+
|
|
856
865
|
for item in p3.get("checklist", []):
|
|
857
|
-
status_icon =
|
|
858
|
-
item["status"], item["status"]
|
|
859
|
-
)
|
|
866
|
+
status_icon = format_status(item["status"])
|
|
860
867
|
lines.append(
|
|
861
868
|
f"| {item['check']} | {status_icon} | {item['details']} | {item['scanner']} |"
|
|
862
869
|
)
|
|
@@ -8,11 +8,33 @@ import os
|
|
|
8
8
|
import sys
|
|
9
9
|
import json
|
|
10
10
|
import argparse
|
|
11
|
+
import ipaddress
|
|
12
|
+
import re
|
|
13
|
+
import socket
|
|
11
14
|
import requests
|
|
15
|
+
from urllib.parse import urlparse
|
|
12
16
|
from typing import Optional, Dict, Any
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
API_BASE_URL = "https://2slides.com/api/v1"
|
|
20
|
+
JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_job_id(job_id: str) -> str:
|
|
24
|
+
if not JOB_ID_RE.match(job_id or ""):
|
|
25
|
+
raise ValueError("Job ID contains unsupported characters")
|
|
26
|
+
return job_id
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_public_https_url(url: str) -> str:
|
|
30
|
+
parsed = urlparse(url)
|
|
31
|
+
if parsed.scheme != "https" or not parsed.hostname:
|
|
32
|
+
raise ValueError("Download URL must be HTTPS")
|
|
33
|
+
for info in socket.getaddrinfo(parsed.hostname, None):
|
|
34
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
35
|
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
|
36
|
+
raise ValueError("Download URL resolves to a non-public address")
|
|
37
|
+
return url
|
|
16
38
|
|
|
17
39
|
|
|
18
40
|
def get_api_key() -> str:
|
|
@@ -51,6 +73,7 @@ def download_slides_pages_voices(
|
|
|
51
73
|
"""
|
|
52
74
|
if api_key is None:
|
|
53
75
|
api_key = get_api_key()
|
|
76
|
+
job_id = validate_job_id(job_id)
|
|
54
77
|
|
|
55
78
|
headers = {
|
|
56
79
|
"Authorization": f"Bearer {api_key}",
|
|
@@ -83,6 +106,7 @@ def download_slides_pages_voices(
|
|
|
83
106
|
download_url = data.get("downloadUrl")
|
|
84
107
|
if not download_url:
|
|
85
108
|
raise ValueError("No download URL in response")
|
|
109
|
+
download_url = validate_public_https_url(download_url)
|
|
86
110
|
|
|
87
111
|
# Optional: log additional info
|
|
88
112
|
file_name = data.get("fileName", "unknown.zip")
|
|
@@ -7,11 +7,27 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
import json
|
|
9
9
|
import argparse
|
|
10
|
+
import re
|
|
10
11
|
import requests
|
|
12
|
+
from urllib.parse import urlparse
|
|
11
13
|
from typing import Optional, Dict, Any
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
API_BASE_URL = "https://2slides.com/api/v1"
|
|
17
|
+
JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_job_id(job_id: str) -> str:
|
|
21
|
+
if not JOB_ID_RE.match(job_id or ""):
|
|
22
|
+
raise ValueError("Job ID contains unsupported characters")
|
|
23
|
+
return job_id
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_api_url(url: str) -> str:
|
|
27
|
+
parsed = urlparse(url)
|
|
28
|
+
if parsed.scheme != "https" or parsed.hostname != "2slides.com" or not parsed.path.startswith("/api/v1/jobs/"):
|
|
29
|
+
raise ValueError("Refusing unsafe 2slides API URL")
|
|
30
|
+
return url
|
|
15
31
|
|
|
16
32
|
|
|
17
33
|
def get_api_key() -> str:
|
|
@@ -41,13 +57,14 @@ def get_job_status(
|
|
|
41
57
|
"""
|
|
42
58
|
if api_key is None:
|
|
43
59
|
api_key = get_api_key()
|
|
60
|
+
job_id = validate_job_id(job_id)
|
|
44
61
|
|
|
45
62
|
headers = {
|
|
46
63
|
"Authorization": f"Bearer {api_key}",
|
|
47
64
|
"Content-Type": "application/json"
|
|
48
65
|
}
|
|
49
66
|
|
|
50
|
-
url = f"{API_BASE_URL}/jobs/{job_id}"
|
|
67
|
+
url = validate_api_url(f"{API_BASE_URL}/jobs/{job_id}")
|
|
51
68
|
|
|
52
69
|
print(f"Checking job status: {job_id}...", file=sys.stderr)
|
|
53
70
|
response = requests.get(url, headers=headers)
|
|
@@ -4,6 +4,10 @@ description: "Create custom AI subagents with proper plugin structure, persona g
|
|
|
4
4
|
risk: critical
|
|
5
5
|
source: community
|
|
6
6
|
date_added: "2026-06-20"
|
|
7
|
+
plugin:
|
|
8
|
+
targets:
|
|
9
|
+
codex: blocked
|
|
10
|
+
claude: blocked
|
|
7
11
|
---
|
|
8
12
|
|
|
9
13
|
# Agent Creator
|
|
@@ -37,6 +41,13 @@ If the user wants the agent inside an **existing plugin**, add the agent folder
|
|
|
37
41
|
to that plugin's `agents/` directory. If no plugin is specified, create a new
|
|
38
42
|
plugin named `<agent-name>-plugin`.
|
|
39
43
|
|
|
44
|
+
Before creating any path, validate both `<agent-name>` and `<plugin-name>`:
|
|
45
|
+
|
|
46
|
+
- accept only lowercase letters, numbers, and single hyphens: `^[a-z0-9]+(-[a-z0-9]+)*$`
|
|
47
|
+
- reject `/`, `\`, `.`, `..`, absolute paths, whitespace, shell metacharacters, and YAML metacharacters
|
|
48
|
+
- resolve the final target path and verify it stays under `<appDataDir>\config\plugins\`
|
|
49
|
+
- stop and ask for a safe replacement instead of sanitizing a suspicious name silently
|
|
50
|
+
|
|
40
51
|
## Workflow
|
|
41
52
|
|
|
42
53
|
Follow these steps in order. Do NOT skip the interview — even a one-line
|
|
@@ -124,7 +135,7 @@ Write the `<agent-name>.md` file in the `agents/` folder following this exact st
|
|
|
124
135
|
---
|
|
125
136
|
name: <agent-name>
|
|
126
137
|
description: <One-line summary of what this agent does.>
|
|
127
|
-
tools: ["Read", "Grep", "Glob"
|
|
138
|
+
tools: ["Read", "Grep", "Glob"]
|
|
128
139
|
model: <current-model>
|
|
129
140
|
---
|
|
130
141
|
|
|
@@ -160,6 +171,9 @@ model: <current-model>
|
|
|
160
171
|
<A checklist the agent should mentally run through before returning its response, to ensure quality.>
|
|
161
172
|
```
|
|
162
173
|
|
|
174
|
+
Grant `Bash` only when the user explicitly asks for command execution and the
|
|
175
|
+
agent's task genuinely needs it. Keep the default tool set read-only.
|
|
176
|
+
|
|
163
177
|
### Step 6: Write the companion routing skill (if requested)
|
|
164
178
|
|
|
165
179
|
Create a `SKILL.md` inside `skills/use-<agent-name>/` that tells the main
|
|
@@ -132,9 +132,9 @@ CAPABILITY_MAP = {
|
|
|
132
132
|
|
|
133
133
|
# ── Utility Functions ──────────────────────────────────────────────────────
|
|
134
134
|
|
|
135
|
-
def
|
|
136
|
-
"""Compute
|
|
137
|
-
h = hashlib.
|
|
135
|
+
def sha256_file(path: Path) -> str:
|
|
136
|
+
"""Compute SHA-256 hash of a file."""
|
|
137
|
+
h = hashlib.sha256()
|
|
138
138
|
with open(path, "rb") as f:
|
|
139
139
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
140
140
|
h.update(chunk)
|
|
@@ -382,7 +382,7 @@ def scan(force: bool = False) -> dict:
|
|
|
382
382
|
changed = False
|
|
383
383
|
|
|
384
384
|
for path_str, path_obj in current_paths.items():
|
|
385
|
-
current_hash =
|
|
385
|
+
current_hash = sha256_file(path_obj)
|
|
386
386
|
new_hashes[path_str] = current_hash
|
|
387
387
|
|
|
388
388
|
if force or path_str not in stored_hashes or stored_hashes[path_str] != current_hash:
|
|
@@ -74,7 +74,7 @@ const config: CapacitorConfig = {
|
|
|
74
74
|
|
|
75
75
|
```typescript
|
|
76
76
|
import { Camera, CameraResultType } from '@capacitor/camera';
|
|
77
|
-
import {
|
|
77
|
+
import { SecureStorage } from '@aparajita/capacitor-secure-storage';
|
|
78
78
|
import { PushNotifications } from '@capacitor/push-notifications';
|
|
79
79
|
import { Geolocation } from '@capacitor/geolocation';
|
|
80
80
|
|
|
@@ -107,8 +107,8 @@ const initPush = async () => {
|
|
|
107
107
|
if (permission.receive === 'granted') {
|
|
108
108
|
await PushNotifications.register();
|
|
109
109
|
}
|
|
110
|
-
PushNotifications.addListener('registration', (
|
|
111
|
-
console.log('
|
|
110
|
+
PushNotifications.addListener('registration', () => {
|
|
111
|
+
console.log('Push registration succeeded');
|
|
112
112
|
});
|
|
113
113
|
};
|
|
114
114
|
```
|
|
@@ -67,24 +67,27 @@ export const RootNavigator = () => {
|
|
|
67
67
|
// Store secrets with a platform-backed module such as react-native-keychain
|
|
68
68
|
// or expo-secure-store, and persist only non-sensitive UI state here.
|
|
69
69
|
interface AuthState {
|
|
70
|
-
token: string | null;
|
|
71
70
|
isLoggedIn: boolean;
|
|
72
|
-
|
|
71
|
+
setLoggedIn: (value: boolean) => void;
|
|
73
72
|
logout: () => void;
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
export const useAuthStore = create<AuthState>()(
|
|
77
76
|
persist(
|
|
78
77
|
(set) => ({
|
|
79
|
-
token: null,
|
|
80
78
|
isLoggedIn: false,
|
|
81
|
-
|
|
82
|
-
logout: () => set({
|
|
79
|
+
setLoggedIn: (value) => set({ isLoggedIn: value }),
|
|
80
|
+
logout: () => set({ isLoggedIn: false }),
|
|
83
81
|
}),
|
|
84
82
|
{ name: 'auth-ui-storage', storage: createJSONStorage(() => mmkvStorage) }
|
|
85
83
|
)
|
|
86
84
|
);
|
|
87
85
|
|
|
86
|
+
// Keep tokens outside persisted app state.
|
|
87
|
+
const getSecureToken = () => Keychain.getGenericPassword().then((r) => (r ? r.password : null));
|
|
88
|
+
const saveSecureToken = (token: string) => Keychain.setGenericPassword('auth', token);
|
|
89
|
+
const clearSecureToken = () => Keychain.resetGenericPassword();
|
|
90
|
+
|
|
88
91
|
// Server state — React Query
|
|
89
92
|
export const useItems = () =>
|
|
90
93
|
useQuery({
|
|
@@ -142,8 +145,8 @@ const apiClient = axios.create({
|
|
|
142
145
|
});
|
|
143
146
|
|
|
144
147
|
// Auth token injection
|
|
145
|
-
apiClient.interceptors.request.use((config) => {
|
|
146
|
-
const token =
|
|
148
|
+
apiClient.interceptors.request.use(async (config) => {
|
|
149
|
+
const token = await getSecureToken();
|
|
147
150
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
148
151
|
return config;
|
|
149
152
|
});
|
|
@@ -155,9 +158,11 @@ apiClient.interceptors.response.use(
|
|
|
155
158
|
if (error.response?.status === 401) {
|
|
156
159
|
const newToken = await refreshToken();
|
|
157
160
|
if (newToken) {
|
|
158
|
-
|
|
161
|
+
await saveSecureToken(newToken);
|
|
162
|
+
useAuthStore.getState().setLoggedIn(true);
|
|
159
163
|
return apiClient(error.config!);
|
|
160
164
|
}
|
|
165
|
+
await clearSecureToken();
|
|
161
166
|
useAuthStore.getState().logout();
|
|
162
167
|
}
|
|
163
168
|
return Promise.reject(error);
|
|
@@ -196,6 +201,7 @@ const getItems = async (): Promise<Item[]> => {
|
|
|
196
201
|
"zustand": "^4.5.4",
|
|
197
202
|
"axios": "^1.7.2",
|
|
198
203
|
"zod": "^3.23.8",
|
|
204
|
+
"react-native-keychain": "^8.2.0",
|
|
199
205
|
"react-native-mmkv": "^2.12.2",
|
|
200
206
|
"react-native-safe-area-context": "^4.10.1",
|
|
201
207
|
"react-native-screens": "^3.32.0"
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// Usage: node compile_report.mjs <research-dir> [--user-company "Acme"] [--template <path>] [--open]
|
|
8
8
|
|
|
9
9
|
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
10
|
-
import {
|
|
10
|
+
import { basename, dirname, join, relative, resolve } from 'path';
|
|
11
11
|
import { fileURLToPath } from 'url';
|
|
12
12
|
import { parseFrontmatter, parseBody, parseSections } from './md_utils.mjs';
|
|
13
13
|
|
|
@@ -15,6 +15,68 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
15
15
|
const __dirname = dirname(__filename);
|
|
16
16
|
|
|
17
17
|
const args = process.argv.slice(2);
|
|
18
|
+
const SAFE_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
19
|
+
|
|
20
|
+
function safeJoin(base, ...parts) {
|
|
21
|
+
const root = resolve(base);
|
|
22
|
+
const target = resolve(root, ...parts);
|
|
23
|
+
const rel = relative(root, target);
|
|
24
|
+
if (rel.startsWith('..') || rel.startsWith('/')) {
|
|
25
|
+
throw new Error(`Path escapes research directory: ${parts.join('/')}`);
|
|
26
|
+
}
|
|
27
|
+
return target;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function safeResearchDir(rawDir) {
|
|
31
|
+
if (typeof rawDir !== 'string' || !rawDir.trim() || rawDir.includes('\0')) {
|
|
32
|
+
throw new Error('Research directory is required');
|
|
33
|
+
}
|
|
34
|
+
const root = resolve(process.cwd());
|
|
35
|
+
const target = resolve(root, rawDir);
|
|
36
|
+
const rel = relative(root, target);
|
|
37
|
+
if ((rel.startsWith('..') || rel.startsWith('/')) && process.env.COMPETITOR_ANALYSIS_ALLOW_EXTERNAL_DIR !== '1') {
|
|
38
|
+
throw new Error('Research directory must stay under the current working directory');
|
|
39
|
+
}
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeTemplatePath(researchDir, rawPath) {
|
|
44
|
+
if (typeof rawPath !== 'string' || !rawPath.trim() || rawPath.includes('\0')) {
|
|
45
|
+
throw new Error('Template path is required');
|
|
46
|
+
}
|
|
47
|
+
const candidate = safeJoin(researchDir, rawPath);
|
|
48
|
+
if (!candidate.endsWith('.html')) {
|
|
49
|
+
throw new Error('Template path must point to an .html file inside the research directory');
|
|
50
|
+
}
|
|
51
|
+
return candidate;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeSlug(slug) {
|
|
55
|
+
if (!SAFE_SLUG_RE.test(slug) || slug.includes('..')) {
|
|
56
|
+
throw new Error(`Unsafe competitor slug: ${slug}`);
|
|
57
|
+
}
|
|
58
|
+
return slug;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function selfTest() {
|
|
62
|
+
const root = resolve('/tmp/research');
|
|
63
|
+
if (safeJoin(root, 'competitors', 'acme.html') !== resolve(root, 'competitors', 'acme.html')) {
|
|
64
|
+
throw new Error('safeJoin failed valid path');
|
|
65
|
+
}
|
|
66
|
+
for (const bad of ['../x', 'competitors/../../x']) {
|
|
67
|
+
try { safeJoin(root, bad); } catch { continue; }
|
|
68
|
+
throw new Error(`safeJoin accepted ${bad}`);
|
|
69
|
+
}
|
|
70
|
+
for (const bad of ['../acme', 'bad/name', '..']) {
|
|
71
|
+
try { safeSlug(bad); } catch { continue; }
|
|
72
|
+
throw new Error(`safeSlug accepted ${bad}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (args.includes('--self-test')) {
|
|
77
|
+
selfTest();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
18
80
|
|
|
19
81
|
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
|
|
20
82
|
console.error(`Usage: node compile_report.mjs <research-dir> [--user-company "<name>"] [--template <path>] [--open]
|
|
@@ -34,12 +96,12 @@ Options:
|
|
|
34
96
|
process.exit(args.includes('--help') || args.includes('-h') ? 0 : 1);
|
|
35
97
|
}
|
|
36
98
|
|
|
37
|
-
const dir = args[0];
|
|
99
|
+
const dir = safeResearchDir(args[0]);
|
|
38
100
|
const shouldOpen = args.includes('--open');
|
|
39
101
|
const userCompanyIdx = args.indexOf('--user-company');
|
|
40
102
|
const userCompany = userCompanyIdx !== -1 ? args[userCompanyIdx + 1] : '';
|
|
41
103
|
const templateIdx = args.indexOf('--template');
|
|
42
|
-
let templatePath = templateIdx !== -1 ? args[templateIdx + 1] : null;
|
|
104
|
+
let templatePath = templateIdx !== -1 ? safeTemplatePath(dir, args[templateIdx + 1]) : null;
|
|
43
105
|
|
|
44
106
|
if (!templatePath) {
|
|
45
107
|
const candidates = [
|
|
@@ -226,14 +288,14 @@ function mdToHtml(md) {
|
|
|
226
288
|
|
|
227
289
|
const competitors = [];
|
|
228
290
|
for (const file of files) {
|
|
229
|
-
const content = readFileSync(
|
|
291
|
+
const content = readFileSync(safeJoin(dir, file), 'utf-8');
|
|
230
292
|
const fields = parseFrontmatter(content);
|
|
231
293
|
if (!fields) continue;
|
|
232
294
|
const body = parseBody(content);
|
|
233
295
|
const sections = parseSections(body);
|
|
234
296
|
const mentions = parseMentions(sections['Mentions']);
|
|
235
297
|
const benchmarks = parseBenchmarks(sections['Benchmarks']);
|
|
236
|
-
const slug = file.replace('.md', '');
|
|
298
|
+
const slug = safeSlug(file.replace('.md', ''));
|
|
237
299
|
competitors.push({ ...fields, body, sections, mentions, benchmarks, slug, file });
|
|
238
300
|
}
|
|
239
301
|
|
|
@@ -253,7 +315,7 @@ const deduped = [...seen.values()].sort((a, b) => (a.competitor_name || '').loca
|
|
|
253
315
|
// whole matrix. Keep this block above the first use site to avoid temporal dead zones.
|
|
254
316
|
let curatedMatrix = null;
|
|
255
317
|
try {
|
|
256
|
-
const p =
|
|
318
|
+
const p = safeJoin(dir, 'matrix.json');
|
|
257
319
|
if (existsSync(p)) curatedMatrix = JSON.parse(readFileSync(p, 'utf-8'));
|
|
258
320
|
} catch (err) {
|
|
259
321
|
console.error(`Warning: matrix.json present but unreadable — falling back to pipe split. ${err.message}`);
|
|
@@ -288,7 +350,7 @@ const totalMentions = competitorRows.reduce((sum, c) => sum + c.mentions.length,
|
|
|
288
350
|
const totalBenchmarks = competitorRows.reduce((sum, c) => sum + c.benchmarks.length, 0);
|
|
289
351
|
const withPricing = competitorRows.filter(c => c.pricing_tiers).length;
|
|
290
352
|
|
|
291
|
-
const dirName = dir
|
|
353
|
+
const dirName = basename(dir);
|
|
292
354
|
const title = dirName.replace(/_/g, ' ').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
293
355
|
const genDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
294
356
|
const metaLine = `${competitorRows.length} competitors · ${totalMentions} mentions · ${totalBenchmarks} benchmarks · ${genDate}`;
|
|
@@ -433,11 +495,11 @@ let indexHtml = template
|
|
|
433
495
|
.replace(/\{\{STRATEGIC_SUMMARY\}\}/g, strategicSummary)
|
|
434
496
|
.replace(/\{\{TABLE_ROWS\}\}/g, tableRows);
|
|
435
497
|
|
|
436
|
-
writeFileSync(
|
|
498
|
+
writeFileSync(safeJoin(dir, 'index.html'), indexHtml);
|
|
437
499
|
|
|
438
500
|
// ---------- competitors/{slug}.html ----------
|
|
439
501
|
|
|
440
|
-
try { mkdirSync(
|
|
502
|
+
try { mkdirSync(safeJoin(dir, 'competitors'), { recursive: true }); } catch {}
|
|
441
503
|
|
|
442
504
|
const perCompetitorCss = `
|
|
443
505
|
:root { --brand:#F03603; --blue:#4DA9E4; --black:#100D0D; --gray:#514F4F; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
|
|
@@ -528,7 +590,7 @@ for (const c of competitorRows) {
|
|
|
528
590
|
const findingsHtml = c.sections['Research Findings'] ? `<h2>Research Findings</h2>${mdToHtml(c.sections['Research Findings'])}` : '';
|
|
529
591
|
|
|
530
592
|
// Screenshot — filename matches capture_screenshots.mjs output.
|
|
531
|
-
const heroShot = existsSync(
|
|
593
|
+
const heroShot = existsSync(safeJoin(dir, 'screenshots', `${c.slug}-hero.png`));
|
|
532
594
|
const screenshotsHtml = heroShot ? `
|
|
533
595
|
<div class="shots">
|
|
534
596
|
<div class="shot shot-hero"><div class="shot-label">Homepage</div><img src="../screenshots/${escapeHtml(c.slug)}-hero.png" alt="${escapeHtml(c.competitor_name)} homepage hero" loading="lazy"></div>
|
|
@@ -586,7 +648,7 @@ for (const c of competitorRows) {
|
|
|
586
648
|
</body>
|
|
587
649
|
</html>`;
|
|
588
650
|
|
|
589
|
-
writeFileSync(
|
|
651
|
+
writeFileSync(safeJoin(dir, 'competitors', `${c.slug}.html`), companyHtml);
|
|
590
652
|
}
|
|
591
653
|
|
|
592
654
|
// ---------- matrix.html (side-by-side) ----------
|
|
@@ -739,7 +801,7 @@ const matrixHtml = `<!DOCTYPE html>
|
|
|
739
801
|
</body>
|
|
740
802
|
</html>`;
|
|
741
803
|
|
|
742
|
-
writeFileSync(
|
|
804
|
+
writeFileSync(safeJoin(dir, 'matrix.html'), matrixHtml);
|
|
743
805
|
|
|
744
806
|
// ---------- mentions.html (feed + filter) ----------
|
|
745
807
|
|
|
@@ -870,7 +932,7 @@ const mentionsHtml = `<!DOCTYPE html>
|
|
|
870
932
|
</body>
|
|
871
933
|
</html>`;
|
|
872
934
|
|
|
873
|
-
writeFileSync(
|
|
935
|
+
writeFileSync(safeJoin(dir, 'mentions.html'), mentionsHtml);
|
|
874
936
|
|
|
875
937
|
// ---------- CSV ----------
|
|
876
938
|
|
|
@@ -900,7 +962,7 @@ function csvEscape(v) {
|
|
|
900
962
|
|
|
901
963
|
const csvLines = [cols.join(',')];
|
|
902
964
|
for (const row of flatRows) csvLines.push(cols.map(c => csvEscape(row[c] || '')).join(','));
|
|
903
|
-
writeFileSync(
|
|
965
|
+
writeFileSync(safeJoin(dir, 'results.csv'), csvLines.join('\n') + '\n');
|
|
904
966
|
|
|
905
967
|
// ---------- Summary ----------
|
|
906
968
|
|
|
@@ -911,19 +973,19 @@ console.error(JSON.stringify({
|
|
|
911
973
|
with_pricing: withPricing,
|
|
912
974
|
user_company: userCompany,
|
|
913
975
|
files_generated: {
|
|
914
|
-
index:
|
|
915
|
-
matrix:
|
|
916
|
-
mentions:
|
|
976
|
+
index: safeJoin(dir, 'index.html'),
|
|
977
|
+
matrix: safeJoin(dir, 'matrix.html'),
|
|
978
|
+
mentions: safeJoin(dir, 'mentions.html'),
|
|
917
979
|
competitors: competitorRows.filter(c => c.body && c.body.length > 50).length,
|
|
918
|
-
csv:
|
|
980
|
+
csv: safeJoin(dir, 'results.csv')
|
|
919
981
|
}
|
|
920
982
|
}, null, 2));
|
|
921
983
|
|
|
922
|
-
console.log(
|
|
984
|
+
console.log(safeJoin(dir, 'index.html'));
|
|
923
985
|
|
|
924
986
|
if (shouldOpen) {
|
|
925
987
|
const { execFileSync } = await import('child_process');
|
|
926
988
|
// Use execFileSync (not execSync with string interpolation) so a `dir` containing
|
|
927
989
|
// shell metacharacters like `"`, `$`, or backticks can't break out into command exec.
|
|
928
|
-
try { execFileSync('open', [
|
|
990
|
+
try { execFileSync('open', [safeJoin(dir, 'index.html')]); } catch {}
|
|
929
991
|
}
|
|
@@ -3,11 +3,29 @@ Base validator with common validation logic for document files.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
import shutil
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import lxml.etree
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
def safe_extract_all(zip_ref, destination):
|
|
13
|
+
"""Extract a zip archive without allowing members to escape destination."""
|
|
14
|
+
destination = Path(destination).resolve()
|
|
15
|
+
for member in zip_ref.infolist():
|
|
16
|
+
target = (destination / member.filename).resolve()
|
|
17
|
+
try:
|
|
18
|
+
target.relative_to(destination)
|
|
19
|
+
except ValueError as exc:
|
|
20
|
+
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
|
21
|
+
if member.is_dir():
|
|
22
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
continue
|
|
24
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
with zip_ref.open(member) as src, target.open("wb") as dst:
|
|
26
|
+
shutil.copyfileobj(src, dst)
|
|
27
|
+
|
|
28
|
+
|
|
11
29
|
class BaseSchemaValidator:
|
|
12
30
|
"""Base validator with common validation logic for document files."""
|
|
13
31
|
|
|
@@ -888,7 +906,7 @@ class BaseSchemaValidator:
|
|
|
888
906
|
|
|
889
907
|
# Extract original file
|
|
890
908
|
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
|
891
|
-
zip_ref
|
|
909
|
+
safe_extract_all(zip_ref, temp_path)
|
|
892
910
|
|
|
893
911
|
# Find corresponding file in original
|
|
894
912
|
original_xml_file = temp_path / relative_path
|
|
@@ -3,14 +3,33 @@ Validator for Word document XML files against XSD schemas.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
import shutil
|
|
6
7
|
import tempfile
|
|
7
8
|
import zipfile
|
|
9
|
+
from pathlib import Path
|
|
8
10
|
|
|
9
11
|
import lxml.etree
|
|
10
12
|
|
|
11
13
|
from .base import BaseSchemaValidator
|
|
12
14
|
|
|
13
15
|
|
|
16
|
+
def safe_extract_all(zip_ref, destination):
|
|
17
|
+
"""Extract a zip archive without allowing members to escape destination."""
|
|
18
|
+
destination = Path(destination).resolve()
|
|
19
|
+
for member in zip_ref.infolist():
|
|
20
|
+
target = (destination / member.filename).resolve()
|
|
21
|
+
try:
|
|
22
|
+
target.relative_to(destination)
|
|
23
|
+
except ValueError as exc:
|
|
24
|
+
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
|
25
|
+
if member.is_dir():
|
|
26
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
continue
|
|
28
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
with zip_ref.open(member) as src, target.open("wb") as dst:
|
|
30
|
+
shutil.copyfileobj(src, dst)
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
class DOCXSchemaValidator(BaseSchemaValidator):
|
|
15
34
|
"""Validator for Word document XML files against XSD schemas."""
|
|
16
35
|
|
|
@@ -198,7 +217,7 @@ class DOCXSchemaValidator(BaseSchemaValidator):
|
|
|
198
217
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
199
218
|
# Unpack original docx
|
|
200
219
|
with zipfile.ZipFile(self.original_file, "r") as zip_ref:
|
|
201
|
-
zip_ref
|
|
220
|
+
safe_extract_all(zip_ref, temp_dir)
|
|
202
221
|
|
|
203
222
|
# Parse document.xml
|
|
204
223
|
doc_xml_path = temp_dir + "/word/document.xml"
|
|
@@ -2,11 +2,31 @@
|
|
|
2
2
|
Validator for tracked changes in Word documents.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
import tempfile
|
|
7
8
|
import zipfile
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
11
|
+
from defusedxml import ElementTree as ET
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def safe_extract_all(zip_ref, destination):
|
|
15
|
+
"""Extract a zip archive without allowing members to escape destination."""
|
|
16
|
+
destination = Path(destination).resolve()
|
|
17
|
+
for member in zip_ref.infolist():
|
|
18
|
+
target = (destination / member.filename).resolve()
|
|
19
|
+
try:
|
|
20
|
+
target.relative_to(destination)
|
|
21
|
+
except ValueError as exc:
|
|
22
|
+
raise ValueError(f"Unsafe archive member: {member.filename}") from exc
|
|
23
|
+
if member.is_dir():
|
|
24
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
continue
|
|
26
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
with zip_ref.open(member) as src, target.open("wb") as dst:
|
|
28
|
+
shutil.copyfileobj(src, dst)
|
|
29
|
+
|
|
10
30
|
|
|
11
31
|
class RedliningValidator:
|
|
12
32
|
"""Validator for tracked changes in Word documents."""
|
|
@@ -29,8 +49,6 @@ class RedliningValidator:
|
|
|
29
49
|
|
|
30
50
|
# First, check if there are any tracked changes by Claude to validate
|
|
31
51
|
try:
|
|
32
|
-
import xml.etree.ElementTree as ET
|
|
33
|
-
|
|
34
52
|
tree = ET.parse(modified_file)
|
|
35
53
|
root = tree.getroot()
|
|
36
54
|
|
|
@@ -67,7 +85,7 @@ class RedliningValidator:
|
|
|
67
85
|
# Unpack original docx
|
|
68
86
|
try:
|
|
69
87
|
with zipfile.ZipFile(self.original_docx, "r") as zip_ref:
|
|
70
|
-
zip_ref
|
|
88
|
+
safe_extract_all(zip_ref, temp_path)
|
|
71
89
|
except Exception as e:
|
|
72
90
|
print(f"FAILED - Error unpacking original docx: {e}")
|
|
73
91
|
return False
|
|
@@ -81,8 +99,6 @@ class RedliningValidator:
|
|
|
81
99
|
|
|
82
100
|
# Parse both XML files using xml.etree.ElementTree for redlining validation
|
|
83
101
|
try:
|
|
84
|
-
import xml.etree.ElementTree as ET
|
|
85
|
-
|
|
86
102
|
modified_tree = ET.parse(modified_file)
|
|
87
103
|
modified_root = modified_tree.getroot()
|
|
88
104
|
original_tree = ET.parse(original_file)
|
|
@@ -81,7 +81,7 @@ harness/
|
|
|
81
81
|
},
|
|
82
82
|
"test_alternatives": {
|
|
83
83
|
"sqlite_in_memory": "DB_DRIVER=sqlite3 DB_URL=:memory:",
|
|
84
|
-
"docker": "docker run -d --name test-pg -p 5433:5432 -e POSTGRES_PASSWORD=test postgres:16"
|
|
84
|
+
"docker": "docker run -d --name test-pg -p 127.0.0.1:5433:5432 -e POSTGRES_PASSWORD=test postgres:16"
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
],
|