@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/CLAUDE.md +84 -0
- package/index.js +3 -0
- package/lib/config.js +675 -0
- package/package.json +23 -0
- package/publish.ps1 +30 -0
- package/security/access-control.js +308 -0
- package/security/governor.js +313 -0
- package/security/index.js +31 -0
- package/security/redactor.js +129 -0
- package/security/sanitizer.js +571 -0
- package/test/config-io.test.js +326 -0
- package/test/security.test.js +488 -0
- package/test/test-utils.test.js +360 -0
- package/test/test.js +586 -0
- package/test-utils.js +255 -0
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
|
+
}
|