opencode-skills-collection 3.0.51 → 3.1.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.
- package/README.md +44 -12
- package/dist/skill-pointer/config-loader.d.ts +14 -0
- package/dist/skill-pointer/config-loader.js +30 -3
- package/dist/skill-pointer/content-scanner.d.ts +38 -0
- package/dist/skill-pointer/content-scanner.js +118 -0
- package/dist/skill-pointer/index.d.ts +7 -2
- package/dist/skill-pointer/index.js +14 -4
- package/dist/skill-pointer/pointer-generator.js +2 -0
- package/dist/skill-pointer/skill-patcher.d.ts +13 -0
- package/dist/skill-pointer/skill-patcher.js +99 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
## Overview
|
|
23
23
|
|
|
24
|
-
**OpenCode Skills Collection** ships a pre-bundled snapshot of
|
|
24
|
+
**OpenCode Skills Collection** ships a pre-bundled snapshot of 1595+ universal skills for the OpenCode CLI.
|
|
25
25
|
|
|
26
26
|
Instead of loading every skill into the AI context at startup — which would consume ~80k tokens and cause compaction
|
|
27
27
|
loops — the plugin uses a **SkillPointer** architecture: skills are organized into categories inside a hidden vault and
|
|
@@ -45,7 +45,10 @@ bundled-skills/ (npm package)
|
|
|
45
45
|
│
|
|
46
46
|
└── SkillPointer pipeline
|
|
47
47
|
│
|
|
48
|
-
├─
|
|
48
|
+
├─ risk-filter → excludes skills by risk level or ID
|
|
49
|
+
├─ content-scanner → quarantines skills with dangerous patterns
|
|
50
|
+
├─ vault-manager → moves safe skills to the vault
|
|
51
|
+
├─ skill-patcher → applies config-driven content patches
|
|
49
52
|
└─ pointer-generator → writes ~35 lightweight pointer files
|
|
50
53
|
```
|
|
51
54
|
|
|
@@ -61,24 +64,19 @@ The full skill content is only injected into context when the AI actually needs
|
|
|
61
64
|
|
|
62
65
|
After the first startup, your `~/.config/opencode/` directory looks like this:
|
|
63
66
|
|
|
64
|
-
```
|
|
65
67
|
~/.config/opencode/
|
|
66
68
|
├── opencode.json
|
|
67
|
-
├──
|
|
69
|
+
├── skill-filter.jsonc ← optional: risk filter + patcher config
|
|
70
|
+
├── skills/ ← pointer folders (active, read by OpenCode)
|
|
68
71
|
│ ├── backend-dev-category-pointer/
|
|
69
72
|
│ │ └── SKILL.md
|
|
70
|
-
│ ├── devops-category-pointer/
|
|
71
|
-
│ │ └── SKILL.md
|
|
72
73
|
│ └── ...
|
|
73
|
-
└── skill-libraries/
|
|
74
|
+
└── skill-libraries/ ← vault with all raw skills
|
|
74
75
|
├── backend-dev/
|
|
75
76
|
│ ├── laravel-expert/
|
|
76
77
|
│ │ └── SKILL.md
|
|
77
|
-
│ └──
|
|
78
|
-
│ └── SKILL.md
|
|
79
|
-
├── devops/
|
|
78
|
+
│ └── ...
|
|
80
79
|
└── ...
|
|
81
|
-
```
|
|
82
80
|
|
|
83
81
|
---
|
|
84
82
|
|
|
@@ -138,7 +136,7 @@ opencode run /refactor clean up this function
|
|
|
138
136
|
|
|
139
137
|
---
|
|
140
138
|
|
|
141
|
-
## Skill
|
|
139
|
+
## Skill Safety & Filtering
|
|
142
140
|
|
|
143
141
|
The plugin supports configurable risk-based filtering of skills. By default, **all skills are loaded** — filtering is
|
|
144
142
|
opt-in.
|
|
@@ -169,6 +167,40 @@ Create a `~/.config/opencode/skill-filter.jsonc` file:
|
|
|
169
167
|
|
|
170
168
|
Blocked skills are excluded from both the vault and the generated pointers — they are never loaded into context.
|
|
171
169
|
|
|
170
|
+
### Content Safety Scanner (CI)
|
|
171
|
+
|
|
172
|
+
Dangerous skills are automatically detected and removed **at build time** — before the npm package is published.
|
|
173
|
+
The nightly sync workflow scans every SKILL.md for recursive loop patterns and strips matching skills from
|
|
174
|
+
`bundled-skills/` and `skills_index.json`, so they never reach end users.
|
|
175
|
+
|
|
176
|
+
**Built-in patterns detect:**
|
|
177
|
+
- Recursive skill invocation loops ("invoke skills before any response")
|
|
178
|
+
- Aggressive match thresholds ("even a 1% chance")
|
|
179
|
+
- Mandatory pre-response skill checks ("you must invoke the skill")
|
|
180
|
+
|
|
181
|
+
### Skill Patcher
|
|
182
|
+
|
|
183
|
+
The plugin can modify skill content after installation via config-driven patches. This allows neutralizing
|
|
184
|
+
problematic instructions without forking upstream skills.
|
|
185
|
+
|
|
186
|
+
Add patches in `skill-filter.jsonc`:
|
|
187
|
+
|
|
188
|
+
```jsonc
|
|
189
|
+
{
|
|
190
|
+
"skillPatches": [
|
|
191
|
+
{
|
|
192
|
+
"skillId": "some-skill-name",
|
|
193
|
+
"find": "regex-pattern-to-match",
|
|
194
|
+
"replace": "replacement-text",
|
|
195
|
+
"description": "Why this patch exists"
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Patches are applied in order, case-insensitive, and globally (all occurrences). Invalid regex patterns are
|
|
202
|
+
skipped silently. Re-running the pipeline with the same patches is idempotent.
|
|
203
|
+
|
|
172
204
|
---
|
|
173
205
|
|
|
174
206
|
## Development
|
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import type { RiskLevel } from "./risk-level.js";
|
|
2
|
+
export interface ScanPattern {
|
|
3
|
+
id: string;
|
|
4
|
+
pattern: string;
|
|
5
|
+
description: string;
|
|
6
|
+
severity: "block" | "warn";
|
|
7
|
+
}
|
|
8
|
+
export interface SkillPatch {
|
|
9
|
+
skillId: string;
|
|
10
|
+
find: string;
|
|
11
|
+
replace: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
2
14
|
export interface SkillRiskFilterConfig {
|
|
3
15
|
excludedRiskLevels?: RiskLevel[];
|
|
4
16
|
excludedSkills?: string[];
|
|
17
|
+
scanPatterns?: ScanPattern[];
|
|
18
|
+
skillPatches?: SkillPatch[];
|
|
5
19
|
}
|
|
6
20
|
export declare const DEFAULT_FILTER_CONFIG_PATH: string;
|
|
7
21
|
/**
|
|
@@ -1,13 +1,34 @@
|
|
|
1
|
-
import os from "os";
|
|
2
|
-
import path from "path";
|
|
3
|
-
import fs from "fs";
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
4
|
import stripJsonComments from "strip-json-comments";
|
|
5
5
|
const DEFAULT_CONFIG = {
|
|
6
6
|
excludedRiskLevels: [],
|
|
7
7
|
excludedSkills: [],
|
|
8
|
+
scanPatterns: [],
|
|
9
|
+
skillPatches: [],
|
|
8
10
|
};
|
|
9
11
|
export const DEFAULT_FILTER_CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "skill-filter.jsonc");
|
|
10
12
|
const VALID_RISK_LEVELS = ["none", "safe", "critical", "offensive", "unknown"];
|
|
13
|
+
const VALID_SEVERITIES = ["block", "warn"];
|
|
14
|
+
function isValidScanPattern(entry) {
|
|
15
|
+
if (typeof entry !== "object" || entry === null)
|
|
16
|
+
return false;
|
|
17
|
+
const obj = entry;
|
|
18
|
+
return (typeof obj.id === "string" &&
|
|
19
|
+
typeof obj.pattern === "string" &&
|
|
20
|
+
typeof obj.description === "string" &&
|
|
21
|
+
typeof obj.severity === "string" &&
|
|
22
|
+
VALID_SEVERITIES.includes(obj.severity));
|
|
23
|
+
}
|
|
24
|
+
function isValidSkillPatch(entry) {
|
|
25
|
+
if (typeof entry !== "object" || entry === null)
|
|
26
|
+
return false;
|
|
27
|
+
const obj = entry;
|
|
28
|
+
return (typeof obj.skillId === "string" &&
|
|
29
|
+
typeof obj.find === "string" &&
|
|
30
|
+
typeof obj.replace === "string");
|
|
31
|
+
}
|
|
11
32
|
/**
|
|
12
33
|
* Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
|
|
13
34
|
* @param configPath Optional override (for testing).
|
|
@@ -28,6 +49,12 @@ export function loadFilterConfig(configPath) {
|
|
|
28
49
|
excludedSkills: Array.isArray(parsed.excludedSkills)
|
|
29
50
|
? parsed.excludedSkills
|
|
30
51
|
: DEFAULT_CONFIG.excludedSkills,
|
|
52
|
+
scanPatterns: Array.isArray(parsed.scanPatterns)
|
|
53
|
+
? parsed.scanPatterns.filter(isValidScanPattern)
|
|
54
|
+
: DEFAULT_CONFIG.scanPatterns,
|
|
55
|
+
skillPatches: Array.isArray(parsed.skillPatches)
|
|
56
|
+
? parsed.skillPatches.filter(isValidSkillPatch)
|
|
57
|
+
: DEFAULT_CONFIG.skillPatches,
|
|
31
58
|
};
|
|
32
59
|
}
|
|
33
60
|
catch {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ScanPattern } from "./config-loader.js";
|
|
2
|
+
import type { SkillIndexEntry } from "./vault-installer.js";
|
|
3
|
+
export interface ScanResult {
|
|
4
|
+
skillId: string;
|
|
5
|
+
matchedPatterns: {
|
|
6
|
+
id: string;
|
|
7
|
+
severity: "block" | "warn";
|
|
8
|
+
}[];
|
|
9
|
+
blocked: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ScanOutput {
|
|
12
|
+
passed: SkillIndexEntry[];
|
|
13
|
+
quarantined: ScanResult[];
|
|
14
|
+
warned: ScanResult[];
|
|
15
|
+
}
|
|
16
|
+
/** Default patterns that detect recursive loop triggers in skill content. */
|
|
17
|
+
export declare const DEFAULT_SCAN_PATTERNS: ScanPattern[];
|
|
18
|
+
/**
|
|
19
|
+
* Merges user-supplied scan patterns with built-in defaults.
|
|
20
|
+
* Config patterns with the same `id` override the default entry,
|
|
21
|
+
* but only if the config pattern has a valid regex. Invalid overrides
|
|
22
|
+
* are rejected and the default is preserved to prevent silently
|
|
23
|
+
* disabling built-in protection.
|
|
24
|
+
*/
|
|
25
|
+
export declare function mergePatterns(configPatterns: ScanPattern[]): ScanPattern[];
|
|
26
|
+
/**
|
|
27
|
+
* Tests skill content against a list of scan patterns.
|
|
28
|
+
* Returns the list of matched pattern ids and severities.
|
|
29
|
+
*/
|
|
30
|
+
export declare function scanSkillContent(content: string, patterns: ScanPattern[]): {
|
|
31
|
+
id: string;
|
|
32
|
+
severity: "block" | "warn";
|
|
33
|
+
}[];
|
|
34
|
+
/**
|
|
35
|
+
* Scans every skill's SKILL.md for dangerous content patterns.
|
|
36
|
+
* Returns lists of passed, quarantined (blocked), and warned entries.
|
|
37
|
+
*/
|
|
38
|
+
export declare function scanSkills(bundledSkillsPath: string, index: SkillIndexEntry[], configPatterns?: ScanPattern[]): ScanOutput;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { SKILL_FILENAME } from "../constants/constants.js";
|
|
4
|
+
/** Default patterns that detect recursive loop triggers in skill content. */
|
|
5
|
+
export const DEFAULT_SCAN_PATTERNS = [
|
|
6
|
+
{
|
|
7
|
+
id: "recursive-skill-invocation",
|
|
8
|
+
pattern: String.raw `(invoke|use|check|load)\b.*?\bskills?\b.*?\bbefore\s+(any|every|all)\s+(response|action|message)`,
|
|
9
|
+
description: "Detects instructions mandating skill invocation before every response",
|
|
10
|
+
severity: "block",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: "aggressive-match-threshold",
|
|
14
|
+
pattern: String.raw `(even\s+)?(a\s+)?1%\s+chance.*skill`,
|
|
15
|
+
description: "Detects extremely low probability thresholds causing over-triggering",
|
|
16
|
+
severity: "block",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "mandatory-pre-response-skill-check",
|
|
20
|
+
pattern: String.raw `you\s+(absolutely\s+)?must.*invoke.*skill`,
|
|
21
|
+
description: "Detects mandatory skill invocation directives",
|
|
22
|
+
severity: "block",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Merges user-supplied scan patterns with built-in defaults.
|
|
27
|
+
* Config patterns with the same `id` override the default entry,
|
|
28
|
+
* but only if the config pattern has a valid regex. Invalid overrides
|
|
29
|
+
* are rejected and the default is preserved to prevent silently
|
|
30
|
+
* disabling built-in protection.
|
|
31
|
+
*/
|
|
32
|
+
export function mergePatterns(configPatterns) {
|
|
33
|
+
const merged = new Map();
|
|
34
|
+
for (const p of DEFAULT_SCAN_PATTERNS) {
|
|
35
|
+
merged.set(p.id, p);
|
|
36
|
+
}
|
|
37
|
+
for (const p of configPatterns) {
|
|
38
|
+
// Validate regex before allowing override
|
|
39
|
+
try {
|
|
40
|
+
new RegExp(p.pattern, "is");
|
|
41
|
+
merged.set(p.id, p);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// If this would override a default, keep the default — don't silently disable protection
|
|
45
|
+
if (!merged.has(p.id)) {
|
|
46
|
+
// New pattern with invalid regex — just skip it
|
|
47
|
+
console.warn(`Invalid regex for pattern '${p.id}': ${p.pattern}`);
|
|
48
|
+
}
|
|
49
|
+
// Existing default stays in place
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...merged.values()];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Tests skill content against a list of scan patterns.
|
|
56
|
+
* Returns the list of matched pattern ids and severities.
|
|
57
|
+
*/
|
|
58
|
+
export function scanSkillContent(content, patterns) {
|
|
59
|
+
const matches = [];
|
|
60
|
+
for (const p of patterns) {
|
|
61
|
+
try {
|
|
62
|
+
const re = new RegExp(p.pattern, "is");
|
|
63
|
+
if (re.test(content)) {
|
|
64
|
+
matches.push({ id: p.id, severity: p.severity });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Invalid regex — skip silently
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return matches;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Scans every skill's SKILL.md for dangerous content patterns.
|
|
75
|
+
* Returns lists of passed, quarantined (blocked), and warned entries.
|
|
76
|
+
*/
|
|
77
|
+
export function scanSkills(bundledSkillsPath, index, configPatterns) {
|
|
78
|
+
const patterns = mergePatterns(configPatterns ?? []);
|
|
79
|
+
const passed = [];
|
|
80
|
+
const quarantined = [];
|
|
81
|
+
const warned = [];
|
|
82
|
+
for (const entry of index) {
|
|
83
|
+
// Path traversal guard: quarantine entries whose id escapes the bundled-skills directory
|
|
84
|
+
if (entry.id.includes("..") || entry.id.includes(path.sep) || entry.id.includes("/")) {
|
|
85
|
+
quarantined.push({
|
|
86
|
+
skillId: entry.id,
|
|
87
|
+
matchedPatterns: [{ id: "path-traversal", severity: "block" }],
|
|
88
|
+
blocked: true,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const skillFile = path.join(bundledSkillsPath, entry.id, SKILL_FILENAME);
|
|
93
|
+
if (!fs.existsSync(skillFile)) {
|
|
94
|
+
passed.push(entry);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const content = fs.readFileSync(skillFile, "utf-8");
|
|
98
|
+
const matches = scanSkillContent(content, patterns);
|
|
99
|
+
if (matches.length === 0) {
|
|
100
|
+
passed.push(entry);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const hasBlock = matches.some((m) => m.severity === "block");
|
|
104
|
+
const result = {
|
|
105
|
+
skillId: entry.id,
|
|
106
|
+
matchedPatterns: matches,
|
|
107
|
+
blocked: hasBlock,
|
|
108
|
+
};
|
|
109
|
+
if (hasBlock) {
|
|
110
|
+
quarantined.push(result);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
warned.push(result);
|
|
114
|
+
passed.push(entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { passed, quarantined, warned };
|
|
118
|
+
}
|
|
@@ -17,10 +17,15 @@ export interface SkillPointerOptions {
|
|
|
17
17
|
* Orchestrates the full SkillPointer pipeline:
|
|
18
18
|
*
|
|
19
19
|
* 1. Reads skills_index.json bundled alongside the skills snapshot.
|
|
20
|
-
* 2.
|
|
21
|
-
* 3.
|
|
20
|
+
* 2. Filters by risk level / excluded skills.
|
|
21
|
+
* 3. Copies skills into the vault, categorised by the index.
|
|
22
|
+
* 4. Applies config-driven content patches to installed skills.
|
|
23
|
+
* 5. Generates pointer SKILL.md files in activeSkillsDir with full skill
|
|
22
24
|
* listings so keyword searches (e.g. "laravel") resolve out of the box.
|
|
23
25
|
*
|
|
26
|
+
* Content safety scanning is performed at CI time (sync-skills.yml),
|
|
27
|
+
* not at runtime — dangerous skills are removed before npm publish.
|
|
28
|
+
*
|
|
24
29
|
* The activeSkillsDir is never used as a staging area — user custom
|
|
25
30
|
* skills already present there are never moved or overwritten.
|
|
26
31
|
*/
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import os from "os";
|
|
2
|
-
import path from "path";
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
3
|
import { VAULT_DIR_NAME } from "../constants/constants.js";
|
|
4
4
|
import { ensureDir } from "../utils/fs.utils.js";
|
|
5
5
|
import { generatePointers } from "./pointer-generator.js";
|
|
6
6
|
import { installSkillsToVault, loadSkillsIndex } from "./vault-installer.js";
|
|
7
7
|
import { filterIndex } from "./skill-risk-filter.js";
|
|
8
8
|
import { loadFilterConfig, DEFAULT_FILTER_CONFIG_PATH } from "./config-loader.js";
|
|
9
|
+
import { applySkillPatches } from "./skill-patcher.js";
|
|
9
10
|
function resolveDefaultVaultDir() {
|
|
10
11
|
return path.join(os.homedir(), ".config", "opencode", VAULT_DIR_NAME);
|
|
11
12
|
}
|
|
@@ -13,10 +14,15 @@ function resolveDefaultVaultDir() {
|
|
|
13
14
|
* Orchestrates the full SkillPointer pipeline:
|
|
14
15
|
*
|
|
15
16
|
* 1. Reads skills_index.json bundled alongside the skills snapshot.
|
|
16
|
-
* 2.
|
|
17
|
-
* 3.
|
|
17
|
+
* 2. Filters by risk level / excluded skills.
|
|
18
|
+
* 3. Copies skills into the vault, categorised by the index.
|
|
19
|
+
* 4. Applies config-driven content patches to installed skills.
|
|
20
|
+
* 5. Generates pointer SKILL.md files in activeSkillsDir with full skill
|
|
18
21
|
* listings so keyword searches (e.g. "laravel") resolve out of the box.
|
|
19
22
|
*
|
|
23
|
+
* Content safety scanning is performed at CI time (sync-skills.yml),
|
|
24
|
+
* not at runtime — dangerous skills are removed before npm publish.
|
|
25
|
+
*
|
|
20
26
|
* The activeSkillsDir is never used as a staging area — user custom
|
|
21
27
|
* skills already present there are never moved or overwritten.
|
|
22
28
|
*/
|
|
@@ -29,5 +35,9 @@ export function runSkillPointer(options) {
|
|
|
29
35
|
const config = loadFilterConfig(configPath);
|
|
30
36
|
const filteredIndex = filterIndex(index, config);
|
|
31
37
|
installSkillsToVault(options.bundledSkillsPath, vaultDir, filteredIndex);
|
|
38
|
+
// Apply content patches
|
|
39
|
+
if (config.skillPatches && config.skillPatches.length > 0) {
|
|
40
|
+
applySkillPatches(vaultDir, filteredIndex, config.skillPatches);
|
|
41
|
+
}
|
|
32
42
|
generatePointers(options.activeSkillsDir, vaultDir, filteredIndex);
|
|
33
43
|
}
|
|
@@ -35,6 +35,8 @@ ${skillList}
|
|
|
35
35
|
**Vault path:** \`${normalizedPath}\`
|
|
36
36
|
|
|
37
37
|
> Do not guess best practices — always read from the vault first.
|
|
38
|
+
|
|
39
|
+
> ⚠️ **Anti-loop guard**: Do NOT invoke skills recursively or check for applicable skills before every response. Each skill should be loaded at most once per user request. If you have already identified and loaded the relevant skill for this task, proceed with execution — do not re-scan for skills.
|
|
38
40
|
`;
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SkillPatch } from "./config-loader.js";
|
|
2
|
+
import type { SkillIndexEntry } from "./vault-installer.js";
|
|
3
|
+
/**
|
|
4
|
+
* Applies regex-based content patches to installed skill files in the vault.
|
|
5
|
+
*
|
|
6
|
+
* Each patch targets a `skillId`, performing a case-insensitive global regex
|
|
7
|
+
* replacement on the skill's SKILL.md content. Multiple patches for the same
|
|
8
|
+
* skill are applied in order. Invalid regex patterns are silently skipped.
|
|
9
|
+
*
|
|
10
|
+
* This function is idempotent — re-running with the same patches on already-patched
|
|
11
|
+
* content produces no further changes (the regex simply won't match).
|
|
12
|
+
*/
|
|
13
|
+
export declare function applySkillPatches(vaultDir: string, index: SkillIndexEntry[], patches: SkillPatch[]): void;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { SKILL_FILENAME } from "../constants/constants.js";
|
|
4
|
+
/**
|
|
5
|
+
* Checks whether a path component is safe (no traversal sequences).
|
|
6
|
+
*/
|
|
7
|
+
function isSafePath(component) {
|
|
8
|
+
return !component.includes("..") && !component.includes("/") && !component.includes(path.sep);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the SKILL.md file path for a given index entry within the vault.
|
|
12
|
+
* Returns `undefined` if the path is unsafe or the file does not exist.
|
|
13
|
+
*/
|
|
14
|
+
function resolveSkillFile(vaultDir, entry) {
|
|
15
|
+
if (!isSafePath(entry.id) || !isSafePath(entry.category))
|
|
16
|
+
return undefined;
|
|
17
|
+
const skillFile = path.join(vaultDir, entry.category, entry.id, SKILL_FILENAME);
|
|
18
|
+
if (!fs.existsSync(skillFile))
|
|
19
|
+
return undefined;
|
|
20
|
+
const resolvedVault = fs.realpathSync(vaultDir);
|
|
21
|
+
const realSkillFile = fs.realpathSync(skillFile);
|
|
22
|
+
if (!realSkillFile.startsWith(resolvedVault + path.sep))
|
|
23
|
+
return undefined;
|
|
24
|
+
return skillFile;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Applies a single patch to content, returning the modified content.
|
|
28
|
+
* Returns `undefined` if the patch could not be applied (invalid regex, empty find, no match).
|
|
29
|
+
*/
|
|
30
|
+
function applySinglePatch(content, patch) {
|
|
31
|
+
if (!patch.find)
|
|
32
|
+
return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const re = new RegExp(patch.find, "gis");
|
|
35
|
+
// Use function replacement to treat patch.replace as a literal string
|
|
36
|
+
// (avoids $& $1 $` $' being interpreted as special replacement patterns)
|
|
37
|
+
const replacement = patch.replace;
|
|
38
|
+
const newContent = content.replace(re, () => replacement);
|
|
39
|
+
return newContent === content ? undefined : newContent;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Groups patches by skillId, preserving order within each group.
|
|
47
|
+
*/
|
|
48
|
+
function groupPatchesBySkill(patches) {
|
|
49
|
+
const grouped = new Map();
|
|
50
|
+
for (const patch of patches) {
|
|
51
|
+
const group = grouped.get(patch.skillId);
|
|
52
|
+
if (group) {
|
|
53
|
+
group.push(patch);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
grouped.set(patch.skillId, [patch]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return grouped;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Applies regex-based content patches to installed skill files in the vault.
|
|
63
|
+
*
|
|
64
|
+
* Each patch targets a `skillId`, performing a case-insensitive global regex
|
|
65
|
+
* replacement on the skill's SKILL.md content. Multiple patches for the same
|
|
66
|
+
* skill are applied in order. Invalid regex patterns are silently skipped.
|
|
67
|
+
*
|
|
68
|
+
* This function is idempotent — re-running with the same patches on already-patched
|
|
69
|
+
* content produces no further changes (the regex simply won't match).
|
|
70
|
+
*/
|
|
71
|
+
export function applySkillPatches(vaultDir, index, patches) {
|
|
72
|
+
if (patches.length === 0)
|
|
73
|
+
return;
|
|
74
|
+
const entryById = new Map();
|
|
75
|
+
for (const entry of index) {
|
|
76
|
+
entryById.set(entry.id, entry);
|
|
77
|
+
}
|
|
78
|
+
const patchesBySkill = groupPatchesBySkill(patches);
|
|
79
|
+
for (const [skillId, skillPatches] of patchesBySkill) {
|
|
80
|
+
const entry = entryById.get(skillId);
|
|
81
|
+
if (!entry)
|
|
82
|
+
continue;
|
|
83
|
+
const skillFile = resolveSkillFile(vaultDir, entry);
|
|
84
|
+
if (!skillFile)
|
|
85
|
+
continue;
|
|
86
|
+
let content = fs.readFileSync(skillFile, "utf-8");
|
|
87
|
+
let modified = false;
|
|
88
|
+
for (const patch of skillPatches) {
|
|
89
|
+
const result = applySinglePatch(content, patch);
|
|
90
|
+
if (result !== undefined) {
|
|
91
|
+
content = result;
|
|
92
|
+
modified = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (modified) {
|
|
96
|
+
fs.writeFileSync(skillFile, content, "utf-8");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/package.json
CHANGED