cipher-security 2.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 (76) hide show
  1. package/bin/cipher.js +566 -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 +238 -0
  45. package/lib/brand.js +105 -0
  46. package/lib/commands.js +100 -0
  47. package/lib/complexity.js +377 -0
  48. package/lib/config.js +213 -0
  49. package/lib/gateway/client.js +309 -0
  50. package/lib/gateway/commands.js +991 -0
  51. package/lib/gateway/config-validate.js +109 -0
  52. package/lib/gateway/gateway.js +367 -0
  53. package/lib/gateway/index.js +62 -0
  54. package/lib/gateway/mode.js +309 -0
  55. package/lib/gateway/plugins.js +222 -0
  56. package/lib/gateway/prompt.js +214 -0
  57. package/lib/mcp/server.js +262 -0
  58. package/lib/memory/compressor.js +425 -0
  59. package/lib/memory/engine.js +763 -0
  60. package/lib/memory/evolution.js +668 -0
  61. package/lib/memory/index.js +58 -0
  62. package/lib/memory/orchestrator.js +506 -0
  63. package/lib/memory/retriever.js +515 -0
  64. package/lib/memory/synthesizer.js +333 -0
  65. package/lib/pipeline/async-scanner.js +510 -0
  66. package/lib/pipeline/binary-analysis.js +1043 -0
  67. package/lib/pipeline/dom-xss-scanner.js +435 -0
  68. package/lib/pipeline/github-actions.js +792 -0
  69. package/lib/pipeline/index.js +124 -0
  70. package/lib/pipeline/osint.js +498 -0
  71. package/lib/pipeline/sarif.js +373 -0
  72. package/lib/pipeline/scanner.js +880 -0
  73. package/lib/pipeline/template-manager.js +525 -0
  74. package/lib/pipeline/xss-scanner.js +353 -0
  75. package/lib/setup-wizard.js +288 -0
  76. package/package.json +31 -0
