opencode-skills-collection 2.0.300 → 3.0.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="./docs/logo.svg" alt="OpenCode Skills Collection"/>
3
+ <img src="docs/assets/logo.svg" alt="OpenCode Skills Collection"/>
4
4
 
5
5
  <br/>
6
6
  <br/>
@@ -36,8 +36,7 @@ The plugin operates in two phases:
36
36
 
37
37
  **1. Local deployment (startup)**
38
38
 
39
- When OpenCode starts, the plugin copies the pre-bundled skills from the npm package and runs the **SkillPointer pipeline
40
- **:
39
+ When OpenCode starts, the plugin copies the pre-bundled skills from the npm package and runs the SkillPointer pipeline:
41
40
 
42
41
  ```
43
42
  bundled-skills/ (npm package)
@@ -88,7 +87,7 @@ After the first startup, your `~/.config/opencode/` directory looks like this:
88
87
 
89
88
  | | Without SkillPointer | With SkillPointer |
90
89
  |----------------------|----------------------|---------------------|
91
- | Folders in `skills/` | ~1000 | ~35 |
90
+ | Folders in `skills/` | ~1000 | ~35 |
92
91
  | Tokens at startup | ~80,000 | ~255 |
93
92
  | Skills available | All injected upfront | On-demand via vault |
94
93
  | Compaction loops | ✗ frequent | ✓ none |
@@ -140,6 +139,39 @@ opencode run /refactor clean up this function
140
139
 
141
140
  ---
142
141
 
142
+ ## Skill Risk Filter
143
+
144
+ The plugin supports configurable risk-based filtering of skills. By default, **all skills are loaded** — filtering is
145
+ opt-in.
146
+
147
+ Each skill in the index has a `risk` field with one of these levels:
148
+
149
+ | Level | Description |
150
+ |-------------|--------------------------------------------------------------------|
151
+ | `none` | No risk assessment |
152
+ | `safe` | Verified safe |
153
+ | `critical` | Contains sensitive operations |
154
+ | `offensive` | Contains offensive security tools (exploits, reverse shells, etc.) |
155
+ | `unknown` | Not yet classified |
156
+
157
+ ### Configuration
158
+
159
+ Create a `~/.config/opencode/skill-filter.jsonc` file:
160
+
161
+ ```jsonc
162
+ {
163
+ "excludedRiskLevels": ["offensive"],
164
+ "excludedSkills": ["windows-privilege-escalation"]
165
+ }
166
+ ```
167
+
168
+ - **`excludedRiskLevels`**: Array of risk levels to block entirely
169
+ - **`excludedSkills`**: Array of specific skill IDs to block
170
+
171
+ Blocked skills are excluded from both the vault and the generated pointers — they are never loaded into context.
172
+
173
+ ---
174
+
143
175
  ## Development
144
176
 
145
177
  **Requirements:** Node.js ≥ 20, TypeScript ≥ 5
@@ -0,0 +1,11 @@
1
+ import type { RiskLevel } from "./risk-level.js";
2
+ export interface SkillRiskFilterConfig {
3
+ excludedRiskLevels?: RiskLevel[];
4
+ excludedSkills?: string[];
5
+ }
6
+ export declare const DEFAULT_FILTER_CONFIG_PATH: string;
7
+ /**
8
+ * Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
9
+ * @param configPath Optional override (for testing).
10
+ */
11
+ export declare function loadFilterConfig(configPath?: string): SkillRiskFilterConfig;
@@ -0,0 +1,36 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import stripJsonComments from "strip-json-comments";
5
+ const DEFAULT_CONFIG = {
6
+ excludedRiskLevels: [],
7
+ excludedSkills: [],
8
+ };
9
+ export const DEFAULT_FILTER_CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "skill-filter.jsonc");
10
+ const VALID_RISK_LEVELS = ["none", "safe", "critical", "offensive", "unknown"];
11
+ /**
12
+ * Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
13
+ * @param configPath Optional override (for testing).
14
+ */
15
+ export function loadFilterConfig(configPath) {
16
+ const resolvedPath = configPath ?? DEFAULT_FILTER_CONFIG_PATH;
17
+ if (!fs.existsSync(resolvedPath)) {
18
+ return { ...DEFAULT_CONFIG };
19
+ }
20
+ try {
21
+ const raw = fs.readFileSync(resolvedPath, "utf-8");
22
+ const stripped = stripJsonComments(raw);
23
+ const parsed = JSON.parse(stripped);
24
+ return {
25
+ excludedRiskLevels: Array.isArray(parsed.excludedRiskLevels)
26
+ ? parsed.excludedRiskLevels.filter((v) => VALID_RISK_LEVELS.includes(v))
27
+ : DEFAULT_CONFIG.excludedRiskLevels,
28
+ excludedSkills: Array.isArray(parsed.excludedSkills)
29
+ ? parsed.excludedSkills
30
+ : DEFAULT_CONFIG.excludedSkills,
31
+ };
32
+ }
33
+ catch {
34
+ return { ...DEFAULT_CONFIG };
35
+ }
36
+ }
@@ -8,6 +8,10 @@ export interface SkillPointerOptions {
8
8
  * Defaults to ~/.config/opencode/skill-libraries
9
9
  */
10
10
  vaultDir?: string;
11
+ /**
12
+ * Optional path to the risk filter configuration file.
13
+ */
14
+ configPath?: string;
11
15
  }
12
16
  /**
13
17
  * Orchestrates the full SkillPointer pipeline:
@@ -4,6 +4,8 @@ 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
+ import { filterIndex } from "./skill-risk-filter.js";
8
+ import { loadFilterConfig, DEFAULT_FILTER_CONFIG_PATH } from "./config-loader.js";
7
9
  function resolveDefaultVaultDir() {
8
10
  return path.join(os.homedir(), ".config", "opencode", VAULT_DIR_NAME);
9
11
  }
@@ -23,6 +25,9 @@ export function runSkillPointer(options) {
23
25
  ensureDir(options.activeSkillsDir);
24
26
  ensureDir(vaultDir);
25
27
  const index = loadSkillsIndex(options.bundledSkillsPath);
26
- installSkillsToVault(options.bundledSkillsPath, vaultDir, index);
27
- generatePointers(options.activeSkillsDir, vaultDir, index);
28
+ const configPath = options.configPath ?? DEFAULT_FILTER_CONFIG_PATH;
29
+ const config = loadFilterConfig(configPath);
30
+ const filteredIndex = filterIndex(index, config);
31
+ installSkillsToVault(options.bundledSkillsPath, vaultDir, filteredIndex);
32
+ generatePointers(options.activeSkillsDir, vaultDir, filteredIndex);
28
33
  }
@@ -1,7 +1,7 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import { POINTER_SUFFIX, SKILL_FILENAME, UNCATEGORIZED_CATEGORY } from "../constants/constants.js";
4
- import { ensureDir, listSubdirectories } from "../utils/fs.utils.js";
4
+ import { ensureDir } from "../utils/fs.utils.js";
5
5
  function buildPointerContent(category, skills, libraryPath) {
6
6
  const title = category
7
7
  .replace(/-/g, " ")
@@ -14,7 +14,7 @@ function buildPointerContent(category, skills, libraryPath) {
14
14
  return `---
