cipher-security 5.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.
Files changed (75) hide show
  1. package/bin/cipher.js +465 -0
  2. package/lib/api/billing.js +321 -0
  3. package/lib/api/compliance.js +693 -0
  4. package/lib/api/controls.js +1401 -0
  5. package/lib/api/index.js +49 -0
  6. package/lib/api/marketplace.js +467 -0
  7. package/lib/api/openai-proxy.js +383 -0
  8. package/lib/api/server.js +685 -0
  9. package/lib/autonomous/feedback-loop.js +554 -0
  10. package/lib/autonomous/framework.js +512 -0
  11. package/lib/autonomous/index.js +97 -0
  12. package/lib/autonomous/leaderboard.js +594 -0
  13. package/lib/autonomous/modes/architect.js +412 -0
  14. package/lib/autonomous/modes/blue.js +386 -0
  15. package/lib/autonomous/modes/incident.js +684 -0
  16. package/lib/autonomous/modes/privacy.js +369 -0
  17. package/lib/autonomous/modes/purple.js +294 -0
  18. package/lib/autonomous/modes/recon.js +250 -0
  19. package/lib/autonomous/parallel.js +587 -0
  20. package/lib/autonomous/researcher.js +583 -0
  21. package/lib/autonomous/runner.js +955 -0
  22. package/lib/autonomous/scheduler.js +615 -0
  23. package/lib/autonomous/task-parser.js +127 -0
  24. package/lib/autonomous/validators/forensic.js +266 -0
  25. package/lib/autonomous/validators/osint.js +216 -0
  26. package/lib/autonomous/validators/privacy.js +296 -0
  27. package/lib/autonomous/validators/purple.js +298 -0
  28. package/lib/autonomous/validators/sigma.js +248 -0
  29. package/lib/autonomous/validators/threat-model.js +363 -0
  30. package/lib/benchmark/agent.js +119 -0
  31. package/lib/benchmark/baselines.js +43 -0
  32. package/lib/benchmark/builder.js +143 -0
  33. package/lib/benchmark/config.js +35 -0
  34. package/lib/benchmark/coordinator.js +91 -0
  35. package/lib/benchmark/index.js +20 -0
  36. package/lib/benchmark/llm.js +58 -0
  37. package/lib/benchmark/models.js +137 -0
  38. package/lib/benchmark/reporter.js +103 -0
  39. package/lib/benchmark/runner.js +103 -0
  40. package/lib/benchmark/sandbox.js +96 -0
  41. package/lib/benchmark/scorer.js +32 -0
  42. package/lib/benchmark/solver.js +166 -0
  43. package/lib/benchmark/tools.js +62 -0
  44. package/lib/bot/bot.js +130 -0
  45. package/lib/commands.js +99 -0
  46. package/lib/complexity.js +377 -0
  47. package/lib/config.js +213 -0
  48. package/lib/gateway/client.js +309 -0
  49. package/lib/gateway/commands.js +830 -0
  50. package/lib/gateway/config-validate.js +109 -0
  51. package/lib/gateway/gateway.js +367 -0
  52. package/lib/gateway/index.js +62 -0
  53. package/lib/gateway/mode.js +309 -0
  54. package/lib/gateway/plugins.js +222 -0
  55. package/lib/gateway/prompt.js +214 -0
  56. package/lib/mcp/server.js +262 -0
  57. package/lib/memory/compressor.js +425 -0
  58. package/lib/memory/engine.js +763 -0
  59. package/lib/memory/evolution.js +668 -0
  60. package/lib/memory/index.js +58 -0
  61. package/lib/memory/orchestrator.js +506 -0
  62. package/lib/memory/retriever.js +515 -0
  63. package/lib/memory/synthesizer.js +333 -0
  64. package/lib/pipeline/async-scanner.js +510 -0
  65. package/lib/pipeline/binary-analysis.js +1043 -0
  66. package/lib/pipeline/dom-xss-scanner.js +435 -0
  67. package/lib/pipeline/github-actions.js +792 -0
  68. package/lib/pipeline/index.js +124 -0
  69. package/lib/pipeline/osint.js +498 -0
  70. package/lib/pipeline/sarif.js +373 -0
  71. package/lib/pipeline/scanner.js +880 -0
  72. package/lib/pipeline/template-manager.js +525 -0
  73. package/lib/pipeline/xss-scanner.js +353 -0
  74. package/lib/setup-wizard.js +229 -0
  75. package/package.json +30 -0