@@ -0,0 +1,377 @@
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
+ * Smart routing complexity classifier for CIPHER CLI.
7
+ *
8
+ * Evaluates multiple heuristic signals to determine query complexity and
9
+ * route to the appropriate backend (Ollama for simple/moderate, Claude for
10
+ * complex/expert).
11
+ *
12
+ * Ported from src/gateway/complexity.py — all keyword dictionaries, regex
13
+ * patterns, score thresholds, and scoring logic are identical.
14
+ *
15
+ * @example
16
+ * import { classify, routeBackend, classifyAndRoute, COMPLEXITY } from './complexity.js';
17
+ *
18
+ * const level = classify("design a zero trust architecture for AWS");
19
+ * const backend = routeBackend(level); // "claude"
20
+ *
21
+ * const [lvl, be] = classifyAndRoute("what port does SSH use");
22
+ * // lvl === 1 (SIMPLE), be === "ollama"
23
+ */
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Complexity enum
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /** Query complexity levels, ordered by increasing difficulty. */
30
+ export const COMPLEXITY = Object.freeze({
31
+ SIMPLE: 1, // Factual lookups, single-concept questions → Ollama
32
+ MODERATE: 2, // Multi-part but bounded questions → Ollama
33
+ COMPLEX: 3, // Deep reasoning, threat models, architecture → Claude
34
+ EXPERT: 4, // Cross-domain, multi-framework, advanced analysis → Claude
35
+ });
36
+
37
+ const _NAMES = Object.freeze({
38
+ [COMPLEXITY.SIMPLE]: 'SIMPLE',
39
+ [COMPLEXITY.MODERATE]: 'MODERATE',
40
+ [COMPLEXITY.COMPLEX]: 'COMPLEX',
41
+ [COMPLEXITY.EXPERT]: 'EXPERT',
42
+ });
43
+
44
+ /**
45
+ * Reverse lookup: complexity level integer → human-readable name.
46
+ * @param {number} level
47
+ * @returns {string}
48
+ */
49
+ export function nameOf(level) {
50
+ return _NAMES[level] ?? 'UNKNOWN';
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Tunable weights — adjust these to change routing sensitivity
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** Points thresholds for each complexity level. */
58
+ export const THRESHOLDS = Object.freeze({
59
+ moderate: 3, // >= 3 points → MODERATE
60
+ complex: 6, // >= 6 points → COMPLEX
61
+ expert: 10, // >= 10 points → EXPERT
62
+ });
63
+
64
+ /** Message length breakpoints (characters) and their point values. */
65
+ export const LENGTH_SCORES = Object.freeze([
66
+ [500, 4], // Very long queries are likely complex
67
+ [300, 3], // Long queries
68
+ [150, 2], // Medium queries
69
+ [80, 1], // Short but not trivial
70
+ ]);
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Keyword sets — each match adds the specified points
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Simple indicators (negative points — pull toward Ollama). */
77
+ export const SIMPLE_KEYWORDS = Object.freeze({
78
+ 'what is': -2,
79
+ 'what are': -1,
80
+ 'what does': -2,
81
+ 'what port': -3,
82
+ 'which port': -3,
83
+ 'default port': -3,
84
+ 'how to install': -2,
85
+ 'how to start': -2,
86
+ 'how to run': -2,
87
+ 'syntax for': -2,
88
+ 'command for': -2,
89
+ 'define ': -2,
90
+ 'definition of': -2,
91
+ 'meaning of': -2,
92
+ 'example of': -1,
93
+ 'list of': -1,
94
+ 'difference between': -1,
95
+ });
96
+
97
+ /** Domain complexity indicators (positive points — push toward Claude). */
98
+ export const COMPLEX_KEYWORDS = Object.freeze({
99
+ // Architecture & design
100
+ 'threat model': 4,
101
+ 'architecture': 3,
102
+ 'design': 2,
103
+ 'zero trust': 3,
104
+ 'blast radius': 3,
105
+ 'compensating control': 3,
106
+ 'defense in depth': 2,
107
+ 'security architecture': 4,
108
+ 'data flow diagram': 3,
109
+ 'trust boundary': 3,
110
+ // Risk & compliance
111
+ 'dpia': 4,
112
+ 'risk assessment': 4,
113
+ 'risk analysis': 3,
114
+ 'compliance mapping': 4,
115
+ 'gap analysis': 3,
116
+ 'maturity assessment': 3,
117
+ 'audit finding': 3,
118
+ 'control mapping': 3,
119
+ // Analysis & reasoning
120
+ 'analyze': 2,
121
+ 'analyse': 2,
122
+ 'evaluate': 2,
123
+ 'compare and contrast': 3,
124
+ 'trade-off': 2,
125
+ 'tradeoff': 2,
126
+ 'pros and cons': 2,
127
+ 'impact analysis': 3,
128
+ 'root cause': 3,
129
+ // Offensive / red team
130
+ 'attack chain': 4,
131
+ 'attack path': 3,
132
+ 'kill chain': 3,
133
+ 'exploitation chain': 4,
134
+ 'lateral movement': 2,
135
+ 'privilege escalation': 2,
136
+ 'privesc': 2,
137
+ 'post-exploitation': 3,
138
+ 'evasion technique': 3,
139
+ 'bypass': 1,
140
+ // Incident response
141
+ 'incident analysis': 4,
142
+ 'incident': 2,
143
+ 'forensic analysis': 4,
144
+ 'forensic': 2,
145
+ 'timeline reconstruction': 4,
146
+ 'indicator of compromise': 2,
147
+ 'ioc': 1,
148
+ 'beacon': 2,
149
+ 'cobalt strike': 3,
150
+ 'mimikatz': 2,
151
+ 'dcsync': 3,
152
+ 'triage': 2,
153
+ 'containment strategy': 3,
154
+ 'eradication plan': 3,
155
+ 'breach': 2,
156
+ 'malware analysis': 3,
157
+ 'reverse engineer': 3,
158
+ // Detection engineering
159
+ 'detection logic': 3,
160
+ 'sigma rule': 2,
161
+ 'detection coverage': 3,
162
+ 'false positive': 2,
163
+ 'detection gap': 3,
164
+ 'correlation rule': 3,
165
+ 'hunting hypothesis': 3,
166
+ // Multi-step / planning
167
+ 'strategy': 2,
168
+ 'roadmap': 3,
169
+ 'implementation plan': 3,
170
+ 'step by step': 1,
171
+ 'runbook': 2,
172
+ 'playbook': 2,
173
+ 'workflow': 1,
174
+ });
175
+
176
+ /** Framework references — signal expert-level queries. */
177
+ export const FRAMEWORK_KEYWORDS = Object.freeze({
178
+ 'nist': 2,
179
+ 'nist 800-53': 3,
180
+ 'nist 800-171': 3,
181
+ 'nist csf': 3,
182
+ 'mitre att&ck': 3,
183
+ 'mitre attack': 3,
184
+ 'stride': 3,
185
+ 'pasta': 3,
186
+ 'dread': 3,
187
+ 'owasp': 2,
188
+ 'owasp top 10': 2,
189
+ 'cis controls': 2,
190
+ 'cis benchmark': 2,
191
+ 'iso 27001': 3,
192
+ 'iso 27002': 3,
193
+ 'soc 2': 2,
194
+ 'pci dss': 3,
195
+ 'hipaa': 2,
196
+ 'gdpr': 2,
197
+ 'ccpa': 2,
198
+ 'fedramp': 3,
199
+ 'cmmc': 3,
200
+ 'cve-': 1,
201
+ 'cwe-': 1,
202
+ });
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Structural patterns — regex-based signals
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /** Multi-part query indicators (each match adds points). */
209
+ export const STRUCTURE_PATTERNS = Object.freeze([
210
+ // Conjunctions joining distinct requests
211
+ [/\b(?:and also|and then|additionally|furthermore|moreover)\b/, 2],
212
+ // Numbered lists in the query
213
+ [/(?:^|\n)\s*\d+[.)]\s/, 2],
214
+ // Conditional logic
215
+ [/\bif\s+.+\bthen\b/, 2],
216
+ [/\bwhat if\b/, 2],
217
+ [/\bassuming\b/, 1],
218
+ [/\bgiven that\b/, 1],
219
+ // Comparison requests
220
+ [/\bvs\.?\b/, 1],
221
+ [/\bversus\b/, 1],
222
+ [/\bcompare\b/, 2],
223
+ // Scope amplifiers
224
+ [/\b(?:comprehensive|exhaustive|thorough|detailed|in-depth|end-to-end)\b/, 2],
225
+ [/\b(?:enterprise|organization-wide|company-wide|across all)\b/, 2],
226
+ // Multiple question marks (multiple questions)
227
+ [/\?.*\?/, 2],
228
+ ]);
229
+
230
+ /** Count of "and" conjunctions — many ands suggest multi-part queries. */
231
+ export const AND_CONJUNCTION_THRESHOLD = 3;
232
+ export const AND_CONJUNCTION_POINTS = 2;
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Scoring functions
236
+ // ---------------------------------------------------------------------------
237
+
238
+ /**
239
+ * Score based on message length.
240
+ * @param {string} message
241
+ * @returns {number}
242
+ */
243
+ export function scoreLength(message) {
244
+ const length = message.length;
245
+ for (const [threshold, points] of LENGTH_SCORES) {
246
+ if (length >= threshold) {
247
+ return points;
248
+ }
249
+ }
250
+ return 0;
251
+ }
252
+
253
+ /**
254
+ * Score based on keyword presence. Returns sum of matched keyword points.
255
+ * @param {string} lower — lowercased message
256
+ * @param {Record<string, number>} keywordSet
257
+ * @returns {number}
258
+ */
259
+ export function scoreKeywords(lower, keywordSet) {
260
+ let total = 0;
261
+ for (const [keyword, points] of Object.entries(keywordSet)) {
262
+ if (lower.includes(keyword)) {
263
+ total += points;
264
+ }
265
+ }
266
+ return total;
267
+ }
268
+
269
+ /**
270
+ * Score based on query structure patterns.
271
+ * @param {string} lower — lowercased message
272
+ * @returns {number}
273
+ */
274
+ export function scoreStructure(lower) {
275
+ let total = 0;
276
+ for (const [pattern, points] of STRUCTURE_PATTERNS) {
277
+ if (pattern.test(lower)) {
278
+ total += points;
279
+ }
280
+ }
281
+
282
+ // Count "and" conjunctions
283
+ const andMatches = lower.match(/\band\b/g);
284
+ const andCount = andMatches ? andMatches.length : 0;
285
+ if (andCount > AND_CONJUNCTION_THRESHOLD) {
286
+ total += AND_CONJUNCTION_POINTS;
287
+ }
288
+
289
+ return total;
290
+ }
291
+
292
+ /**
293
+ * Bonus points when multiple frameworks are referenced.
294
+ * @param {string} lower — lowercased message
295
+ * @returns {number}
296
+ */
297
+ export function scoreFrameworkDensity(lower) {
298
+ let matches = 0;
299
+ let totalPoints = 0;
300
+ for (const [keyword, points] of Object.entries(FRAMEWORK_KEYWORDS)) {
301
+ if (lower.includes(keyword)) {
302
+ matches += 1;
303
+ totalPoints += points;
304
+ }
305
+ }
306
+
307
+ // Bonus for cross-framework queries (referencing 3+ frameworks)
308
+ if (matches >= 3) {
309
+ totalPoints += 3;
310
+ }
311
+
312
+ return totalPoints;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Public API
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Classify a query's complexity based on multiple heuristic signals.
321
+ * @param {string} message — The user's query string.
322
+ * @returns {number} Complexity level (1=SIMPLE, 2=MODERATE, 3=COMPLEX, 4=EXPERT).
323
+ */
324
+ export function classify(message) {
325
+ const lower = message.toLowerCase();
326
+
327
+ let score = 0;
328
+ score += scoreLength(message);
329
+ score += scoreKeywords(lower, SIMPLE_KEYWORDS);
330
+ score += scoreKeywords(lower, COMPLEX_KEYWORDS);
331
+ score += scoreStructure(lower);
332
+ score += scoreFrameworkDensity(lower);
333
+
334
+ // Floor at 0 — negative scores are still SIMPLE
335
+ score = Math.max(score, 0);
336
+
337
+ if (score >= THRESHOLDS.expert) {
338
+ return COMPLEXITY.EXPERT;
339
+ }
340
+ if (score >= THRESHOLDS.complex) {
341
+ return COMPLEXITY.COMPLEX;
342
+ }
343
+ if (score >= THRESHOLDS.moderate) {
344
+ return COMPLEXITY.MODERATE;
345
+ }
346
+ return COMPLEXITY.SIMPLE;
347
+ }
348
+
349
+ /**
350
+ * Map a complexity level to a backend name.
351
+ *
352
+ * COMPLEX and EXPERT → "claude", SIMPLE and MODERATE → "ollama".
353
+ * The "litellm" backend is user-configured, not auto-routed.
354
+ *
355
+ * @param {number} level — The classified complexity level.
356
+ * @returns {string} Backend string: "ollama" or "claude".
357
+ */
358
+ export function routeBackend(level) {
359
+ if (level >= COMPLEXITY.COMPLEX) {
360
+ return 'claude';
361
+ }
362
+ return 'ollama';
363
+ }
364
+
365
+ /**
366
+ * Classify a message and return both the complexity level and backend.
367
+ *
368
+ * Convenience function combining classify() and routeBackend().
369
+ *
370
+ * @param {string} message — The user's query string.
371
+ * @returns {[number, string]} Tuple of [complexity level, backend name].
372
+ */
373
+ export function classifyAndRoute(message) {
374
+ const level = classify(message);
375
+ const backend = routeBackend(level);
376
+ return [level, backend];
377
+ }
package/lib/config.js ADDED
@@ -0,0 +1,213 @@
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
+ * config.js — YAML config reader/writer for CIPHER CLI.
7
+ *
8
+ * Mirrors the Python gateway/config.py load_config() format and precedence:
9
+ * 1. Environment variables (LLM_BACKEND, ANTHROPIC_API_KEY) — highest
10
+ * 2. Project-root config.yaml (where pyproject.toml lives)
11
+ * 3. ~/.config/cipher/config.yaml — lowest
12
+ *
13
+ * Produces YAML that Python's yaml.safe_load() can parse identically.
14
+ *
15
+ * @module config
16
+ */
17
+
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
19
+ import { join, dirname, resolve } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+ import { parse, stringify } from 'yaml';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Path resolution
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * Walk up from `startDir` looking for pyproject.toml — same logic as Python's
29
+ * _find_project_root(). Returns the directory containing pyproject.toml, or null.
30
+ *
31
+ * @param {string} [startDir=process.cwd()]
32
+ * @returns {string | null}
33
+ */
34
+ function findProjectRoot(startDir = process.cwd()) {
35
+ let current = resolve(startDir);
36
+ for (let i = 0; i < 20; i++) {
37
+ if (existsSync(join(current, 'pyproject.toml'))) {
38
+ return current;
39
+ }
40
+ const parent = dirname(current);
41
+ if (parent === current) break; // filesystem root
42
+ current = parent;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /**
48
+ * Return the two config file paths CIPHER checks.
49
+ *
50
+ * @param {{ home?: string, cwd?: string }} [opts] — overrides for testing
51
+ * @returns {{ userConfig: string, projectConfig: string }}
52
+ */
53
+ export function getConfigPaths(opts = {}) {
54
+ const home = opts.home || homedir();
55
+ const startDir = opts.cwd || process.cwd();
56
+ const userConfig = join(home, '.config', 'cipher', 'config.yaml');
57
+ const projectRoot = findProjectRoot(startDir);
58
+ const projectConfig = projectRoot
59
+ ? join(projectRoot, 'config.yaml')
60
+ : join(startDir, 'config.yaml');
61
+ return { userConfig, projectConfig };
62
+ }
63
+
64
+ /**
65
+ * Check whether any config file exists (user-home or project-root).
66
+ *
67
+ * @param {{ home?: string, cwd?: string }} [opts] — overrides for testing
68
+ * @returns {boolean}
69
+ */
70
+ export function configExists(opts) {
71
+ const { userConfig, projectConfig } = getConfigPaths(opts);
72
+ return existsSync(userConfig) || existsSync(projectConfig);
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // YAML file I/O
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Safely load a YAML file. Returns empty object if missing or empty.
81
+ * Wraps parse errors with the file path for diagnostics.
82
+ *
83
+ * @param {string} filePath
84
+ * @returns {Record<string, any>}
85
+ */
86
+ function loadYamlFile(filePath) {
87
+ if (!existsSync(filePath)) return {};
88
+ try {
89
+ const raw = readFileSync(filePath, 'utf-8');
90
+ const data = parse(raw);
91
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
92
+ } catch (err) {
93
+ throw new Error(
94
+ `Failed to parse config at ${filePath}: ${err.message}`
95
+ );
96
+ }
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Deep merge helper
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * Deep-merge `source` into `target`. Source values override target.
105
+ * Only merges plain objects — arrays and primitives are replaced wholesale.
106
+ *
107
+ * @param {Record<string, any>} target
108
+ * @param {Record<string, any>} source
109
+ * @returns {Record<string, any>}
110
+ */
111
+ function deepMerge(target, source) {
112
+ const result = { ...target };
113
+ for (const key of Object.keys(source)) {
114
+ if (
115
+ source[key] &&
116
+ typeof source[key] === 'object' &&
117
+ !Array.isArray(source[key]) &&
118
+ target[key] &&
119
+ typeof target[key] === 'object' &&
120
+ !Array.isArray(target[key])
121
+ ) {
122
+ result[key] = deepMerge(target[key], source[key]);
123
+ } else {
124
+ result[key] = source[key];
125
+ }
126
+ }
127
+ return result;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Load config
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Load configuration from YAML files and environment variable overrides.
136
+ *
137
+ * Precedence (highest → lowest):
138
+ * 1. Environment variables
139
+ * 2. Project-root config.yaml
140
+ * 3. ~/.config/cipher/config.yaml
141
+ *
142
+ * @param {{ home?: string, cwd?: string }} [opts] — overrides for testing
143
+ * @returns {Record<string, any>} Plain config object matching the Python shape.
144
+ */
145
+ export function loadConfig(opts) {
146
+ const { userConfig, projectConfig } = getConfigPaths(opts);
147
+
148
+ // Load both files — project-root overrides user-home via deep merge
149
+ const userData = loadYamlFile(userConfig);
150
+ const projectData = loadYamlFile(projectConfig);
151
+ const merged = deepMerge(userData, projectData);
152
+
153
+ // Apply env var overrides (highest precedence)
154
+ if (process.env.LLM_BACKEND) {
155
+ merged.llm_backend = process.env.LLM_BACKEND;
156
+ }
157
+ if (process.env.ANTHROPIC_API_KEY) {
158
+ if (!merged.claude || typeof merged.claude !== 'object') {
159
+ merged.claude = {};
160
+ }
161
+ merged.claude.api_key = process.env.ANTHROPIC_API_KEY;
162
+ }
163
+
164
+ return merged;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Write config
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Write a config object as YAML. Creates parent directories if needed.
173
+ *
174
+ * @param {Record<string, any>} config — config object to write
175
+ * @param {string} [targetPath] — defaults to ~/.config/cipher/config.yaml
176
+ * @returns {string} The path that was written.
177
+ */
178
+ export function writeConfig(config, targetPath) {
179
+ const dest = targetPath || join(homedir(), '.config', 'cipher', 'config.yaml');
180
+ const dir = dirname(dest);
181
+
182
+ try {
183
+ mkdirSync(dir, { recursive: true });
184
+ } catch (err) {
185
+ throw new Error(
186
+ `Failed to create config directory ${dir}: ${err.code || err.message}`
187
+ );
188
+ }
189
+
190
+ const yamlStr = stringify(config, {
191
+ lineWidth: 0, // no line wrapping — matches Python's default_flow_style=False
192
+ nullStr: '""', // empty strings stay as "" not null
193
+ });
194
+
195
+ // Add the standard header comment
196
+ const header = [
197
+ '# CIPHER Gateway Configuration',
198
+ `# Written by cipher setup — ${new Date().toISOString()}`,
199
+ '#',
200
+ '# Precedence: env vars > project-root config.yaml > ~/.config/cipher/config.yaml',
201
+ '',
202
+ ].join('\n');
203
+
204
+ try {
205
+ writeFileSync(dest, header + yamlStr, 'utf-8');
206
+ } catch (err) {
207
+ throw new Error(
208
+ `Failed to write config to ${dest}: ${err.code || err.message}`
209
+ );
210
+ }
211
+
212
+ return dest;
213
+ }