opencode-skills-collection 2.0.282 → 3.0.0-beta.1

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,10 @@
1
+ import type { RiskLevel } from "./risk-level.js";
2
+ export interface SkillRiskFilterConfig {
3
+ excludedRiskLevels?: RiskLevel[];
4
+ excludedSkills?: string[];
5
+ }
6
+ /**
7
+ * Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
8
+ * @param configPath Optional override (for testing).
9
+ */
10
+ export declare function loadFilterConfig(configPath?: string): SkillRiskFilterConfig;
@@ -0,0 +1,41 @@
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
+ /**
10
+ * Strips JSONC comments so content can be parsed as plain JSON.
11
+ * Uses strip-json-comments library to properly handle strings.
12
+ */
13
+ function stripJsoncComments(content) {
14
+ return stripJsonComments(content);
15
+ }
16
+ /**
17
+ * Loads filter config from skill-filter.jsonc. Missing file or section returns defaults.
18
+ * @param configPath Optional override (for testing).
19
+ */
20
+ export function loadFilterConfig(configPath) {
21
+ const resolvedPath = configPath ?? path.join(os.homedir(), ".config", "opencode", "skill-filter.jsonc");
22
+ if (!fs.existsSync(resolvedPath)) {
23
+ return { ...DEFAULT_CONFIG };
24
+ }
25
+ try {
26
+ const raw = fs.readFileSync(resolvedPath, "utf-8");
27
+ const stripped = stripJsoncComments(raw);
28
+ const parsed = JSON.parse(stripped);
29
+ return {
30
+ excludedRiskLevels: Array.isArray(parsed.excludedRiskLevels)
31
+ ? parsed.excludedRiskLevels
32
+ : DEFAULT_CONFIG.excludedRiskLevels,
33
+ excludedSkills: Array.isArray(parsed.excludedSkills)
34
+ ? parsed.excludedSkills
35
+ : DEFAULT_CONFIG.excludedSkills,
36
+ };
37
+ }
38
+ catch {
39
+ return { ...DEFAULT_CONFIG };
40
+ }
41
+ }
@@ -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 } 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 || path.join(os.homedir(), ".config", "opencode", "skill-filter.jsonc");
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, " ")
@@ -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,8 @@ 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) {
56
+ for (const [categoryName, skills] of byCategory.entries()) {
58
57
  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)
63
- 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
- }));
70
- }
71
58
  const pointerDir = path.join(activeSkillsDir, `${categoryName}${POINTER_SUFFIX}`);
72
59
  ensureDir(pointerDir);
73
60
  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,13 @@ 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
+ const valid = ["none", "safe", "critical", "offensive", "unknown"];
18
+ return valid.includes(parsed) ? parsed : "unknown";
19
+ }
13
20
  /**
14
21
  * Derives a category slug from a skill folder name by taking the
15
22
  * first hyphen-separated segment (e.g. "laravel-expert" → "laravel",
@@ -40,7 +47,9 @@ function buildIndexFromBundledSkills(bundledSkillsPath) {
40
47
  const name = parseFrontmatterField(content, "name") || entry;
41
48
  const description = parseFrontmatterField(content, "description") || name;
42
49
  const category = parseFrontmatterField(content, "category") || categoryFromFolderName(entry);
43
- index.push({ id: entry, category, name, description });
50
+ const riskRaw = parseFrontmatterField(content, "risk");
51
+ const risk = normalizeRisk(riskRaw);
52
+ index.push({ id: entry, category, name, description, risk });
44
53
  }
45
54
  return index;
46
55
  }
@@ -69,17 +78,12 @@ export function loadSkillsIndex(bundledSkillsPath) {
69
78
  export function installSkillsToVault(bundledSkillsPath, vaultDir, index) {
70
79
  if (!fs.existsSync(bundledSkillsPath))
71
80
  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")
77
- continue;
78
- const srcPath = path.join(bundledSkillsPath, entry);
79
- if (!fs.statSync(srcPath).isDirectory())
81
+ for (const entry of index) {
82
+ const srcPath = path.join(bundledSkillsPath, entry.id);
83
+ if (!fs.existsSync(srcPath) || !fs.statSync(srcPath).isDirectory())
80
84
  continue;
81
- const category = categoryMap.get(entry) ?? UNCATEGORIZED_CATEGORY;
82
- const destPath = path.join(vaultDir, category, entry);
85
+ const category = entry.category ?? UNCATEGORIZED_CATEGORY;
86
+ const destPath = path.join(vaultDir, category, entry.id);
83
87
  ensureDir(path.join(vaultDir, category));
84
88
  fs.cpSync(srcPath, destPath, { recursive: true, force: true });
85
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-skills-collection",
3
- "version": "2.0.282",
3
+ "version": "3.0.0-beta.1",
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": "^0.0.30",
42
45
  "typescript": "^6.0.2"
43
46
  }
44
47
  }