@jamaynor/hal-config 1.0.2 → 1.1.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.
package/CLAUDE.md CHANGED
@@ -52,7 +52,8 @@ halConfig.halSkillConfig.setSetting(skillNameOrScope, key, value) // merge singl
52
52
 
53
53
  // Registration (for init wizards to write back)
54
54
  halConfig.register(name, data) // Merge into skills[name].runtime
55
- halConfig.writeAccounts(accounts) // Merge-by-label upsert into hal-communication-accounts
55
+ halConfig.upsertCommunicationAccount(account) // Upsert one communication account by label
56
+ halConfig.deleteCommunicationAccount(label) // Delete one communication account by label
56
57
  halConfig.writeSkillConfig(name, data) // Merge into skills[name]
57
58
  halConfig.writeSharedSettings(settings) // Write top-level keys (hal-timezone, etc.)
58
59
  ```
package/lib/config.js CHANGED
@@ -1,11 +1,37 @@
1
1
  // =============================================================================
2
- // hal-shared-config — Shared configuration loader for HAL OpenClaw skills
2
+ // hal-config/lib/config
3
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).
4
+ // SRP (Single Responsibility)
5
+ // - Load + cache `hal-system-config.json` and provide a single, stable API for
6
+ // HAL/OpenClaw skills to read shared settings (vaults, accounts, timezone,
7
+ // work hours), resolve workspaces, and locate per-skill config/log directories.
7
8
  //
8
- // Skills use this instead of each maintaining their own system-config reader.
9
+ // Public Interface
10
+ // config
11
+ // ├── Loading / cache
12
+ // │ ├── load(configDir?)
13
+ // │ ├── reload()
14
+ // │ └── configDir()
15
+ // ├── Runtime context (primary read API)
16
+ // │ ├── getRuntimeContext(opts?) -> HalRuntimeContext
17
+ // │ │ opts.callingAgentRuntimePath?: string|null
18
+ // │ │ - If null/unresolved, returns only `general` (no `callingAgent`)
19
+ // │ └── HalRuntimeContext
20
+ // │ ├── general
21
+ // │ └── callingAgent? (optional)
22
+ // │ └── skills[*].agentHasAccess (resolved from homeAgent + caller)
23
+ // ├── Installation checks
24
+ // │ ├── isInstalled(skillName)
25
+ // │ └── isInstalledAsync(skillName)
26
+ // ├── Per-skill config I/O (skill-owned files; separate from hal-system-config.json)
27
+ // │ ├── getSkillConfig(skillName, workspace)
28
+ // │ └── saveSkillConfig(skillName, workspace, data)
29
+ // └── Init-wizard writes (mutate hal-system-config.json)
30
+ // ├── register(name, runtimeData)
31
+ // ├── upsertCommunicationAccount(account)
32
+ // ├── deleteCommunicationAccount(label)
33
+ // ├── writeSkillConfig(name, data)
34
+ // └── writeSharedSettings(settings)
9
35
  // =============================================================================
10
36
 
11
37
  import fs from 'node:fs';
@@ -474,6 +500,190 @@ export function isInstalledAsync(name) {
474
500
  return _isBinaryOnPathAsync(bins[0]);
475
501
  }
476
502
 
503
+ function _canWriteDir(dirPath) {
504
+ try {
505
+ if (!dirPath) return false;
506
+ fs.accessSync(dirPath, fs.constants.W_OK);
507
+ return true;
508
+ } catch {
509
+ return false;
510
+ }
511
+ }
512
+
513
+ function _deriveAgentIdFromRuntimePath(runtimePath) {
514
+ const normalized = path.resolve(String(runtimePath || '').trim());
515
+ if (!normalized) return null;
516
+ const id = path.basename(normalized);
517
+ return id || null;
518
+ }
519
+
520
+ function _vaultDirectoryName(vaultPath) {
521
+ const p = String(vaultPath || '').trim();
522
+ if (!p) return '';
523
+ const normalized = p.replace(/[\\/]+$/, '');
524
+ const useWin = normalized.includes('\\') || /^[a-zA-Z]:\\/.test(normalized);
525
+ return useWin ? path.win32.basename(normalized) : path.posix.basename(normalized);
526
+ }
527
+
528
+ function _resolveMasterVault(vaultEntries) {
529
+ const masters = vaultEntries.filter(v => v.role === 'master');
530
+ if (masters.length === 0) return null;
531
+ if (masters.length > 1) {
532
+ throw new Error('ERROR: Multiple master vaults configured in hal-obsidian-vaults.');
533
+ }
534
+ const m = masters[0];
535
+ return {
536
+ name: m.name,
537
+ label: m.label,
538
+ absolutePath: m.absolutePath,
539
+ directoryName: m.directoryName,
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Build a normalized runtime context for HAL/OpenClaw consumers.
545
+ *
546
+ * @param {{ callingAgentRuntimePath?: string|null }} [opts]
547
+ * @returns {{ general: object, callingAgent?: object }}
548
+ */
549
+ export function getRuntimeContext(opts) {
550
+ _ensure();
551
+ const o = opts || {};
552
+
553
+ const openClawRoot = '/data/openclaw';
554
+ const openClawAgentRoot = '/data/agents';
555
+ const openClawConfigFile = path.join(openClawRoot, 'openclaw.json');
556
+ const openClawSharedAgentWorkspace = openclawSharedWorkspaceRoot();
557
+ const halRoot = halSystemConfigDir();
558
+ const halSystemConfigFile = halSystemConfigFilePath();
559
+
560
+ const rawVaults = vaults();
561
+ const vaultEntries = rawVaults.map(v => {
562
+ const name = String(v?.name || '').trim();
563
+ const label = String(v?.label || name).trim();
564
+ const absolutePath = String(v?.path || '').trim();
565
+ const role = String(v?.role || v?.vaultType || 'standard').trim().toLowerCase() === 'master'
566
+ ? 'master'
567
+ : 'standard';
568
+ const folderNames = vaultFolders(name);
569
+ return {
570
+ name,
571
+ label,
572
+ role,
573
+ absolutePath,
574
+ directoryName: _vaultDirectoryName(absolutePath),
575
+ knownAreas: {
576
+ projectsPath: _joinVaultSubpath(absolutePath, folderNames.projectsFolder),
577
+ dailyNotesPath: _joinVaultSubpath(absolutePath, folderNames.dailyNotesFolder),
578
+ emailPath: _joinVaultSubpath(absolutePath, folderNames.emailFolder),
579
+ meetingsPath: _joinVaultSubpath(absolutePath, folderNames.meetingsFolder),
580
+ peoplePath: _joinVaultSubpath(absolutePath, folderNames.peopleFolder),
581
+ },
582
+ };
583
+ });
584
+
585
+ const skillMap = _cache.skills || {};
586
+ const skills = {};
587
+ for (const [skillName, skillData] of Object.entries(skillMap)) {
588
+ const bins = (() => {
589
+ const bin = skillData?.install?.binary;
590
+ if (!bin) return [];
591
+ return Array.isArray(bin) ? bin : [bin];
592
+ })();
593
+ skills[skillName] = {
594
+ enabled: skillData?.enabled !== false,
595
+ homeAgent: skillData?.homeAgent || null,
596
+ binaries: bins,
597
+ configPath: skillConfigPath(skillName),
598
+ configDir: skillConfigDir(skillName),
599
+ };
600
+ }
601
+
602
+ const general = {
603
+ openClawPaths: {
604
+ openClawRoot,
605
+ openClawAgentRoot,
606
+ openClawConfigFile,
607
+ openClawSharedAgentWorkspace,
608
+ },
609
+ halPaths: {
610
+ halRoot,
611
+ halSystemConfigDir: halRoot,
612
+ halSystemConfigFile,
613
+ },
614
+ masterTemplates: {
615
+ masterUserPath: path.join(openClawSharedAgentWorkspace, 'USER.md'),
616
+ masterSafetyPath: path.join(openClawSharedAgentWorkspace, 'SAFETY.md'),
617
+ },
618
+ vaults: vaultEntries,
619
+ masterVault: _resolveMasterVault(vaultEntries),
620
+ communicationAccounts: _cache['hal-communication-accounts'] || [],
621
+ settings: {
622
+ timezone: _cache['hal-timezone'] || null,
623
+ workHours: _cache['hal-work-hours'] || null,
624
+ },
625
+ skills,
626
+ };
627
+
628
+ const runtimePathRaw = (o.callingAgentRuntimePath ?? process.env.HAL_AGENT_WORKSPACE ?? '').toString().trim();
629
+ if (!runtimePathRaw) return { general };
630
+
631
+ const runtimePath = path.resolve(runtimePathRaw);
632
+ const agentId = _deriveAgentIdFromRuntimePath(runtimePath);
633
+
634
+ const skillPathsBySkill = {};
635
+ const accessBySkill = {};
636
+ const agentSkillState = {};
637
+ for (const [skillName, skillData] of Object.entries(skills)) {
638
+ const sharedConfigDir = halSkillConfigDir(skillName);
639
+ const sharedLogDir = halSkillLogDir(skillName);
640
+ const agentWorkingDir = path.join(runtimePath, 'hal', skillName);
641
+ skillPathsBySkill[skillName] = {
642
+ skillName,
643
+ sharedConfigDir,
644
+ sharedLogDir,
645
+ agentWorkingDir,
646
+ agentStateDir: path.join(agentWorkingDir, 'state'),
647
+ agentCacheDir: path.join(agentWorkingDir, 'cache'),
648
+ agentTmpDir: path.join(agentWorkingDir, 'tmp'),
649
+ agentSweepDir: path.join(agentWorkingDir, 'sweep'),
650
+ };
651
+ const hasAccess = !skillData.homeAgent || (agentId && skillData.homeAgent === agentId);
652
+ accessBySkill[skillName] = !!hasAccess;
653
+ agentSkillState[skillName] = { agentHasAccess: !!hasAccess };
654
+ }
655
+
656
+ const callingAgent = {
657
+ id: agentId,
658
+ workspacePath: runtimePath,
659
+ identityFiles: {
660
+ soul: path.join(runtimePath, 'SOUL.md'),
661
+ agents: path.join(runtimePath, 'AGENTS.md'),
662
+ identity: path.join(runtimePath, 'IDENTITY.md'),
663
+ heartbeat: path.join(runtimePath, 'HEARTBEAT.md'),
664
+ tools: path.join(runtimePath, 'TOOLS.md'),
665
+ user: path.join(runtimePath, 'USER.md'),
666
+ safety: path.join(runtimePath, 'SAFETY.md'),
667
+ },
668
+ stores: {
669
+ memoryDir: path.join(runtimePath, 'memory'),
670
+ knowledgeDir: path.join(runtimePath, 'knowledge'),
671
+ skillsDir: path.join(runtimePath, 'skills'),
672
+ },
673
+ skillPaths: {
674
+ bySkill: skillPathsBySkill,
675
+ },
676
+ skills: agentSkillState,
677
+ capabilities: {
678
+ canWriteAgentWorkspace: _canWriteDir(runtimePath),
679
+ canWriteSharedWorkspace: _canWriteDir(openClawSharedAgentWorkspace),
680
+ isHomeAgentForSkill: accessBySkill,
681
+ },
682
+ };
683
+
684
+ return { general, callingAgent };
685
+ }
686
+
477
687
  // ---------------------------------------------------------------------------
478
688
  // Binary helpers
479
689
  // ---------------------------------------------------------------------------
@@ -521,25 +731,51 @@ export function register(name, runtimeData) {
521
731
  }
522
732
 
523
733
  /**
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
734
+ * Upsert a single communication account by label.
735
+ *
736
+ * @param {import('./types').HalCommunicationAccountEntry} account
527
737
  */
528
- export function writeAccounts(newAccounts) {
738
+ export function upsertCommunicationAccount(account) {
529
739
  _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
- }
740
+ if (!account || typeof account !== 'object') {
741
+ throw new Error('ERROR: upsertCommunicationAccount requires an account object.');
742
+ }
743
+ if (!account.label || typeof account.label !== 'string') {
744
+ throw new Error('ERROR: upsertCommunicationAccount requires account.label (string).');
538
745
  }
746
+ if (!account.provider || typeof account.provider !== 'string') {
747
+ throw new Error('ERROR: upsertCommunicationAccount requires account.provider (string).');
748
+ }
749
+
750
+ const existing = _cache['hal-communication-accounts'] || [];
751
+ const idx = existing.findIndex(e => e.label === account.label);
752
+ if (idx >= 0) existing[idx] = account;
753
+ else existing.push(account);
754
+
539
755
  _cache['hal-communication-accounts'] = existing;
540
756
  _flush();
541
757
  }
542
758
 
759
+ /**
760
+ * Delete a communication account by label.
761
+ *
762
+ * @param {string} label
763
+ * @returns {boolean} true when an entry was removed
764
+ */
765
+ export function deleteCommunicationAccount(label) {
766
+ _ensure();
767
+ if (!label || typeof label !== 'string') {
768
+ throw new Error('ERROR: deleteCommunicationAccount requires label (string).');
769
+ }
770
+
771
+ const existing = _cache['hal-communication-accounts'] || [];
772
+ const before = existing.length;
773
+ _cache['hal-communication-accounts'] = existing.filter(e => e?.label !== label);
774
+ const removed = _cache['hal-communication-accounts'].length < before;
775
+ if (removed) _flush();
776
+ return removed;
777
+ }
778
+
543
779
  /**
544
780
  * Merge key/value pairs into a skill's config block (not runtime — top level
545
781
  * of the skill entry). Used by init wizards to store skill-specific settings.
@@ -623,7 +859,7 @@ export function resolveWorkspace(opts) {
623
859
  * @param {string} workspace
624
860
  * @returns {object}
625
861
  */
626
- export function loadSkillConfig(skillName, workspace) {
862
+ export function getSkillConfig(skillName, workspace) {
627
863
  const filePath = path.join(workspace, 'hal', 'config', `${skillName}.json`);
628
864
  try {
629
865
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
@@ -633,6 +869,9 @@ export function loadSkillConfig(skillName, workspace) {
633
869
  }
634
870
  }
635
871
 
872
+ // Backward compatibility alias; prefer getSkillConfig().
873
+ export const loadSkillConfig = getSkillConfig;
874
+
636
875
  /**
637
876
  * Deep-merge source into target. Plain objects are merged recursively.
638
877
  * Arrays are replaced atomically. Non-object values overwrite.
package/lib/types.d.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * =============================================================================
3
+ * hal-config/lib/types
4
+ *
5
+ * SRP (Single Responsibility)
6
+ * - Define the shared TypeScript contracts for HAL/OpenClaw runtime context:
7
+ * global platform state plus calling-agent state used by skills at runtime.
8
+ *
9
+ * Interface Tree
10
+ HalRuntimeContext
11
+ ├── general: HalGeneralState
12
+ │ ├── openClawPaths
13
+ │ │ ├── openClawRoot
14
+ │ │ ├── openClawAgentRoot
15
+ │ │ ├── openClawConfigFile
16
+ │ │ └── openClawSharedAgentWorkspace
17
+ │ ├── halPaths
18
+ │ │ ├── halRoot
19
+ │ │ ├── halSystemConfigDir
20
+ │ │ └── halSystemConfigFile
21
+ │ ├── masterTemplates
22
+ │ │ ├── masterUserPath (e.g. {openClawSharedAgentWorkspace}/USER.md)
23
+ │ │ └── masterSafetyPath (e.g. {openClawSharedAgentWorkspace}/SAFETY.md)
24
+ │ ├── vaults: HalVaultEntry[]
25
+ │ │ └── HalVaultEntry
26
+ │ │ ├── name
27
+ │ │ ├── label
28
+ │ │ ├── role (master|standard)
29
+ │ │ ├── absolutePath
30
+ │ │ ├── directoryName
31
+ │ │ └── knownAreas
32
+ │ │ ├── projectsPath
33
+ │ │ ├── dailyNotesPath
34
+ │ │ ├── emailPath
35
+ │ │ ├── meetingsPath
36
+ │ │ └── peoplePath
37
+ │ ├── masterVault
38
+ │ │ ├── name
39
+ │ │ ├── label
40
+ │ │ ├── absolutePath
41
+ │ │ └── directoryName
42
+ │ ├── communicationAccounts: HalCommunicationAccountEntry[]
43
+ │ │ └── HalCommunicationAccountEntry
44
+ │ │ ├── label
45
+ │ │ ├── provider
46
+ │ │ ├── email?
47
+ │ │ └── scopes?
48
+ │ ├── settings
49
+ │ │ ├── timezone
50
+ │ │ └── workHours
51
+ │ │ ├── start
52
+ │ │ └── end
53
+ │ └── skills: Record<string, HalSkillRuntimeEntry>
54
+ │ └── HalSkillRuntimeEntry
55
+ │ ├── enabled
56
+ │ ├── homeAgent
57
+ │ ├── binaries[]
58
+ │ ├── configPath
59
+ │ └── configDir
60
+ └── callingAgent: HalCallingAgentState
61
+ ├── id
62
+ ├── workspacePath
63
+ ├── identityFiles
64
+ │ ├── soul
65
+ │ ├── agents
66
+ │ ├── identity
67
+ │ ├── heartbeat
68
+ │ ├── tools
69
+ │ ├── user
70
+ │ └── safety
71
+ ├── stores
72
+ │ ├── memoryDir
73
+ │ ├── knowledgeDir
74
+ │ └── skillsDir
75
+ ├── skillPaths
76
+ │ └── bySkill: Record<string, HalSkillPathSet>
77
+ │ └── HalSkillPathSet
78
+ │ ├── skillName
79
+ │ ├── sharedConfigDir
80
+ │ ├── sharedLogDir
81
+ │ ├── agentWorkingDir
82
+ │ ├── agentStateDir
83
+ │ ├── agentCacheDir
84
+ │ ├── agentTmpDir
85
+ │ └── agentSweepDir
86
+ └── capabilities
87
+ ├── canWriteAgentWorkspace
88
+ ├── canWriteSharedWorkspace
89
+ └── isHomeAgentForSkill: Record<string, boolean>
90
+ * =============================================================================
91
+ */
92
+ export type VaultRole = 'master' | 'standard';
93
+
94
+ export interface HalVaultAreas {
95
+ projectsFolder: string;
96
+ dailyNotesFolder: string;
97
+ emailFolder: string;
98
+ meetingsFolder: string;
99
+ peopleFolder: string;
100
+ }
101
+
102
+ export interface HalVaultResolvedAreas {
103
+ projectsPath: string;
104
+ dailyNotesPath: string;
105
+ emailPath: string;
106
+ meetingsPath: string;
107
+ peoplePath: string;
108
+ }
109
+
110
+ export interface HalVaultEntry {
111
+ name: string;
112
+ label: string;
113
+ role: VaultRole;
114
+ absolutePath: string;
115
+ directoryName: string;
116
+ knownAreas: HalVaultResolvedAreas;
117
+ }
118
+
119
+ export interface HalMasterVaultRef {
120
+ name: string;
121
+ label: string;
122
+ absolutePath: string;
123
+ directoryName: string;
124
+ }
125
+
126
+ export interface HalCommunicationAccountEntry {
127
+ label: string;
128
+ provider: string;
129
+ email?: string;
130
+ scopes?: string[];
131
+ [key: string]: unknown;
132
+ }
133
+
134
+ export interface HalSkillRuntimeEntry {
135
+ enabled: boolean;
136
+ homeAgent: string | null;
137
+ binaries: string[];
138
+ configPath: string;
139
+ configDir: string;
140
+ }
141
+
142
+ export interface HalSkillPathSet {
143
+ skillName: string;
144
+ sharedConfigDir: string;
145
+ sharedLogDir: string;
146
+ agentWorkingDir: string;
147
+ agentStateDir: string;
148
+ agentCacheDir: string;
149
+ agentTmpDir: string;
150
+ agentSweepDir: string;
151
+ }
152
+
153
+ export interface HalGeneralState {
154
+ openClawPaths: {
155
+ openClawRoot: string;
156
+ openClawAgentRoot: string;
157
+ openClawConfigFile: string;
158
+ openClawSharedAgentWorkspace: string;
159
+ };
160
+
161
+ halPaths: {
162
+ halRoot: string;
163
+ halSystemConfigDir: string;
164
+ halSystemConfigFile: string;
165
+ };
166
+
167
+ masterTemplates: {
168
+ masterUserPath: string;
169
+ masterSafetyPath: string;
170
+ };
171
+
172
+ vaults: HalVaultEntry[];
173
+ masterVault: HalMasterVaultRef | null;
174
+ communicationAccounts: HalCommunicationAccountEntry[];
175
+
176
+ settings: {
177
+ timezone: string | null;
178
+ workHours: { start: string; end: string } | null;
179
+ };
180
+
181
+ skills: Record<string, HalSkillRuntimeEntry>;
182
+ }
183
+
184
+ export interface HalCallingAgentState {
185
+ id: string | null;
186
+ workspacePath: string | null;
187
+
188
+ identityFiles: {
189
+ soul: string | null;
190
+ agents: string | null;
191
+ identity: string | null;
192
+ heartbeat: string | null;
193
+ tools: string | null;
194
+ user: string | null;
195
+ safety: string | null;
196
+ };
197
+
198
+ stores: {
199
+ memoryDir: string | null;
200
+ knowledgeDir: string | null;
201
+ skillsDir: string | null;
202
+ };
203
+
204
+ skillPaths: {
205
+ bySkill: Record<string, HalSkillPathSet>;
206
+ };
207
+
208
+ capabilities: {
209
+ canWriteAgentWorkspace: boolean;
210
+ canWriteSharedWorkspace: boolean;
211
+ isHomeAgentForSkill: Record<string, boolean>;
212
+ };
213
+ }
214
+
215
+ export interface HalRuntimeContext {
216
+ general: HalGeneralState;
217
+ callingAgent: HalCallingAgentState;
218
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jamaynor/hal-config",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "Shared configuration loader for HAL OpenClaw skills — reads system-config.json, provides skill discovery, and runtime registration",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/test/test.js CHANGED
@@ -495,12 +495,12 @@ describe('hal-shared-config', () => {
495
495
  });
496
496
  });
497
497
 
498
- describe('writeAccounts()', () => {
499
- it('appends new accounts', () => {
498
+ describe('upsertCommunicationAccount()', () => {
499
+ it('appends new account', () => {
500
500
  hal.load(tmpDir);
501
- hal.writeAccounts([
502
- { label: 'imap@example.com', provider: 'imap', email: 'imap@example.com' },
503
- ]);
501
+ hal.upsertCommunicationAccount(
502
+ { label: 'imap@example.com', provider: 'imap', email: 'imap@example.com' }
503
+ );
504
504
 
505
505
  const ondisk = readConfig(tmpDir);
506
506
  assert.equal(ondisk['hal-communication-accounts'].length, 3);
@@ -508,10 +508,10 @@ describe('hal-shared-config', () => {
508
508
 
509
509
  it('replaces existing account by label', () => {
510
510
  hal.load(tmpDir);
511
- hal.writeAccounts([
511
+ hal.upsertCommunicationAccount(
512
512
  { label: 'me@gmail.com', provider: 'google', email: 'me@gmail.com',
513
- scopes: ['email'] },
514
- ]);
513
+ scopes: ['email'] }
514
+ );
515
515
 
516
516
  const ondisk = readConfig(tmpDir);
517
517
  assert.equal(ondisk['hal-communication-accounts'].length, 2);
@@ -522,9 +522,9 @@ describe('hal-shared-config', () => {
522
522
 
523
523
  it('does not remove unmentioned accounts', () => {
524
524
  hal.load(tmpDir);
525
- hal.writeAccounts([
526
- { label: 'new@example.com', provider: 'imap', email: 'new@example.com' },
527
- ]);
525
+ hal.upsertCommunicationAccount(
526
+ { label: 'new@example.com', provider: 'imap', email: 'new@example.com' }
527
+ );
528
528
 
529
529
  const ondisk = readConfig(tmpDir);
530
530
  const labels = ondisk['hal-communication-accounts'].map(a => a.label);
@@ -534,6 +534,23 @@ describe('hal-shared-config', () => {
534
534
  });
535
535
  });
536
536
 
537
+ describe('deleteCommunicationAccount()', () => {
538
+ it('deletes an existing account by label', () => {
539
+ hal.load(tmpDir);
540
+ const removed = hal.deleteCommunicationAccount('me@gmail.com');
541
+ assert.equal(removed, true);
542
+ const ondisk = readConfig(tmpDir);
543
+ const labels = ondisk['hal-communication-accounts'].map(a => a.label);
544
+ assert.ok(!labels.includes('me@gmail.com'));
545
+ });
546
+
547
+ it('returns false when label does not exist', () => {
548
+ hal.load(tmpDir);
549
+ const removed = hal.deleteCommunicationAccount('missing@example.com');
550
+ assert.equal(removed, false);
551
+ });
552
+ });
553
+
537
554
  describe('writeSkillConfig()', () => {
538
555
  it('merges into skill block', () => {
539
556
  hal.load(tmpDir);
package/publish.ps1 DELETED
@@ -1,30 +0,0 @@
1
- $ErrorActionPreference = 'Stop'
2
-
3
- Write-Host "Publishing @jamaynor/hal-config from this directory..."
4
-
5
- # Prompt for token without echoing it to the console.
6
- $secure = Read-Host -Prompt "Paste npm automation token (input hidden)" -AsSecureString
7
- $token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
8
- [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
9
- )
10
-
11
- if (-not $token -or -not $token.Trim()) {
12
- throw "No token provided."
13
- }
14
-
15
- try {
16
- $env:NPM_TOKEN = $token.Trim()
17
-
18
- Write-Host ""
19
- Write-Host "npm whoami:"
20
- npm whoami
21
-
22
- Write-Host ""
23
- Write-Host "npm publish:"
24
- npm publish
25
- } finally {
26
- Remove-Item Env:NPM_TOKEN -ErrorAction SilentlyContinue
27
- # Reduce lifetime of plaintext token in memory.
28
- $token = $null
29
- }
30
-