15
15
  name: ${category}${POINTER_SUFFIX}
16
16
  description: "Pointer to a library of ${skillCount} specialized ${title} skills. Use when working on ${category}-related tasks."
17
- risk: safe
17
+ risk: none
18
18
  ---
19
19
 
20
20
  # ${title} Capability Library 🎯
@@ -46,7 +46,6 @@ ${skillList}
46
46
  * via get_available_skills without loading every SKILL.md.
47
47
  */
48
48
  export function generatePointers(activeSkillsDir, vaultDir, index = []) {
49
- const categoryDirs = listSubdirectories(vaultDir);
50
49
  const byCategory = new Map();
51
50
  for (const entry of index) {
52
51
  const cat = entry.category ?? UNCATEGORIZED_CATEGORY;
@@ -54,20 +53,18 @@ export function generatePointers(activeSkillsDir, vaultDir, index = []) {
54
53
  byCategory.set(cat, []);
55
54
  byCategory.get(cat).push(entry);
56
55
  }
57
- for (const categoryName of categoryDirs) {
58
- const categoryVaultPath = path.join(vaultDir, categoryName);
59
- let skills = byCategory.get(categoryName) ?? [];
60
- if (skills.length === 0) {
61
- const subDirs = fs.readdirSync(categoryVaultPath).filter((e) => fs.statSync(path.join(categoryVaultPath, e)).isDirectory());
62
- if (subDirs.length === 0)
56
+ const expectedPointers = new Set([...byCategory.keys()].map((c) => `${c}${POINTER_SUFFIX}`));
57
+ if (fs.existsSync(activeSkillsDir)) {
58
+ for (const dirent of fs.readdirSync(activeSkillsDir, { withFileTypes: true })) {
59
+ if (!dirent.isDirectory() || !dirent.name.endsWith(POINTER_SUFFIX))
63
60
  continue;
64
- skills = subDirs.map((dir) => ({
65
- id: dir,
66
- category: categoryName,
67
- name: dir.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()),
68
- description: dir.replace(/-/g, " "),
69
- }));
61
+ if (!expectedPointers.has(dirent.name)) {
62
+ fs.rmSync(path.join(activeSkillsDir, dirent.name), { recursive: true, force: true });
63
+ }
70
64
  }
65
+ }
66
+ for (const [categoryName, skills] of byCategory.entries()) {
67
+ const categoryVaultPath = path.join(vaultDir, categoryName);
71
68
  const pointerDir = path.join(activeSkillsDir, `${categoryName}${POINTER_SUFFIX}`);
72
69
  ensureDir(pointerDir);
73
70
  fs.writeFileSync(path.join(pointerDir, SKILL_FILENAME), buildPointerContent(categoryName, skills, categoryVaultPath), "utf-8");
@@ -0,0 +1 @@
1
+ export type RiskLevel = "none" | "safe" | "critical" | "offensive" | "unknown";
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { RiskLevel } from "./risk-level.js";
2
+ import type { SkillIndexEntry } from "./vault-installer.js";
3
+ import type { SkillRiskFilterConfig } from "./config-loader.js";
4
+ export declare function shouldLoad(skillId: string, risk: RiskLevel | undefined, config: SkillRiskFilterConfig): boolean;
5
+ export declare function filterIndex(index: SkillIndexEntry[], config: SkillRiskFilterConfig): SkillIndexEntry[];
@@ -0,0 +1,15 @@
1
+ export function shouldLoad(skillId, risk, config) {
2
+ const effectiveRisk = risk ?? "unknown";
3
+ if ((config.excludedRiskLevels ?? []).includes(effectiveRisk)) {
4
+ return false;
5
+ }
6
+ if ((config.excludedSkills ?? []).includes(skillId)) {
7
+ return false;
8
+ }
9
+ return true;
10
+ }
11
+ export function filterIndex(index, config) {
12
+ return index.filter((entry) => {
13
+ return shouldLoad(entry.id, entry.risk, config);
14
+ });
15
+ }
@@ -1,8 +1,10 @@
1
+ import type { RiskLevel } from "./risk-level.js";
1
2
  export interface SkillIndexEntry {
2
3
  id: string;
3
4
  category: string;
4
5
  name: string;
5
6
  description: string;
7
+ risk?: RiskLevel;
6
8
  }
7
9
  /**
8
10
  * Loads the pre-built skills_index.json from the project root.
@@ -10,6 +10,16 @@ function parseFrontmatterField(content, field) {
10
10
  const match = content.match(new RegExp(`^${field}:\\s*["']?([^"'\\n]+)["']?`, "m"));
11
11
  return match ? match[1].trim() : "";
12
12
  }
13
+ /**
14
+ * Normalizes a parsed risk value to a valid RiskLevel, or "unknown" if invalid.
15
+ */
16
+ function normalizeRisk(parsed) {
17
+ if (!parsed)
18
+ return "unknown";
19
+ const lowered = parsed.trim().toLowerCase();
20
+ const valid = ["none", "safe", "critical", "offensive", "unknown"];
21
+ return valid.includes(lowered) ? lowered : "unknown";
22
+ }
13
23
  /**
14
24
  * Derives a category slug from a skill folder name by taking the
15
25
  * first hyphen-separated segment (e.g. "laravel-expert" → "laravel",
@@ -40,10 +50,15 @@ function buildIndexFromBundledSkills(bundledSkillsPath) {
40
50
  const name = parseFrontmatterField(content, "name") || entry;
41
51
  const description = parseFrontmatterField(content, "description") || name;
42
52
  const category = parseFrontmatterField(content, "category") || categoryFromFolderName(entry);
43
- index.push({ id: entry, category, name, description });
53
+ const riskRaw = parseFrontmatterField(content, "risk");
54
+ const risk = normalizeRisk(riskRaw);
55
+ index.push({ id: entry, category, name, description, risk });
44
56
  }
45
57
  return index;
46
58
  }
59
+ function isSafePathComponent(segment) {
60
+ return !segment.includes("..") && !segment.includes(path.sep) && !segment.includes("/");
61
+ }
47
62
  /**
48
63
  * Loads the pre-built skills_index.json from the project root.
49
64
  * Falls back to a dynamically generated index from SKILL.md frontmatter
@@ -54,7 +69,10 @@ export function loadSkillsIndex(bundledSkillsPath) {
54
69
  if (fs.existsSync(indexPath)) {
55
70
  try {
56
71
  const raw = fs.readFileSync(indexPath, "utf-8");
57
- return JSON.parse(raw);
72
+ return JSON.parse(raw).map((entry) => ({
73
+ ...entry,
74
+ risk: normalizeRisk(entry.risk),
75
+ }));
58
76
  }
59
77
  catch {
60
78
  // fall through to dynamic generation
@@ -69,17 +87,40 @@ export function loadSkillsIndex(bundledSkillsPath) {
69
87
  export function installSkillsToVault(bundledSkillsPath, vaultDir, index) {
70
88
  if (!fs.existsSync(bundledSkillsPath))
71
89
  return;
72
- const categoryMap = new Map(index.map((e) => [e.id, e.category ?? UNCATEGORIZED_CATEGORY]));
73
- for (const entry of fs.readdirSync(bundledSkillsPath)) {
74
- if (entry.startsWith(".") ||
75
- entry === "skills_index.json" ||
76
- entry === "README.md")
90
+ const expected = new Map();
91
+ for (const entry of index) {
92
+ if (!isSafePathComponent(entry.id) || !isSafePathComponent(entry.category ?? UNCATEGORIZED_CATEGORY))
93
+ continue;
94
+ const category = entry.category ?? UNCATEGORIZED_CATEGORY;
95
+ if (!expected.has(category))
96
+ expected.set(category, new Set());
97
+ expected.get(category).add(entry.id);
98
+ }
99
+ if (fs.existsSync(vaultDir)) {
100
+ for (const category of fs.readdirSync(vaultDir)) {
101
+ const categoryPath = path.join(vaultDir, category);
102
+ if (!fs.statSync(categoryPath).isDirectory())
103
+ continue;
104
+ const allowedSkills = expected.get(category) ?? new Set();
105
+ for (const skillId of fs.readdirSync(categoryPath)) {
106
+ const skillPath = path.join(categoryPath, skillId);
107
+ if (!allowedSkills.has(skillId)) {
108
+ fs.rmSync(skillPath, { recursive: true, force: true });
109
+ }
110
+ }
111
+ if (fs.readdirSync(categoryPath).length === 0) {
112
+ fs.rmSync(categoryPath, { recursive: true, force: true });
113
+ }
114
+ }
115
+ }
116
+ for (const entry of index) {
117
+ if (!isSafePathComponent(entry.id) || !isSafePathComponent(entry.category ?? UNCATEGORIZED_CATEGORY))
77
118
  continue;
78
- const srcPath = path.join(bundledSkillsPath, entry);
79
- if (!fs.statSync(srcPath).isDirectory())
119
+ const srcPath = path.join(bundledSkillsPath, entry.id);
120
+ if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isDirectory())
80
121
  continue;
81
- const category = categoryMap.get(entry) ?? UNCATEGORIZED_CATEGORY;
82
- const destPath = path.join(vaultDir, category, entry);
122
+ const category = entry.category ?? UNCATEGORIZED_CATEGORY;
123
+ const destPath = path.join(vaultDir, category, entry.id);
83
124
  ensureDir(path.join(vaultDir, category));
84
125
  fs.cpSync(srcPath, destPath, { recursive: true, force: true });
85
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-collection",
3
- "version": "2.0.300",
3
+ "version": "3.0.0",
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",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "scripts": {
21
21
  "build": "tsc",
22
+ "test": "bun test",
22
23
  "prepublishOnly": "npm run build"
23
24
  },
24
25
  "keywords": [
@@ -33,12 +34,14 @@
33
34
  "author": "Davide Ladisa <info@davideladisa.it>",
34
35
  "license": "MIT",
35
36
  "dependencies": {
36
- "@opencode-ai/plugin": "^1.4.0"
37
+ "@opencode-ai/plugin": "^1.4.0",
38
+ "strip-json-comments": "^5.0.3"
37
39
  },
38
40
  "devDependencies": {
39
41
  "@opencode-ai/sdk": "^1.4.0",
40
42
  "@types/bun": "latest",
41
43
  "@types/node": "^25.5.2",
44
+ "@types/strip-json-comments": "^3.0.0",
42
45
  "typescript": "^6.0.2"
43
46
  }
44
47
  }