@jamaynor/hal-config 1.0.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/lib/config.js ADDED
@@ -0,0 +1,675 @@
1
+ // =============================================================================
2
+ // hal-shared-config — Shared configuration loader for HAL OpenClaw skills
3
+ //
4
+ // Reads system-config.json once, caches for the process lifetime, and exposes
5
+ // accessors for shared data (vaults, accounts, timezone, etc.) and skill
6
+ // discovery (enabled, installed, workspace, binaries).
7
+ //
8
+ // Skills use this instead of each maintaining their own system-config reader.
9
+ // =============================================================================
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { spawnSync, execFile } from 'node:child_process';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Module-level cache — one system-config.json per process
17
+ // ---------------------------------------------------------------------------
18
+ let _cache = null;
19
+ let _configDir = null;
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Loading
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Read and cache hal-system-config.json. Call explicitly to set a custom configDir,
27
+ * or let it auto-load on first accessor call.
28
+ *
29
+ * @param {string} [configDir] — directory containing hal-system-config.json.
30
+ * Defaults to HAL_SYSTEM_CONFIG env var, then /data/openclaw/hal.
31
+ * @returns {object} The parsed config object.
32
+ */
33
+ export function load(configDir) {
34
+ _configDir = configDir || process.env.HAL_SYSTEM_CONFIG || '/data/openclaw/hal';
35
+ const filePath = path.join(_configDir, 'hal-system-config.json');
36
+ try {
37
+ _cache = JSON.parse(fs.readFileSync(filePath, 'utf8'));
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') {
40
+ _cache = {};
41
+ } else {
42
+ throw err;
43
+ }
44
+ }
45
+ return _cache;
46
+ }
47
+
48
+ /**
49
+ * Bust the cache and re-read from disk.
50
+ */
51
+ export function reload() {
52
+ _cache = null;
53
+ return load(_configDir);
54
+ }
55
+
56
+ /** Ensure config is loaded (auto-load on first access). */
57
+ function _ensure() {
58
+ if (!_cache) load();
59
+ }
60
+
61
+ /**
62
+ * Return the full parsed config object.
63
+ */
64
+ export function raw() {
65
+ _ensure();
66
+ return _cache;
67
+ }
68
+
69
+ /**
70
+ * Return the resolved config directory path.
71
+ */
72
+ export function configDir() {
73
+ _ensure();
74
+ return _configDir;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Deterministic filesystem locations (OpenClaw + HAL)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ export function openclawSharedWorkspaceRoot() {
82
+ return '/data/openclaw/workspace';
83
+ }
84
+
85
+ export function openclawConfigFilePath() {
86
+ return '/data/openclaw/openclaw.json';
87
+ }
88
+
89
+ export function halSystemConfigDir() {
90
+ // Matches load() defaulting behavior (dir containing hal-system-config.json)
91
+ return process.env.HAL_SYSTEM_CONFIG || '/data/openclaw/hal';
92
+ }
93
+
94
+ export function halSystemConfigFilePath() {
95
+ return path.join(halSystemConfigDir(), 'hal-system-config.json');
96
+ }
97
+
98
+ export function halSkillAdminRoot(skillName) {
99
+ const root = process.env.HAL_SKILL_ADMIN_ROOT?.trim() || '/data/openclaw/hal';
100
+ return path.join(root, String(skillName || '').trim());
101
+ }
102
+
103
+ export function halSkillConfigDir(skillName) {
104
+ return path.join(halSkillAdminRoot(skillName), 'config');
105
+ }
106
+
107
+ export function halSkillLogDir(skillName) {
108
+ return path.join(halSkillAdminRoot(skillName), 'log');
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // halSkillConfig (independent per-skill config files under HAL skill admin root)
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function _deepMergeSkillConfig(target, source) {
116
+ for (const key of Object.keys(source || {})) {
117
+ const srcVal = source[key];
118
+ const tgtVal = target[key];
119
+ const srcIsPlain = typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal);
120
+ const tgtIsPlain = typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal);
121
+ if (srcIsPlain && tgtIsPlain) {
122
+ _deepMergeSkillConfig(tgtVal, srcVal);
123
+ } else {
124
+ target[key] = srcVal;
125
+ }
126
+ }
127
+ return target;
128
+ }
129
+
130
+ function _readJsonOrEmpty(filePath) {
131
+ try {
132
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
133
+ } catch (err) {
134
+ if (err && err.code === 'ENOENT') return {};
135
+ throw err;
136
+ }
137
+ }
138
+
139
+ function _writeJsonAtomic(filePath, payload) {
140
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
141
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
142
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
143
+ fs.renameSync(tempPath, filePath);
144
+ }
145
+
146
+ export const halSkillConfig = {
147
+ normalizeSkillName: _normalizeSkillName,
148
+ adminRoot: halSkillAdminRoot,
149
+ configDir: halSkillConfigDir,
150
+ logDir: halSkillLogDir,
151
+ configPath: skillConfigPath,
152
+
153
+ read(skillScope) {
154
+ const name = _normalizeSkillName(skillScope);
155
+ const fp = skillConfigPath(name);
156
+ return _readJsonOrEmpty(fp);
157
+ },
158
+
159
+ getSetting(skillScope, key) {
160
+ const name = _normalizeSkillName(skillScope);
161
+ const cfg = this.read(name);
162
+ const k = String(key);
163
+ return Object.prototype.hasOwnProperty.call(cfg, k) ? cfg[k] : undefined;
164
+ },
165
+
166
+ write(skillScope, data) {
167
+ const name = _normalizeSkillName(skillScope);
168
+ const fp = skillConfigPath(name);
169
+ _writeJsonAtomic(fp, data || {});
170
+ },
171
+
172
+ merge(skillScope, patch) {
173
+ const name = _normalizeSkillName(skillScope);
174
+ const fp = skillConfigPath(name);
175
+ const existing = _readJsonOrEmpty(fp);
176
+ const merged = _deepMergeSkillConfig(existing, patch || {});
177
+ _writeJsonAtomic(fp, merged);
178
+ return merged;
179
+ },
180
+
181
+ setSetting(skillScope, key, value) {
182
+ return this.merge(skillScope, { [String(key)]: value });
183
+ },
184
+ };
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Shared data accessors
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Return the hal-obsidian-vaults array (raw — consumers transform as needed).
192
+ * @returns {Array<{name: string, path: string, label: string, [key: string]: any}>}
193
+ */
194
+ export function vaults() {
195
+ _ensure();
196
+ return _cache['hal-obsidian-vaults'] || [];
197
+ }
198
+
199
+ function _vaultByName(name) {
200
+ const n = String(name || '').trim();
201
+ if (!n) return null;
202
+ const v = vaults().find(x => x && x.name === n);
203
+ return v || null;
204
+ }
205
+
206
+ const DEFAULT_VAULT_FOLDERS = {
207
+ projectsFolder: '1-Projects',
208
+ dailyNotesFolder: 'areas/daily-notes',
209
+ emailFolder: 'areas/email',
210
+ meetingsFolder: 'areas/meetings',
211
+ peopleFolder: 'areas/people',
212
+ };
213
+
214
+ /**
215
+ * Return configured vault folder names (subpaths) for a given vault.
216
+ * These come from the `hal-obsidian-vaults` entries in hal-system-config.json.
217
+ *
218
+ * @param {string} vaultName
219
+ * @returns {{
220
+ * projectsFolder: string,
221
+ * dailyNotesFolder: string,
222
+ * emailFolder: string,
223
+ * meetingsFolder: string,
224
+ * peopleFolder: string,
225
+ * }}
226
+ */
227
+ export function vaultFolders(vaultName) {
228
+ _ensure();
229
+ const v = _vaultByName(vaultName);
230
+ const get = (key, fallback) => {
231
+ if (!v) return fallback;
232
+ const val = v[key];
233
+ return (typeof val === 'string' && val.trim()) ? val.trim() : fallback;
234
+ };
235
+ return {
236
+ projectsFolder: get('projects-folder', DEFAULT_VAULT_FOLDERS.projectsFolder),
237
+ dailyNotesFolder: get('daily-notes-folder', DEFAULT_VAULT_FOLDERS.dailyNotesFolder),
238
+ emailFolder: get('email-folder', DEFAULT_VAULT_FOLDERS.emailFolder),
239
+ meetingsFolder: get('meetings-folder', DEFAULT_VAULT_FOLDERS.meetingsFolder),
240
+ peopleFolder: get('people-folder', DEFAULT_VAULT_FOLDERS.peopleFolder),
241
+ };
242
+ }
243
+
244
+ function _joinVaultSubpath(base, sub) {
245
+ const b = String(base || '');
246
+ const s = String(sub || '');
247
+ if (!b) return s;
248
+ // Config paths are usually POSIX (/vaults/...). Keep POSIX joining unless
249
+ // the base looks like a Windows path.
250
+ const useWin = b.includes('\\') || /^[a-zA-Z]:\\/.test(b);
251
+ const joiner = useWin ? path.win32.join : path.posix.join;
252
+ return joiner(b, s);
253
+ }
254
+
255
+ /**
256
+ * Return resolved full paths for vault directories (vault root + configured folder).
257
+ *
258
+ * @param {string} vaultName
259
+ * @returns {{
260
+ * vaultPath: string|null,
261
+ * projectsPath: string|null,
262
+ * dailyNotesPath: string|null,
263
+ * emailPath: string|null,
264
+ * meetingsPath: string|null,
265
+ * peoplePath: string|null,
266
+ * }}
267
+ */
268
+ export function vaultDirectoryPaths(vaultName) {
269
+ _ensure();
270
+ const v = _vaultByName(vaultName);
271
+ if (!v || !v.path) {
272
+ return {
273
+ vaultPath: null,
274
+ projectsPath: null,
275
+ dailyNotesPath: null,
276
+ emailPath: null,
277
+ meetingsPath: null,
278
+ peoplePath: null,
279
+ };
280
+ }
281
+ const folders = vaultFolders(vaultName);
282
+ return {
283
+ vaultPath: v.path,
284
+ projectsPath: _joinVaultSubpath(v.path, folders.projectsFolder),
285
+ dailyNotesPath: _joinVaultSubpath(v.path, folders.dailyNotesFolder),
286
+ emailPath: _joinVaultSubpath(v.path, folders.emailFolder),
287
+ meetingsPath: _joinVaultSubpath(v.path, folders.meetingsFolder),
288
+ peoplePath: _joinVaultSubpath(v.path, folders.peopleFolder),
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Return communication accounts, optionally filtered by provider.
294
+ * @param {string} [provider] — e.g. 'google', 'ms365', 'imap'
295
+ * @returns {Array<{label: string, provider: string, email: string, scopes: string[]}>}
296
+ */
297
+ export function accounts(provider) {
298
+ _ensure();
299
+ const all = _cache['hal-communication-accounts'] || [];
300
+ if (!provider) return all;
301
+ return all.filter(a => a.provider === provider);
302
+ }
303
+
304
+ /**
305
+ * Return the configured timezone string.
306
+ * @returns {string|null}
307
+ */
308
+ export function timezone() {
309
+ _ensure();
310
+ return _cache['hal-timezone'] || null;
311
+ }
312
+
313
+ /**
314
+ * Return work hours config.
315
+ * @returns {{start: string, end: string}|null}
316
+ */
317
+ export function workHours() {
318
+ _ensure();
319
+ return _cache['hal-work-hours'] || null;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Settings accessors (deterministic callers)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ function _normalizeSkillName(name) {
327
+ const n = String(name || '').trim();
328
+ if (!n) return n;
329
+ if (n.startsWith('hal-')) return n.slice('hal-'.length);
330
+ if (n.startsWith('openclaw-skill-')) return n.slice('openclaw-skill-'.length);
331
+ return n;
332
+ }
333
+
334
+ /**
335
+ * Deterministic settings getter.
336
+ *
337
+ * Usage:
338
+ * getSetting('system-emails') -> reads top-level key from hal-system-config.json
339
+ * getSetting('hal-email-triage', 'gog_client_id') -> reads from the skill's independent config file
340
+ *
341
+ * Skill config path (deterministic):
342
+ * {HAL_SKILL_ADMIN_ROOT or /data/openclaw/hal}/{skillName}/config/{skillName}.json
343
+ *
344
+ * Returns undefined when not found.
345
+ */
346
+ export function getSetting(scopeOrKey, key) {
347
+ _ensure();
348
+
349
+ // Global setting: getSetting('system-emails')
350
+ if (key === undefined) {
351
+ return _cache ? _cache[String(scopeOrKey)] : undefined;
352
+ }
353
+
354
+ // Skill setting: getSetting('hal-email-triage', 'gog_client_id')
355
+ const skillName = _normalizeSkillName(scopeOrKey);
356
+ const settingKey = String(key);
357
+ const filePath = skillConfigPath(skillName);
358
+
359
+ let cfg;
360
+ try {
361
+ cfg = JSON.parse(fs.readFileSync(filePath, 'utf8'));
362
+ } catch (err) {
363
+ if (err && err.code === 'ENOENT') return undefined;
364
+ throw err;
365
+ }
366
+
367
+ if (!cfg || typeof cfg !== 'object') return undefined;
368
+ return Object.prototype.hasOwnProperty.call(cfg, settingKey)
369
+ ? cfg[settingKey]
370
+ : undefined;
371
+ }
372
+
373
+ // Alias for callers that prefer PascalCase.
374
+ export const GetSetting = getSetting;
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Skill accessors
378
+ // ---------------------------------------------------------------------------
379
+
380
+ /**
381
+ * Return the full config block for a skill, or null if not configured.
382
+ * @param {string} name — skill name (e.g. 'project-manager')
383
+ * @returns {object|null}
384
+ */
385
+ export function skill(name) {
386
+ _ensure();
387
+ const skills = _cache.skills || {};
388
+ return skills[name] || null;
389
+ }
390
+
391
+ /**
392
+ * Check if a skill is enabled (exists in config and enabled !== false).
393
+ */
394
+ export function isEnabled(name) {
395
+ const s = skill(name);
396
+ return s ? s.enabled !== false : false;
397
+ }
398
+
399
+ /**
400
+ * Return the runtime.workspace path for a skill, or null.
401
+ */
402
+ export function skillWorkspace(name) {
403
+ const s = skill(name);
404
+ return s && s.runtime ? s.runtime.workspace || null : null;
405
+ }
406
+
407
+ /**
408
+ * Return the binary name(s) for a skill as an array.
409
+ * @returns {string[]}
410
+ */
411
+ export function skillBinaries(name) {
412
+ const s = skill(name);
413
+ if (!s || !s.install) return [];
414
+ const bin = s.install.binary;
415
+ if (!bin) return [];
416
+ return Array.isArray(bin) ? bin : [bin];
417
+ }
418
+
419
+ /**
420
+ * Return the homeAgent for a skill, or null if not set.
421
+ * When null, the skill is homed in the primary (first) agent.
422
+ * @param {string} name — skill name
423
+ * @returns {string|null}
424
+ */
425
+ export function homeAgent(name) {
426
+ const s = skill(name);
427
+ return s ? s.homeAgent || null : null;
428
+ }
429
+
430
+ /**
431
+ * Resolve the path to a skill's config file in the HAL skill admin root.
432
+ *
433
+ * Canonical convention:
434
+ * /data/openclaw/hal/{skillName}/config/{skillName}.json
435
+ *
436
+ * Override the root via HAL_SKILL_ADMIN_ROOT.
437
+ *
438
+ * @param {string} name — skill name (e.g. 'project-manager')
439
+ * @returns {string} absolute path to the skill's config file
440
+ */
441
+ export function skillConfigPath(name) {
442
+ _ensure();
443
+ const skillName = String(name || '').trim();
444
+ return path.join(halSkillConfigDir(skillName), `${skillName}.json`);
445
+ }
446
+
447
+ /**
448
+ * Resolve the config directory for a skill under the HAL skill admin root.
449
+ * @param {string} name — skill name
450
+ * @returns {string} absolute path to the hal/config directory
451
+ */
452
+ export function skillConfigDir(name) {
453
+ return path.dirname(skillConfigPath(name));
454
+ }
455
+
456
+ /**
457
+ * Synchronous check: is a skill enabled AND is its primary binary on PATH?
458
+ */
459
+ export function isInstalled(name) {
460
+ if (!isEnabled(name)) return false;
461
+ const bins = skillBinaries(name);
462
+ if (bins.length === 0) return false;
463
+ return _isBinaryOnPath(bins[0]);
464
+ }
465
+
466
+ /**
467
+ * Async check: is a skill enabled AND is its primary binary on PATH?
468
+ * @returns {Promise<boolean>}
469
+ */
470
+ export function isInstalledAsync(name) {
471
+ if (!isEnabled(name)) return Promise.resolve(false);
472
+ const bins = skillBinaries(name);
473
+ if (bins.length === 0) return Promise.resolve(false);
474
+ return _isBinaryOnPathAsync(bins[0]);
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Binary helpers
479
+ // ---------------------------------------------------------------------------
480
+
481
+ function _isBinaryOnPath(binary) {
482
+ try {
483
+ const result = spawnSync(binary, ['--version'], {
484
+ timeout: 3000,
485
+ stdio: 'ignore',
486
+ });
487
+ return result.status === 0;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ function _isBinaryOnPathAsync(binary) {
494
+ return new Promise(resolve => {
495
+ try {
496
+ execFile(binary, ['--version'], { timeout: 5000 }, err => {
497
+ resolve(!err);
498
+ });
499
+ } catch {
500
+ resolve(false);
501
+ }
502
+ });
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Registration — write data back to system-config.json
507
+ // ---------------------------------------------------------------------------
508
+
509
+ /**
510
+ * Merge runtime data into a skill's runtime block and flush to disk.
511
+ * @param {string} name — skill name
512
+ * @param {object} runtimeData — key/value pairs to merge into skills[name].runtime
513
+ */
514
+ export function register(name, runtimeData) {
515
+ _ensure();
516
+ if (!_cache.skills) _cache.skills = {};
517
+ if (!_cache.skills[name]) _cache.skills[name] = {};
518
+ if (!_cache.skills[name].runtime) _cache.skills[name].runtime = {};
519
+ Object.assign(_cache.skills[name].runtime, runtimeData);
520
+ _flush();
521
+ }
522
+
523
+ /**
524
+ * Merge communication accounts by label (upsert — match by label, replace or
525
+ * append). Never deletes entries whose label isn't in newAccounts.
526
+ * @param {Array} newAccounts — accounts to upsert
527
+ */
528
+ export function writeAccounts(newAccounts) {
529
+ _ensure();
530
+ const existing = _cache['hal-communication-accounts'] || [];
531
+ for (const acc of newAccounts) {
532
+ const idx = existing.findIndex(e => e.label === acc.label);
533
+ if (idx >= 0) {
534
+ existing[idx] = acc;
535
+ } else {
536
+ existing.push(acc);
537
+ }
538
+ }
539
+ _cache['hal-communication-accounts'] = existing;
540
+ _flush();
541
+ }
542
+
543
+ /**
544
+ * Merge key/value pairs into a skill's config block (not runtime — top level
545
+ * of the skill entry). Used by init wizards to store skill-specific settings.
546
+ * @param {string} name — skill name
547
+ * @param {object} data — key/value pairs to merge
548
+ */
549
+ export function writeSkillConfig(name, data) {
550
+ _ensure();
551
+ if (!_cache.skills) _cache.skills = {};
552
+ if (!_cache.skills[name]) _cache.skills[name] = {};
553
+ Object.assign(_cache.skills[name], data);
554
+ _flush();
555
+ }
556
+
557
+ /**
558
+ * Write shared top-level settings (e.g. hal-timezone, hal-work-hours).
559
+ * Merges into the config root — does not touch skills or other nested objects.
560
+ * @param {object} settings — key/value pairs for top-level config keys
561
+ */
562
+ export function writeSharedSettings(settings) {
563
+ _ensure();
564
+ for (const [key, val] of Object.entries(settings)) {
565
+ _cache[key] = val;
566
+ }
567
+ _flush();
568
+ }
569
+
570
+ /**
571
+ * Flush the in-memory config to disk.
572
+ */
573
+ function _flush() {
574
+ const dir = _configDir || process.env.HAL_SYSTEM_CONFIG || '/data/openclaw/hal';
575
+ fs.mkdirSync(dir, { recursive: true });
576
+ const filePath = path.join(dir, 'hal-system-config.json');
577
+ fs.writeFileSync(filePath, JSON.stringify(_cache, null, 2) + '\n', 'utf8');
578
+ }
579
+
580
+ // ---------------------------------------------------------------------------
581
+ // Workspace resolution
582
+ // ---------------------------------------------------------------------------
583
+
584
+ /**
585
+ * Resolve the workspace path for a skill using a priority chain:
586
+ * 1. opts.workspace (explicit CLI flag)
587
+ * 2. HAL_{opts.envPrefix}_MASTER_WORKSPACE (skill-specific env var)
588
+ * 3. HAL_AGENT_WORKSPACE (agent-level env var)
589
+ * 4. opts.fallback (skill-specific default)
590
+ * 5. process.cwd()
591
+ *
592
+ * @param {{ workspace?: string, envPrefix?: string, fallback?: string }} [opts]
593
+ * @returns {string} Resolved absolute path.
594
+ */
595
+ export function resolveWorkspace(opts) {
596
+ const o = opts || {};
597
+
598
+ if (o.workspace) return path.resolve(o.workspace);
599
+
600
+ if (o.envPrefix) {
601
+ const skillEnv = process.env[`HAL_${o.envPrefix}_MASTER_WORKSPACE`];
602
+ if (skillEnv) return path.resolve(skillEnv);
603
+ }
604
+
605
+ const agentEnv = process.env.HAL_AGENT_WORKSPACE;
606
+ if (agentEnv) return path.resolve(agentEnv);
607
+
608
+ if (o.fallback) return path.resolve(o.fallback);
609
+
610
+ return path.resolve(process.cwd());
611
+ }
612
+
613
+ // ---------------------------------------------------------------------------
614
+ // Skill config file I/O
615
+ // ---------------------------------------------------------------------------
616
+
617
+ /**
618
+ * Read a skill's config file from {workspace}/hal/config/{skillName}.json.
619
+ * Returns {} when the file does not exist. Throws on corrupt JSON or
620
+ * permission errors.
621
+ *
622
+ * @param {string} skillName
623
+ * @param {string} workspace
624
+ * @returns {object}
625
+ */
626
+ export function loadSkillConfig(skillName, workspace) {
627
+ const filePath = path.join(workspace, 'hal', 'config', `${skillName}.json`);
628
+ try {
629
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
630
+ } catch (err) {
631
+ if (err.code === 'ENOENT') return {};
632
+ throw err;
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Deep-merge source into target. Plain objects are merged recursively.
638
+ * Arrays are replaced atomically. Non-object values overwrite.
639
+ *
640
+ * @param {object} target
641
+ * @param {object} source
642
+ * @returns {object} The mutated target.
643
+ */
644
+ function _deepMerge(target, source) {
645
+ for (const key of Object.keys(source)) {
646
+ const srcVal = source[key];
647
+ const tgtVal = target[key];
648
+ const srcIsPlain = typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal);
649
+ const tgtIsPlain = typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal);
650
+
651
+ if (srcIsPlain && tgtIsPlain) {
652
+ _deepMerge(tgtVal, srcVal);
653
+ } else {
654
+ target[key] = srcVal;
655
+ }
656
+ }
657
+ return target;
658
+ }
659
+
660
+ /**
661
+ * Deep-merge data into the existing skill config at
662
+ * {workspace}/hal/config/{skillName}.json and write to disk.
663
+ * Creates parent directories as needed.
664
+ *
665
+ * @param {string} skillName
666
+ * @param {string} workspace
667
+ * @param {object} data
668
+ */
669
+ export function saveSkillConfig(skillName, workspace, data) {
670
+ const existing = loadSkillConfig(skillName, workspace);
671
+ const merged = _deepMerge(existing, data);
672
+ const filePath = path.join(workspace, 'hal', 'config', `${skillName}.json`);
673
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
674
+ fs.writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
675
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@jamaynor/hal-config",
3
+ "version": "1.0.1",
4
+ "description": "Shared configuration loader for HAL OpenClaw skills — reads system-config.json, provides skill discovery, and runtime registration",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": "./index.js",
12
+ "./test-utils": "./test-utils.js",
13
+ "./security": "./security/index.js"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test test/test.js test/test-utils.test.js test/config-io.test.js test/security.test.js"
17
+ },
18
+ "keywords": ["openclaw", "hal", "config"],
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": ">=22"
22
+ }
23
+ }