@@ -0,0 +1,309 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * mode.js — Mode detection for CIPHER gateway.
7
+ *
8
+ * Ports Python gateway/mode.py with behavioral fidelity:
9
+ * - parseKeywordTable: extract mode→keywords from CLAUDE.md trigger table
10
+ * - detectMode: route message to operating mode via explicit prefix or keyword scoring
11
+ * - stripModePrefix: remove [MODE: X] prefix from message text
12
+ *
13
+ * @module gateway/mode
14
+ */
15
+
16
+ // Matches [MODE: X] at the start of a message, case-insensitive.
17
+ const EXPLICIT_RE = /^\[MODE:\s*(\w+)\]/i;
18
+
19
+ // Matches the trigger keywords table block inside CLAUDE.md.
20
+ // Python: re.DOTALL → JS `s` flag, re.IGNORECASE → `i` flag.
21
+ const TABLE_HEADER_RE = /Trigger Keywords.*?\n\|[-| ]+\|\n(.*?)(?:\n-+|\n\n|$)/si;
22
+
23
+ // Individual data row: | WORD | content |
24
+ // Python: re.MULTILINE → JS `m` flag.
25
+ const TABLE_ROW_RE = /^\|\s*(\w+)\s*\|\s*(.+?)\s*\|/gm;
26
+
27
+ // Known header/separator cell values to skip.
28
+ const SKIP_ROW_NAMES = new Set(['MODE', '---']);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Supplementary keywords — ported verbatim from Python mode.py
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** @type {Record<string, string[]>} */
35
+ const SUPPLEMENTARY_KEYWORDS = {
36
+ RED: [
37
+ // Tools
38
+ 'smb', 'nmap', 'bloodhound', 'mimikatz', 'impacket', 'cobaltstrike',
39
+ 'cobalt strike', 'msfconsole', 'metasploit', 'hashcat', 'john the ripper',
40
+ 'burp suite', 'sqlmap', 'ettercap', 'bettercap', 'scapy', 'hydra',
41
+ 'crackmapexec', 'evil-winrm', 'chisel', 'ligolo', 'rubeus', 'certipy',
42
+ 'responder', 'beef', 'empire', 'sliver', 'havoc',
43
+ // Techniques
44
+ 'brute force', 'credential stuffing', 'credential dumping', 'kerberoast',
45
+ 'as-rep', 'pentest', 'penetration test', 'post-exploitation', 'exfiltrate',
46
+ 'exfiltration', 'privilege escalation', 'escalate privilege', 'initial access',
47
+ 'dcsync', 'secretsdump', 'pass the hash', 'pass the ticket', 'golden ticket',
48
+ 'silver ticket', 'ntlm relay', 'sql injection', 'xss', 'cross-site scripting',
49
+ 'command injection', 'buffer overflow', 'heap spray', 'rop chain', 'shellcode',
50
+ 'arp spoofing', 'arp poisoning', 'arp cache poisoning', 'man in the middle',
51
+ 'man-in-the-middle', 'session hijacking', 'dns tunneling', 'dns poisoning',
52
+ 'phishing campaign', 'phishing website', 'phishing email', 'spear phishing',
53
+ 'spear-phishing', 'covert channel', 'webshell', 'web shell',
54
+ 'reverse engineering', 'dll injection', 'process injection',
55
+ 'token impersonation', 'tokenimpersonation', 'password spraying',
56
+ 'password spray', 'kerberos', 'ntlm', 'lsass', 'sam database',
57
+ 'crack password', 'crack hash', 'pivot', 'pivoting',
58
+ // Attack types
59
+ 'vlan hopping', 'ssl stripping', 'ssl strip', 'syn flood', 'ddos attack',
60
+ 'ddos', 'dos attack', 'malicious usb', 'rubber ducky', 'evil twin',
61
+ 'rogue ap', 'poisoning attack', 'spoofing attack', 'injection attack',
62
+ 'race condition exploit', 'deserialization', 'ssrf', 'csrf', 'lfi', 'rfi',
63
+ 'path traversal attack', 'directory traversal', 'xml external entity', 'xxe',
64
+ 'ssti', 'template injection', 'type juggling', 'prototype pollution',
65
+ 'insecure deserialization',
66
+ ],
67
+
68
+ BLUE: [
69
+ // Tools
70
+ 'splunk', 'elastic', 'elasticsearch', 'kibana', 'sentinel', 'kql', 'spl',
71
+ 'yara', 'snort', 'suricata', 'zeek', 'osquery', 'wazuh', 'velociraptor',
72
+ 'carbon black', 'crowdstrike', 'elk stack', 'logstash', 'graylog', 'qradar',
73
+ 'arcsight', 'chronicle', 'datadog security',
74
+ // Techniques
75
+ 'alert rule', 'alert rules', 'detect malware', 'detect lateral',
76
+ 'detect exfiltration', 'detect anomal', 'log correlation', 'log monitoring',
77
+ 'baseline', 'false positive', 'false positives', 'tuning', 'rule tuning',
78
+ 'ioc detection', 'behavioral detection', 'anomaly detection',
79
+ 'network monitoring', 'file integrity', 'audit policy', 'audit policies',
80
+ 'windows event', 'event log', 'syslog', 'sysmon',
81
+ // From scale eval failures
82
+ 'honeypot', 'honeynet', 'deception technology', 'canary token',
83
+ 'default credential', 'continuous monitoring', 'security automation',
84
+ 'security playbook', 'soar', 'dwell time', 'mean time to detect', 'mttd',
85
+ 'mean time to respond', 'mttr', 'security posture', 'threat visibility',
86
+ 'security stack', 'powershell logging',
87
+ ],
88
+
89
+ PURPLE: [
90
+ 'att&ck coverage', 'mitre coverage', 'detection gap', 'detection gaps',
91
+ 'coverage gap', 'coverage gaps', 'emulate', 'adversary emulation',
92
+ 'atomic red team', 'caldera', 'purple team', 'detection coverage',
93
+ 'how well do our', 'gaps in our', 'gaps in detecting',
94
+ 'test our detection', 'test our defenses', 'simulate attack',
95
+ 'simulate an attack', 'rules against', 'evaluate our',
96
+ 'effectiveness of our',
97
+ ],
98
+
99
+ INCIDENT: [
100
+ 'compromise', 'compromised', 'ransomware', 'malware found', 'we found',
101
+ 'active breach', 'evidence collection', 'got an alert', 'beacon callback',
102
+ 'what do we do', 'we detected', 'suspicious activity', 'active incident',
103
+ 'we suspect', 'forensic analysis', 'forensic investigation',
104
+ 'memory forensics', 'disk forensics', 'how do we proceed',
105
+ 'how should we respond', 'how do we contain', 'contain the breach',
106
+ 'containment plan', 'our siem shows', 'our ids detected',
107
+ 'our firewall logs show', 'signs of lateral', 'unusual traffic',
108
+ 'unusual activity', 'incident runbook', 'evidence preservation',
109
+ 'chain of custody', 'root cause analysis', 'post-incident', 'after-action',
110
+ ],
111
+
112
+ RECON: [
113
+ 'enumerate subdomains', 'subdomain enumeration', 'dns recon', 'shodan',
114
+ 'censys', 'whois', 'footprinting', 'footprint', 'gather intelligence',
115
+ 'intelligence gathering', 'attack surface', 'asset discovery',
116
+ 'exposed services', 'exposed assets', 'open ports', 'port scanning',
117
+ 'banner grabbing', 'google dorking', 'google dork', 'github recon',
118
+ 'linkedin', 'email harvesting', 'email addresses', 'amass', 'subfinder',
119
+ 'httpx', 'theharvester', 'recon-ng', 'maltego', 'spiderfoot', 's3 bucket',
120
+ 'cloud enumeration', 'digital presence', 'network footprint',
121
+ ],
122
+ };
123
+
124
+ // Keywords too generic to be decisive — score 0.5 instead of word count.
125
+ const GENERIC_KEYWORDS = new Set([
126
+ 'enumerate', 'credential', 'persistence', 'detection',
127
+ 'att&ck', 'network', 'firewall',
128
+ ]);
129
+
130
+ // Defensive signals that boost BLUE and reduce RED.
131
+ const DEFENSIVE_SIGNALS = [
132
+ 'detect', 'detecting', 'alert on', 'alerting', 'monitor for',
133
+ 'monitoring', 'hunt for', 'hunting', 'identify indicator',
134
+ 'write a rule', 'write a query', 'kql ',
135
+ 'yara rule', 'snort rule', 'suricata rule',
136
+ 'indicators of', 'how do i detect', 'how can i detect',
137
+ 'how to detect', 'for detecting', 'rule for',
138
+ 'detection rule', 'to identify potential', 'to detect',
139
+ ];
140
+
141
+ // Incident context signals.
142
+ const INCIDENT_SIGNALS = [
143
+ 'we suspect', 'we found', 'we detected', 'our siem',
144
+ 'our ids', 'our firewall', 'how do we', 'how should we',
145
+ 'contain', 'respond to', 'triage', 'runbook',
146
+ ];
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Public API
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Parse the `| Mode | Trigger Keywords |` table from CLAUDE.md text.
154
+ *
155
+ * Returns a dict mapping UPPERCASE mode names to arrays of lowercase,
156
+ * stripped keyword strings. Returns empty object if the table is absent.
157
+ *
158
+ * @param {string} claudeMdText - Raw CLAUDE.md content
159
+ * @returns {Record<string, string[]>}
160
+ */
161
+ export function parseKeywordTable(claudeMdText) {
162
+ if (!claudeMdText || !claudeMdText.trim()) {
163
+ return {};
164
+ }
165
+
166
+ let rowsText;
167
+ const headerMatch = TABLE_HEADER_RE.exec(claudeMdText);
168
+ if (!headerMatch) {
169
+ // Try a looser parse: scan full text for table rows
170
+ if (!claudeMdText.includes('Trigger Keywords')) {
171
+ return {};
172
+ }
173
+ rowsText = claudeMdText;
174
+ } else {
175
+ rowsText = headerMatch[0];
176
+ }
177
+
178
+ /** @type {Record<string, string[]>} */
179
+ const result = {};
180
+
181
+ // Reset lastIndex since TABLE_ROW_RE has /g flag
182
+ TABLE_ROW_RE.lastIndex = 0;
183
+ let rowMatch;
184
+ while ((rowMatch = TABLE_ROW_RE.exec(rowsText)) !== null) {
185
+ const mode = rowMatch[1].trim().toUpperCase();
186
+
187
+ // Skip table header row and separators
188
+ if (SKIP_ROW_NAMES.has(mode)) continue;
189
+
190
+ const rawKeywords = rowMatch[2];
191
+ // Skip if the "keyword" cell looks like a column header
192
+ if (rawKeywords.toLowerCase().includes('trigger keywords')) continue;
193
+
194
+ const keywords = rawKeywords
195
+ .split(',')
196
+ .map(kw => kw.trim().toLowerCase())
197
+ .filter(Boolean);
198
+
199
+ if (mode && keywords.length) {
200
+ result[mode] = keywords;
201
+ }
202
+ }
203
+
204
+ // Merge supplementary keywords
205
+ for (const [mode, extraKws] of Object.entries(SUPPLEMENTARY_KEYWORDS)) {
206
+ if (result[mode]) {
207
+ const existing = new Set(result[mode]);
208
+ for (const kw of extraKws) {
209
+ if (!existing.has(kw)) {
210
+ result[mode].push(kw);
211
+ }
212
+ }
213
+ } else {
214
+ result[mode] = [...extraKws];
215
+ }
216
+ }
217
+
218
+ return result;
219
+ }
220
+
221
+ /**
222
+ * Detect the CIPHER operating mode for a given message.
223
+ *
224
+ * Detection order:
225
+ * 1. Explicit [MODE: X] prefix → returns immediately
226
+ * 2. Weighted keyword scoring with supplementary keywords,
227
+ * defensive/incident boosting, and tie-breaking disambiguation
228
+ *
229
+ * @param {string} message - Raw user message text
230
+ * @param {Record<string, string[]>} keywordTable - From parseKeywordTable
231
+ * @returns {[string, boolean]} [mode, needsClarification]
232
+ */
233
+ export function detectMode(message, keywordTable) {
234
+ // 1. Check explicit prefix.
235
+ const prefixMatch = message.match(EXPLICIT_RE);
236
+ if (prefixMatch) {
237
+ return [prefixMatch[1].toUpperCase(), false];
238
+ }
239
+
240
+ // 2. Weighted keyword scoring.
241
+ const lowerMessage = message.toLowerCase();
242
+ /** @type {Record<string, number>} */
243
+ const modeScores = {};
244
+
245
+ for (const [mode, keywords] of Object.entries(keywordTable)) {
246
+ let score = 0;
247
+ for (const keyword of keywords) {
248
+ if (lowerMessage.includes(keyword)) {
249
+ // Multi-word phrases are more specific → higher weight
250
+ const wordCount = keyword.split(/\s+/).length;
251
+ const kwScore = GENERIC_KEYWORDS.has(keyword) ? 0.5 : wordCount;
252
+ score = Math.max(score, kwScore);
253
+ }
254
+ }
255
+ if (score > 0) {
256
+ modeScores[mode] = score;
257
+ }
258
+ }
259
+
260
+ // 3. Intent-based disambiguation
261
+ const hasDefensive = DEFENSIVE_SIGNALS.some(sig => lowerMessage.includes(sig));
262
+ const hasIncident = INCIDENT_SIGNALS.some(sig => lowerMessage.includes(sig));
263
+
264
+ if (hasDefensive && modeScores.BLUE !== undefined && modeScores.RED !== undefined) {
265
+ // Defensive context: boost BLUE, reduce RED
266
+ // But NOT if PURPLE is already scoring well
267
+ if (modeScores.PURPLE === undefined || modeScores.PURPLE < (modeScores.BLUE || 0)) {
268
+ modeScores.BLUE = modeScores.BLUE + 1.5;
269
+ modeScores.RED = Math.max(modeScores.RED - 1.0, 0.1);
270
+ }
271
+ }
272
+
273
+ if (hasIncident && modeScores.INCIDENT !== undefined && modeScores.RED !== undefined) {
274
+ // Incident context: boost INCIDENT, reduce RED
275
+ modeScores.INCIDENT = modeScores.INCIDENT + 1.5;
276
+ modeScores.RED = Math.max(modeScores.RED - 1.0, 0.1);
277
+ }
278
+
279
+ if (Object.keys(modeScores).length === 0) {
280
+ return ['ARCHITECT', false];
281
+ }
282
+
283
+ // Find the top score and all modes that achieved it.
284
+ const topScore = Math.max(...Object.values(modeScores));
285
+ const topModes = Object.entries(modeScores)
286
+ .filter(([, s]) => s >= topScore - 0.1)
287
+ .map(([m]) => m);
288
+
289
+ if (topModes.length === 1) {
290
+ return [topModes[0], false];
291
+ }
292
+
293
+ // Multiple modes tied — needs clarification.
294
+ const sortedModes = topModes.sort().join(',');
295
+ return [sortedModes, true];
296
+ }
297
+
298
+ /**
299
+ * Remove [MODE: X] prefix from message text.
300
+ *
301
+ * Strips any leading whitespace after the prefix. Returns the original
302
+ * string unchanged if no prefix is present.
303
+ *
304
+ * @param {string} message
305
+ * @returns {string}
306
+ */
307
+ export function stripModePrefix(message) {
308
+ return message.replace(EXPLICIT_RE, '').trimStart();
309
+ }
@@ -0,0 +1,222 @@
1
+ // Copyright (c) 2026 defconxt. All rights reserved.
2
+ // Licensed under AGPL-3.0 — see LICENSE file for details.
3
+ // CIPHER is a trademark of defconxt.
4
+
5
+ /**
6
+ * plugins.js — Plugin discovery and loading for CIPHER gateway.
7
+ *
8
+ * Ports Python gateway/plugins.py:
9
+ * - PluginManager: discovers plugins from ~/.config/cipher/plugins/
10
+ * - Each plugin dir needs plugin.yaml (parsed with yaml npm) and SKILL.md
11
+ * - Returns PluginInfo objects with name, version, modes, priority, skill_content
12
+ * - Graceful fallback when plugin directory doesn't exist
13
+ *
14
+ * @module gateway/plugins
15
+ */
16
+
17
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { parse as parseYaml } from 'yaml';
21
+
22
+ const USER_PLUGIN_DIR = join(homedir(), '.config', 'cipher', 'plugins');
23
+
24
+ /**
25
+ * @typedef {Object} PluginInfo
26
+ * @property {string} name
27
+ * @property {string} version
28
+ * @property {string} author
29
+ * @property {string} description
30
+ * @property {string[]} modes - Uppercase mode names this plugin activates for
31
+ * @property {string[]} triggers - Lowercase trigger keywords
32
+ * @property {number} priority - 0-100, lower = loaded first
33
+ * @property {string} path - Directory containing SKILL.md
34
+ * @property {string} source - "user" | "entrypoint"
35
+ * @property {string} skill_content - Loaded SKILL.md content
36
+ */
37
+
38
+ /**
39
+ * Load and validate plugin.yaml from a directory. Returns null on failure.
40
+ *
41
+ * @param {string} pluginDir
42
+ * @returns {Record<string, any> | null}
43
+ */
44
+ function loadPluginYaml(pluginDir) {
45
+ const yamlFile = join(pluginDir, 'plugin.yaml');
46
+ if (!existsSync(yamlFile)) return null;
47
+
48
+ try {
49
+ const raw = readFileSync(yamlFile, 'utf-8');
50
+ const data = parseYaml(raw);
51
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
52
+ if (!data.name) return null;
53
+ return data;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Load SKILL.md from a plugin directory.
61
+ *
62
+ * @param {string} pluginDir
63
+ * @returns {string}
64
+ */
65
+ function loadSkillContent(pluginDir) {
66
+ const skillFile = join(pluginDir, 'SKILL.md');
67
+ if (!existsSync(skillFile)) return '';
68
+ return readFileSync(skillFile, 'utf-8');
69
+ }
70
+
71
+ /**
72
+ * Create a PluginInfo from a plugin directory.
73
+ *
74
+ * @param {string} pluginDir
75
+ * @param {string} source
76
+ * @returns {PluginInfo | null}
77
+ */
78
+ function pluginFromDir(pluginDir, source) {
79
+ let meta = loadPluginYaml(pluginDir);
80
+ if (!meta) {
81
+ // If no plugin.yaml but has SKILL.md, create minimal metadata
82
+ if (existsSync(join(pluginDir, 'SKILL.md'))) {
83
+ const dirName = pluginDir.split('/').pop() || 'unknown';
84
+ meta = { name: dirName };
85
+ } else {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ const content = loadSkillContent(pluginDir);
91
+ if (!content) return null;
92
+
93
+ const modesRaw = meta.modes || [];
94
+ const modes = Array.isArray(modesRaw) ? modesRaw.map(m => String(m).toUpperCase()) : [];
95
+
96
+ return {
97
+ name: meta.name || pluginDir.split('/').pop(),
98
+ version: meta.version || '0.0.0',
99
+ author: meta.author || 'unknown',
100
+ description: meta.description || '',
101
+ modes,
102
+ triggers: Array.isArray(meta.triggers)
103
+ ? meta.triggers.map(t => String(t).toLowerCase())
104
+ : [],
105
+ priority: parseInt(meta.priority ?? 50, 10),
106
+ path: pluginDir,
107
+ source,
108
+ skill_content: content,
109
+ };
110
+ }
111
+
112
+ export class PluginManager {
113
+ /**
114
+ * @param {Object} [opts]
115
+ * @param {string} [opts.dataRoot] - Not used directly but kept for API compat
116
+ * @param {string} [opts.pluginDir] - Override plugin directory for testing
117
+ */
118
+ constructor(opts = {}) {
119
+ this._pluginDir = opts.pluginDir || USER_PLUGIN_DIR;
120
+ /** @type {PluginInfo[]} */
121
+ this._plugins = [];
122
+ this._discovered = false;
123
+ }
124
+
125
+ /** @returns {PluginInfo[]} */
126
+ get plugins() {
127
+ if (!this._discovered) this.discover();
128
+ return this._plugins;
129
+ }
130
+
131
+ /**
132
+ * Scan all plugin sources and return discovered plugins.
133
+ *
134
+ * @returns {PluginInfo[]}
135
+ */
136
+ discover() {
137
+ this._plugins = [];
138
+
139
+ // Scan user plugin directory
140
+ if (existsSync(this._pluginDir)) {
141
+ try {
142
+ const entries = readdirSync(this._pluginDir).sort();
143
+ for (const entry of entries) {
144
+ const entryPath = join(this._pluginDir, entry);
145
+ try {
146
+ if (statSync(entryPath).isDirectory()) {
147
+ const plugin = pluginFromDir(entryPath, 'user');
148
+ if (plugin) this._plugins.push(plugin);
149
+ }
150
+ } catch {
151
+ // Skip entries we can't stat
152
+ }
153
+ }
154
+ } catch {
155
+ // Plugin directory unreadable — graceful fallback
156
+ }
157
+ }
158
+
159
+ // Sort by priority (lower first)
160
+ this._plugins.sort((a, b) => a.priority - b.priority);
161
+ this._discovered = true;
162
+
163
+ return this._plugins;
164
+ }
165
+
166
+ /**
167
+ * Get all plugins that activate for a given mode.
168
+ *
169
+ * @param {string} mode - CIPHER mode name
170
+ * @returns {PluginInfo[]}
171
+ */
172
+ getSkillsForMode(mode) {
173
+ const upper = mode.toUpperCase();
174
+ return this.plugins.filter(p => p.modes.includes(upper));
175
+ }
176
+
177
+ /**
178
+ * Get additional trigger keywords from plugins.
179
+ *
180
+ * @returns {Record<string, string[]>}
181
+ */
182
+ getExtraTriggers() {
183
+ /** @type {Record<string, string[]>} */
184
+ const triggers = {};
185
+ for (const plugin of this.plugins) {
186
+ if (!plugin.triggers || !plugin.triggers.length) continue;
187
+ for (const mode of plugin.modes) {
188
+ if (!triggers[mode]) triggers[mode] = [];
189
+ triggers[mode].push(...plugin.triggers);
190
+ }
191
+ }
192
+ return triggers;
193
+ }
194
+
195
+ /**
196
+ * Find a plugin by name.
197
+ *
198
+ * @param {string} name
199
+ * @returns {PluginInfo | null}
200
+ */
201
+ getPluginByName(name) {
202
+ return this.plugins.find(p => p.name === name) || null;
203
+ }
204
+
205
+ /**
206
+ * Return a serializable list of plugin metadata for CLI display.
207
+ *
208
+ * @returns {Array<Record<string, any>>}
209
+ */
210
+ listPlugins() {
211
+ return this.plugins.map(p => ({
212
+ name: p.name,
213
+ version: p.version,
214
+ author: p.author,
215
+ description: p.description,
216
+ modes: p.modes,
217
+ source: p.source,
218
+ path: p.path,
219
+ priority: p.priority,
220
+ }));
221
+ }
222
+ }