@karmaniverous/jeeves 0.4.0 → 0.4.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.
@@ -0,0 +1,1003 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync, renameSync, existsSync, readFileSync, rmSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import * as commander from 'commander';
5
+ import { lock } from 'proper-lockfile';
6
+ import 'semver';
7
+ import { homedir } from 'node:os';
8
+
9
+ function getDefaultExportFromCjs (x) {
10
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
11
+ }
12
+
13
+ function getAugmentedNamespace(n) {
14
+ if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
15
+ var f = n.default;
16
+ if (typeof f == "function") {
17
+ var a = function a () {
18
+ var isInstance = false;
19
+ try {
20
+ isInstance = this instanceof a;
21
+ } catch {}
22
+ if (isInstance) {
23
+ return Reflect.construct(f, arguments, this.constructor);
24
+ }
25
+ return f.apply(this, arguments);
26
+ };
27
+ a.prototype = f.prototype;
28
+ } else a = {};
29
+ Object.defineProperty(a, '__esModule', {value: true});
30
+ Object.keys(n).forEach(function (k) {
31
+ var d = Object.getOwnPropertyDescriptor(n, k);
32
+ Object.defineProperty(a, k, d.get ? d : {
33
+ enumerable: true,
34
+ get: function () {
35
+ return n[k];
36
+ }
37
+ });
38
+ });
39
+ return a;
40
+ }
41
+
42
+ var extraTypings = {exports: {}};
43
+
44
+ var require$$0 = /*@__PURE__*/getAugmentedNamespace(commander);
45
+
46
+ var hasRequiredExtraTypings;
47
+
48
+ function requireExtraTypings () {
49
+ if (hasRequiredExtraTypings) return extraTypings.exports;
50
+ hasRequiredExtraTypings = 1;
51
+ (function (module, exports$1) {
52
+ const commander = require$$0;
53
+
54
+ exports$1 = module.exports = {};
55
+
56
+ // Return a different global program than commander,
57
+ // and don't also return it as default export.
58
+ exports$1.program = new commander.Command();
59
+
60
+ /**
61
+ * Expose classes. The FooT versions are just types, so return Commander original implementations!
62
+ */
63
+
64
+ exports$1.Argument = commander.Argument;
65
+ exports$1.Command = commander.Command;
66
+ exports$1.CommanderError = commander.CommanderError;
67
+ exports$1.Help = commander.Help;
68
+ exports$1.InvalidArgumentError = commander.InvalidArgumentError;
69
+ exports$1.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
70
+ exports$1.Option = commander.Option;
71
+
72
+ exports$1.createCommand = (name) => new commander.Command(name);
73
+ exports$1.createOption = (flags, description) =>
74
+ new commander.Option(flags, description);
75
+ exports$1.createArgument = (name, description) =>
76
+ new commander.Argument(name, description);
77
+ } (extraTypings, extraTypings.exports));
78
+ return extraTypings.exports;
79
+ }
80
+
81
+ var extraTypingsExports = requireExtraTypings();
82
+ var extraTypingsCommander = /*@__PURE__*/getDefaultExportFromCjs(extraTypingsExports);
83
+
84
+ // wrapper to provide named exports for ESM.
85
+ const {
86
+ program,
87
+ createCommand,
88
+ createArgument,
89
+ createOption,
90
+ CommanderError,
91
+ InvalidArgumentError,
92
+ InvalidOptionArgumentError, // deprecated old name
93
+ Command,
94
+ Argument,
95
+ Option,
96
+ Help,
97
+ } = extraTypingsCommander;
98
+
99
+ /**
100
+ * Directory and file path conventions for the Jeeves platform.
101
+ */
102
+ /** Core config directory name within the config root. */
103
+ const CORE_CONFIG_DIR = 'jeeves-core';
104
+ /** Default workspace file names. */
105
+ const WORKSPACE_FILES = {
106
+ /** TOOLS.md — live platform state and component sections. */
107
+ tools: 'TOOLS.md',
108
+ /** SOUL.md — professional discipline and behavioral foundations. */
109
+ soul: 'SOUL.md',
110
+ /** AGENTS.md — operational protocols and memory architecture. */
111
+ agents: 'AGENTS.md',
112
+ /** HEARTBEAT.md — platform status and health alerts. */
113
+ heartbeat: 'HEARTBEAT.md',
114
+ };
115
+ /** Component versions state file name. */
116
+ const COMPONENT_VERSIONS_FILE = 'component-versions.json';
117
+
118
+ /**
119
+ * Core library version, inlined at build time.
120
+ *
121
+ * @remarks
122
+ * The `0.4.0` placeholder is replaced by
123
+ * `@rollup/plugin-replace` during the build with the actual version
124
+ * from `package.json`. This ensures the correct version survives
125
+ * when consumers bundle core into their own dist (where runtime
126
+ * `import.meta.url`-based resolution would find the wrong package.json).
127
+ */
128
+ /** The core library version from package.json (inlined at build time). */
129
+ const CORE_VERSION = '0.4.0';
130
+
131
+ /**
132
+ * Shared file I/O helpers for managed section operations.
133
+ *
134
+ * @remarks
135
+ * Extracts the atomic write pattern and file-level locking into
136
+ * reusable utilities, eliminating duplication between
137
+ * `updateManagedSection` and `removeManagedSection`.
138
+ */
139
+ /** Stale lock threshold in ms (2 minutes). */
140
+ const STALE_LOCK_MS = 120_000;
141
+ /** Default core version when none provided. */
142
+ const DEFAULT_CORE_VERSION = CORE_VERSION;
143
+ /** Lock retry options. */
144
+ const LOCK_RETRIES = { retries: 5, minTimeout: 100, maxTimeout: 1000 };
145
+ /**
146
+ * Write content to a file atomically via a temp file + rename.
147
+ *
148
+ * @param filePath - Absolute path to the target file.
149
+ * @param content - Content to write.
150
+ */
151
+ function atomicWrite(filePath, content) {
152
+ const dir = dirname(filePath);
153
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
154
+ writeFileSync(tempPath, content, 'utf-8');
155
+ renameSync(tempPath, filePath);
156
+ }
157
+ /**
158
+ * Execute a callback while holding a file lock.
159
+ *
160
+ * @remarks
161
+ * Acquires a lock on the file, executes the callback, and releases
162
+ * the lock in a finally block. The lock uses a 2-minute stale threshold
163
+ * and retries up to 5 times.
164
+ *
165
+ * @param filePath - Absolute path to the file to lock.
166
+ * @param fn - Async callback to execute while holding the lock.
167
+ */
168
+ async function withFileLock(filePath, fn) {
169
+ let release;
170
+ try {
171
+ release = await lock(filePath, {
172
+ stale: STALE_LOCK_MS,
173
+ retries: LOCK_RETRIES,
174
+ });
175
+ await fn();
176
+ }
177
+ finally {
178
+ if (release) {
179
+ try {
180
+ await release();
181
+ }
182
+ catch {
183
+ // Lock already released or file deleted — safe to ignore
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Shared component version state file management.
191
+ *
192
+ * @remarks
193
+ * Each `ComponentWriter` cycle writes its component's entry to
194
+ * `{coreConfigDir}/component-versions.json`. The Platform Handlebars
195
+ * template reads this file to populate ALL rows in the service health
196
+ * table, not just the calling component's.
197
+ */
198
+ /**
199
+ * Read the component versions state file.
200
+ *
201
+ * @param coreConfigDir - Path to the core config directory.
202
+ * @returns The parsed state, or an empty object if the file doesn't exist.
203
+ */
204
+ function readComponentVersions(coreConfigDir) {
205
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
206
+ if (!existsSync(filePath))
207
+ return {};
208
+ try {
209
+ const raw = readFileSync(filePath, 'utf-8');
210
+ return JSON.parse(raw);
211
+ }
212
+ catch {
213
+ return {};
214
+ }
215
+ }
216
+ /**
217
+ * Remove a component's version entry from the shared state file.
218
+ *
219
+ * @remarks
220
+ * Called during plugin uninstall to prevent the HEARTBEAT writer from
221
+ * probing a service that's intentionally gone. If the component isn't
222
+ * in the file, this is a no-op.
223
+ *
224
+ * @param coreConfigDir - Path to the core config directory.
225
+ * @param componentName - The component name to remove.
226
+ */
227
+ function removeComponentVersion(coreConfigDir, componentName) {
228
+ const existing = readComponentVersions(coreConfigDir);
229
+ if (!(componentName in existing))
230
+ return;
231
+ const updated = Object.fromEntries(Object.entries(existing).filter(([key]) => key !== componentName));
232
+ const filePath = join(coreConfigDir, COMPONENT_VERSIONS_FILE);
233
+ atomicWrite(filePath, JSON.stringify(updated, null, 2) + '\n');
234
+ }
235
+
236
+ /**
237
+ * Comment markers for managed content blocks.
238
+ *
239
+ * @remarks
240
+ * Managed content in TOOLS.md, SOUL.md, and AGENTS.md is enclosed
241
+ * in HTML comment markers. Content between markers is refreshed
242
+ * atomically on each writer cycle. User content outside the markers
243
+ * is never touched.
244
+ */
245
+ /** Default markers for TOOLS.md managed block. */
246
+ const TOOLS_MARKERS = {
247
+ /** BEGIN comment marker text. */
248
+ begin: 'BEGIN JEEVES PLATFORM TOOLS — DO NOT EDIT THIS SECTION',
249
+ /** END comment marker text. */
250
+ end: 'END JEEVES PLATFORM TOOLS',
251
+ /** H1 title prepended in section mode. */
252
+ title: 'Jeeves Platform Tools',
253
+ /** Managed block at bottom of file. */
254
+ position: 'bottom',
255
+ };
256
+ /**
257
+ * Regex pattern to extract version stamp from a BEGIN marker comment.
258
+ *
259
+ * @remarks
260
+ * Format: `\<!-- BEGIN MARKER | core:X.Y.Z | ISO-TIMESTAMP --\>`
261
+ * Captures: [1] marker text, [2] version, [3] timestamp
262
+ */
263
+ const VERSION_STAMP_PATTERN = /<!--\s*(.+?)\s*\|\s*core:(\S+)\s*\|\s*(\S+)\s*-->/;
264
+
265
+ /**
266
+ * Managed section IDs, stable ordering, and platform component registry.
267
+ *
268
+ * @remarks
269
+ * Section ordering is fixed to prevent diff churn regardless of which
270
+ * component writes last. Sections always appear in this order.
271
+ */
272
+ /** Known section IDs for TOOLS.md managed block. */
273
+ const SECTION_IDS = {
274
+ /** Platform health and guidance section. */
275
+ Platform: 'Platform',
276
+ /** Watcher index stats and search configuration. */
277
+ Watcher: 'Watcher',
278
+ /** Server export capabilities and connected services. */
279
+ Server: 'Server',
280
+ /** Runner job status and active scripts. */
281
+ Runner: 'Runner',
282
+ /** Meta synthesis entity summary and tools. */
283
+ Meta: 'Meta',
284
+ };
285
+ /**
286
+ * Stable ordering of sections within the managed TOOLS.md block.
287
+ * Sections always appear in this order regardless of write order.
288
+ */
289
+ const SECTION_ORDER = [
290
+ SECTION_IDS.Platform,
291
+ SECTION_IDS.Watcher,
292
+ SECTION_IDS.Server,
293
+ SECTION_IDS.Runner,
294
+ SECTION_IDS.Meta,
295
+ ];
296
+
297
+ /**
298
+ * Workspace and config root initialization.
299
+ *
300
+ * @remarks
301
+ * `init()` must be called once before any other core library functions.
302
+ * It caches `workspacePath` and `configRoot` at module level.
303
+ * Core derives all namespaced paths from these values:
304
+ * - `{configRoot}/jeeves-core/` for core config
305
+ * - `{configRoot}/jeeves-{name}/` for each component
306
+ */
307
+ let state;
308
+ /**
309
+ * Initialize the core library with workspace and config root paths.
310
+ *
311
+ * @param options - Workspace and config root paths.
312
+ */
313
+ function init(options) {
314
+ state = {
315
+ workspacePath: options.workspacePath,
316
+ configRoot: options.configRoot,
317
+ coreConfigDir: join(options.configRoot, CORE_CONFIG_DIR),
318
+ };
319
+ }
320
+ /**
321
+ * Get the core config directory path.
322
+ *
323
+ * @throws Error if `init()` has not been called.
324
+ */
325
+ function getCoreConfigDir() {
326
+ if (!state)
327
+ throw new Error('jeeves-core: init() must be called first');
328
+ return state.coreConfigDir;
329
+ }
330
+
331
+ /**
332
+ * Heading-based HEARTBEAT section writer.
333
+ *
334
+ * @remarks
335
+ * Manages the `# Jeeves Platform Status` section in HEARTBEAT.md.
336
+ * Unlike TOOLS/SOUL/AGENTS (which use HTML comment markers), HEARTBEAT
337
+ * uses markdown headings as markers — this ensures the file passes
338
+ * OpenClaw's heartbeat emptiness check when only headings remain.
339
+ *
340
+ * The section is always at the bottom of the file (H1 to EOF).
341
+ * User heartbeat items above the section are preserved.
342
+ */
343
+ /** The H1 heading that anchors the platform status section. */
344
+ const HEARTBEAT_HEADING = '# Jeeves Platform Status';
345
+ /**
346
+ * Parse the HEARTBEAT.md file content.
347
+ *
348
+ * @param fileContent - Full file content.
349
+ * @returns Parsed result with user zone and component entries.
350
+ */
351
+ function parseHeartbeat(fileContent) {
352
+ const headingIndex = fileContent.indexOf(HEARTBEAT_HEADING);
353
+ if (headingIndex === -1) {
354
+ return {
355
+ userContent: fileContent.trim(),
356
+ found: false,
357
+ entries: [],
358
+ };
359
+ }
360
+ const userContent = fileContent.slice(0, headingIndex).trim();
361
+ const sectionContent = fileContent.slice(headingIndex + HEARTBEAT_HEADING.length);
362
+ const entries = [];
363
+ const h2Re = /^## (jeeves-\S+?)(?:: declined)?$/gm;
364
+ let match;
365
+ const h2Positions = [];
366
+ while ((match = h2Re.exec(sectionContent)) !== null) {
367
+ const fullHeading = match[0];
368
+ const name = match[1];
369
+ const declined = fullHeading.endsWith(': declined');
370
+ h2Positions.push({ name, declined, start: match.index });
371
+ }
372
+ for (let i = 0; i < h2Positions.length; i++) {
373
+ const pos = h2Positions[i];
374
+ const headingLine = pos.declined
375
+ ? `## ${pos.name}: declined`
376
+ : `## ${pos.name}`;
377
+ const contentStart = pos.start + headingLine.length;
378
+ const contentEnd = i + 1 < h2Positions.length
379
+ ? h2Positions[i + 1].start
380
+ : sectionContent.length;
381
+ const content = sectionContent.slice(contentStart, contentEnd).trim();
382
+ entries.push({
383
+ name: pos.name,
384
+ declined: pos.declined,
385
+ content,
386
+ });
387
+ }
388
+ return { userContent, found: true, entries };
389
+ }
390
+ /**
391
+ * Build the HEARTBEAT section content from entries.
392
+ *
393
+ * @param entries - Component entries to write.
394
+ * @returns The full section string (H1 + H2s).
395
+ */
396
+ function buildHeartbeatSection(entries) {
397
+ const parts = [HEARTBEAT_HEADING];
398
+ for (const entry of entries) {
399
+ if (entry.declined) {
400
+ parts.push(`## ${entry.name}: declined`);
401
+ }
402
+ else if (entry.content) {
403
+ parts.push(`## ${entry.name}`);
404
+ parts.push(entry.content);
405
+ }
406
+ // Healthy components (no content, not declined) get no H2 section
407
+ }
408
+ return parts.join('\n');
409
+ }
410
+
411
+ /**
412
+ * Stable section ordering for managed TOOLS.md blocks.
413
+ *
414
+ * @remarks
415
+ * Sorts sections by the canonical SECTION_ORDER: known sections
416
+ * appear in their defined order, unknown sections are appended after.
417
+ * Used by both parseManaged (for consistent output) and
418
+ * updateManagedSection (for reassembly).
419
+ */
420
+ /**
421
+ * Sort sections in place by stable ordering.
422
+ *
423
+ * @param sections - Array of managed sections to sort.
424
+ * @returns The sorted array (same reference, mutated in place).
425
+ */
426
+ function sortSectionsByOrder(sections) {
427
+ return sections.sort((a, b) => {
428
+ const aIdx = SECTION_ORDER.indexOf(a.id);
429
+ const bIdx = SECTION_ORDER.indexOf(b.id);
430
+ const aOrder = aIdx === -1 ? SECTION_ORDER.length : aIdx;
431
+ const bOrder = bIdx === -1 ? SECTION_ORDER.length : bIdx;
432
+ return aOrder - bOrder;
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Parse managed block from file content.
438
+ *
439
+ * @remarks
440
+ * Extracts managed content delimited by comment markers, parses H2
441
+ * sections within the block, and returns the structured result plus
442
+ * user content outside the markers.
443
+ */
444
+ /**
445
+ * Build regex patterns for the given markers.
446
+ *
447
+ * @param markers - Begin/end marker strings.
448
+ * @returns Object with begin and end regex patterns.
449
+ */
450
+ function buildMarkerPatterns(markers) {
451
+ const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
452
+ const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
453
+ return {
454
+ beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
455
+ endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
456
+ };
457
+ }
458
+ /**
459
+ * Parse H2 sections from managed block content.
460
+ *
461
+ * @param content - Raw managed block content.
462
+ * @returns Array of parsed sections in stable order.
463
+ */
464
+ function parseSections(content) {
465
+ const lines = content.split('\n');
466
+ const sections = [];
467
+ let currentId;
468
+ let currentLines = [];
469
+ for (const line of lines) {
470
+ const h2Match = /^## (.+)$/.exec(line);
471
+ if (h2Match) {
472
+ if (currentId !== undefined) {
473
+ sections.push({
474
+ id: currentId,
475
+ content: currentLines.join('\n').trim(),
476
+ });
477
+ }
478
+ currentId = h2Match[1];
479
+ currentLines = [];
480
+ }
481
+ else if (currentId !== undefined) {
482
+ currentLines.push(line);
483
+ }
484
+ }
485
+ if (currentId !== undefined) {
486
+ sections.push({
487
+ id: currentId,
488
+ content: currentLines.join('\n').trim(),
489
+ });
490
+ }
491
+ return sortSectionsByOrder(sections);
492
+ }
493
+ /**
494
+ * Parse a managed block from file content.
495
+ *
496
+ * @param fileContent - Full file content.
497
+ * @param markers - Optional custom markers (defaults to TOOLS markers).
498
+ * @returns Parsed result with sections, version stamp, and user content.
499
+ */
500
+ function parseManaged(fileContent, markers = TOOLS_MARKERS) {
501
+ const { beginRe, endRe } = buildMarkerPatterns(markers);
502
+ const beginMatch = beginRe.exec(fileContent);
503
+ if (!beginMatch) {
504
+ return {
505
+ found: false,
506
+ versionStamp: undefined,
507
+ managedContent: '',
508
+ sections: [],
509
+ beforeContent: '',
510
+ userContent: fileContent,
511
+ };
512
+ }
513
+ const endMatch = endRe.exec(fileContent.slice(beginMatch.index + beginMatch[0].length));
514
+ if (!endMatch) {
515
+ // Corrupt: BEGIN without END — treat as fresh file
516
+ return {
517
+ found: false,
518
+ versionStamp: undefined,
519
+ managedContent: '',
520
+ sections: [],
521
+ beforeContent: '',
522
+ userContent: fileContent,
523
+ };
524
+ }
525
+ const beforeContent = fileContent.slice(0, beginMatch.index).trim();
526
+ const managedStart = beginMatch.index + beginMatch[0].length;
527
+ const managedEnd = managedStart + endMatch.index;
528
+ const managedContent = fileContent.slice(managedStart, managedEnd).trim();
529
+ const afterEnd = managedStart + endMatch.index + endMatch[0].length;
530
+ const userContent = fileContent.slice(afterEnd).trim();
531
+ // Extract version stamp from BEGIN marker line
532
+ let versionStamp;
533
+ const stampMatch = VERSION_STAMP_PATTERN.exec(beginMatch[0]);
534
+ if (stampMatch?.[2] && stampMatch[3]) {
535
+ versionStamp = {
536
+ version: stampMatch[2],
537
+ timestamp: stampMatch[3],
538
+ };
539
+ }
540
+ const sections = parseSections(managedContent);
541
+ return {
542
+ found: true,
543
+ versionStamp,
544
+ managedContent,
545
+ sections,
546
+ beforeContent,
547
+ userContent,
548
+ };
549
+ }
550
+
551
+ /**
552
+ * Version-stamp parsing and convergence logic.
553
+ *
554
+ * @remarks
555
+ * When multiple component plugins bundle different core library versions,
556
+ * they independently maintain shared managed content. The version-stamp
557
+ * mechanism ensures convergence without coordination state.
558
+ */
559
+ /**
560
+ * Format the BEGIN marker comment with a version stamp.
561
+ *
562
+ * @param markerText - The marker text (e.g., 'BEGIN JEEVES PLATFORM TOOLS').
563
+ * @param version - The core library version.
564
+ * @returns Formatted comment line.
565
+ */
566
+ function formatBeginMarker(markerText, version) {
567
+ const timestamp = new Date().toISOString();
568
+ return `<!-- ${markerText} | core:${version} | ${timestamp} -->`;
569
+ }
570
+ /**
571
+ * Format the END marker comment.
572
+ *
573
+ * @param markerText - The marker text (e.g., 'END JEEVES PLATFORM TOOLS').
574
+ * @returns Formatted comment line.
575
+ */
576
+ function formatEndMarker(markerText) {
577
+ return `<!-- ${markerText} -->`;
578
+ }
579
+
580
+ /**
581
+ * Remove a managed section or entire managed block from a file.
582
+ *
583
+ * @remarks
584
+ * Supports two modes:
585
+ * - No `sectionId`: Remove the entire managed block (markers + content),
586
+ * leaving user content intact.
587
+ * - With `sectionId`: Remove a specific H2 section from within the
588
+ * managed block. If it was the last section, remove the entire block.
589
+ *
590
+ * Provides file-level locking and atomic writes (temp file + rename).
591
+ * Missing markers or nonexistent sections are no-ops (no error thrown).
592
+ */
593
+ /**
594
+ * Remove a managed section or entire managed block from a file.
595
+ *
596
+ * @param filePath - Absolute path to the target file.
597
+ * @param options - Optional section ID and custom markers.
598
+ */
599
+ async function removeManagedSection(filePath, options = {}) {
600
+ const { sectionId, markers = TOOLS_MARKERS } = options;
601
+ if (!existsSync(filePath))
602
+ return;
603
+ await withFileLock(filePath, () => {
604
+ const fileContent = readFileSync(filePath, 'utf-8');
605
+ const parsed = parseManaged(fileContent, markers);
606
+ if (!parsed.found)
607
+ return;
608
+ let newContent;
609
+ if (!sectionId) {
610
+ // Remove entire managed block
611
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
612
+ }
613
+ else {
614
+ // Remove specific section
615
+ const remaining = parsed.sections.filter((s) => s.id !== sectionId);
616
+ if (remaining.length === parsed.sections.length) {
617
+ // Section not found — no-op
618
+ return;
619
+ }
620
+ if (remaining.length === 0) {
621
+ // Last section removed — remove entire block
622
+ newContent = buildWithoutBlock(parsed.beforeContent, parsed.userContent);
623
+ }
624
+ else {
625
+ // Rebuild managed block without the removed section
626
+ newContent = buildWithSections(parsed.beforeContent, parsed.userContent, remaining, markers, parsed.versionStamp?.version);
627
+ }
628
+ }
629
+ atomicWrite(filePath, newContent);
630
+ });
631
+ }
632
+ /** Build file content without the managed block. */
633
+ function buildWithoutBlock(beforeContent, userContent) {
634
+ const parts = [];
635
+ if (beforeContent)
636
+ parts.push(beforeContent);
637
+ if (userContent) {
638
+ if (parts.length > 0)
639
+ parts.push('');
640
+ parts.push(userContent);
641
+ }
642
+ if (parts.length === 0)
643
+ return '';
644
+ return parts.join('\n') + '\n';
645
+ }
646
+ /** Rebuild file content with remaining sections. */
647
+ function buildWithSections(beforeContent, userContent, sections, markers, coreVersion) {
648
+ const sorted = sortSectionsByOrder([...sections]);
649
+ const sectionText = sorted
650
+ .map((s) => `## ${s.id}\n\n${s.content}`)
651
+ .join('\n\n');
652
+ const managedBody = markers.title
653
+ ? `# ${markers.title}\n\n${sectionText}`
654
+ : sectionText;
655
+ const beginLine = formatBeginMarker(markers.begin, coreVersion ?? DEFAULT_CORE_VERSION);
656
+ const endLine = formatEndMarker(markers.end);
657
+ const parts = [];
658
+ if (beforeContent) {
659
+ parts.push(beforeContent);
660
+ parts.push('');
661
+ }
662
+ parts.push(beginLine);
663
+ parts.push('');
664
+ parts.push(managedBody);
665
+ parts.push('');
666
+ parts.push(endLine);
667
+ if (userContent) {
668
+ parts.push('');
669
+ parts.push(userContent);
670
+ }
671
+ parts.push('');
672
+ return parts.join('\n');
673
+ }
674
+
675
+ /**
676
+ * OpenClaw configuration helpers for plugin CLI installers.
677
+ *
678
+ * @remarks
679
+ * Provides resolution of OpenClaw home directory and config file path,
680
+ * plus idempotent config patching for plugin install/uninstall.
681
+ */
682
+ /**
683
+ * Resolve the OpenClaw home directory.
684
+ *
685
+ * @remarks
686
+ * Resolution order:
687
+ * 1. `OPENCLAW_CONFIG` env var → dirname of the config file path
688
+ * 2. `OPENCLAW_HOME` env var → resolved path
689
+ * 3. Default: `~/.openclaw`
690
+ *
691
+ * @returns Absolute path to the OpenClaw home directory.
692
+ */
693
+ function resolveOpenClawHome() {
694
+ if (process.env.OPENCLAW_CONFIG) {
695
+ return dirname(resolve(process.env.OPENCLAW_CONFIG));
696
+ }
697
+ if (process.env.OPENCLAW_HOME) {
698
+ return resolve(process.env.OPENCLAW_HOME);
699
+ }
700
+ return join(homedir(), '.openclaw');
701
+ }
702
+ /**
703
+ * Resolve the OpenClaw config file path.
704
+ *
705
+ * @remarks
706
+ * If `OPENCLAW_CONFIG` is set, uses that directly.
707
+ * Otherwise defaults to `{home}/openclaw.json`.
708
+ *
709
+ * @param home - The OpenClaw home directory.
710
+ * @returns Absolute path to the config file.
711
+ */
712
+ function resolveConfigPath(home) {
713
+ if (process.env.OPENCLAW_CONFIG) {
714
+ return resolve(process.env.OPENCLAW_CONFIG);
715
+ }
716
+ return join(home, 'openclaw.json');
717
+ }
718
+ /**
719
+ * Patch an allowlist array: add or remove the plugin ID.
720
+ *
721
+ * @returns A log message if a change was made, or undefined.
722
+ */
723
+ function patchAllowList(parent, key, label, pluginId, mode) {
724
+ if (mode === 'add') {
725
+ if (!Array.isArray(parent[key])) {
726
+ parent[key] = [pluginId];
727
+ return `Created ${label} with "${pluginId}"`;
728
+ }
729
+ const list = parent[key];
730
+ if (!list.includes(pluginId)) {
731
+ list.push(pluginId);
732
+ return `Added "${pluginId}" to ${label}`;
733
+ }
734
+ }
735
+ else {
736
+ if (!Array.isArray(parent[key]))
737
+ return undefined;
738
+ const list = parent[key];
739
+ const filtered = list.filter((id) => id !== pluginId);
740
+ if (filtered.length !== list.length) {
741
+ parent[key] = filtered;
742
+ return `Removed "${pluginId}" from ${label}`;
743
+ }
744
+ }
745
+ return undefined;
746
+ }
747
+ /**
748
+ * Patch an OpenClaw config for plugin install or uninstall.
749
+ *
750
+ * @remarks
751
+ * Manages `plugins.entries.{pluginId}` and `tools.alsoAllow`.
752
+ * Idempotent: adding twice produces no duplicates; removing when absent
753
+ * produces no errors.
754
+ *
755
+ * @param config - The parsed OpenClaw config object (mutated in place).
756
+ * @param pluginId - The plugin identifier.
757
+ * @param mode - Whether to add or remove the plugin.
758
+ * @returns Array of log messages describing changes made.
759
+ */
760
+ function patchConfig(config, pluginId, mode) {
761
+ const messages = [];
762
+ // Ensure plugins section
763
+ if (!config.plugins || typeof config.plugins !== 'object') {
764
+ config.plugins = {};
765
+ }
766
+ const plugins = config.plugins;
767
+ // plugins.entries
768
+ if (!plugins.entries || typeof plugins.entries !== 'object') {
769
+ plugins.entries = {};
770
+ }
771
+ const entries = plugins.entries;
772
+ if (mode === 'add') {
773
+ if (!entries[pluginId]) {
774
+ entries[pluginId] = { enabled: true };
775
+ messages.push(`Added "${pluginId}" to plugins.entries`);
776
+ }
777
+ }
778
+ else if (pluginId in entries) {
779
+ Reflect.deleteProperty(entries, pluginId);
780
+ messages.push(`Removed "${pluginId}" from plugins.entries`);
781
+ }
782
+ // tools.alsoAllow
783
+ if (!config.tools || typeof config.tools !== 'object') {
784
+ config.tools = {};
785
+ }
786
+ const tools = config.tools;
787
+ const toolAlsoAllow = patchAllowList(tools, 'alsoAllow', 'tools.alsoAllow', pluginId, mode);
788
+ if (toolAlsoAllow)
789
+ messages.push(toolAlsoAllow);
790
+ return messages;
791
+ }
792
+
793
+ /**
794
+ * Factory for the standard `-openclaw` plugin installer CLI.
795
+ *
796
+ * @remarks
797
+ * Produces a Commander program with `install` and `uninstall` commands
798
+ * that handle the full plugin lifecycle: copy dist to extensions,
799
+ * patch OpenClaw config, manage HEARTBEAT entries, and clean up
800
+ * managed sections on uninstall.
801
+ */
802
+ /**
803
+ * Derive a component name from a plugin ID.
804
+ *
805
+ * @remarks
806
+ * Strips `jeeves-` prefix and `-openclaw` suffix.
807
+ *
808
+ * @param pluginId - The plugin identifier.
809
+ * @returns Component short name.
810
+ */
811
+ function deriveComponentName(pluginId) {
812
+ return pluginId.replace(/^jeeves-/, '').replace(/-openclaw$/, '');
813
+ }
814
+ /**
815
+ * Copy all files from source directory to destination.
816
+ *
817
+ * @param srcDir - Source directory.
818
+ * @param destDir - Destination directory.
819
+ */
820
+ function copyDistFiles(srcDir, destDir) {
821
+ mkdirSync(destDir, { recursive: true });
822
+ const entries = readdirSync(srcDir, { withFileTypes: true });
823
+ for (const entry of entries) {
824
+ const srcPath = join(srcDir, entry.name);
825
+ const destPath = join(destDir, entry.name);
826
+ if (entry.isDirectory()) {
827
+ copyDistFiles(srcPath, destPath);
828
+ }
829
+ else {
830
+ copyFileSync(srcPath, destPath);
831
+ }
832
+ }
833
+ }
834
+ /**
835
+ * Read and parse a JSON file, returning an empty object if not found.
836
+ *
837
+ * @param filePath - Path to the JSON file.
838
+ * @returns Parsed object.
839
+ */
840
+ function readJsonFile(filePath) {
841
+ try {
842
+ const raw = readFileSync(filePath, 'utf-8');
843
+ return JSON.parse(raw);
844
+ }
845
+ catch {
846
+ return {};
847
+ }
848
+ }
849
+ /**
850
+ * Create a standard plugin installer CLI program.
851
+ *
852
+ * @param options - Plugin CLI configuration.
853
+ * @returns A Commander program ready for `.parse()`.
854
+ */
855
+ function createPluginCli(options) {
856
+ const { pluginId, distDir, pluginPackage, configRoot = 'j:/config', } = options;
857
+ const componentName = options.componentName ?? deriveComponentName(pluginId);
858
+ const program = new Command()
859
+ .name(pluginPackage)
860
+ .description(`Jeeves ${componentName} plugin installer`);
861
+ program
862
+ .command('install')
863
+ .description(`Install the ${componentName} plugin`)
864
+ .option('--memory', 'Claim a memory slot for this plugin')
865
+ .option('-w, --workspace <path>', 'Workspace root path')
866
+ .option('-c, --config-root <path>', 'Platform config root path', configRoot)
867
+ .action((opts) => {
868
+ const openClawHome = resolveOpenClawHome();
869
+ const configPath = resolveConfigPath(openClawHome);
870
+ // 1. Copy dist to extensions
871
+ const extensionsDir = join(openClawHome, 'extensions', pluginId);
872
+ console.log(`Copying dist to ${extensionsDir}...`);
873
+ copyDistFiles(distDir, extensionsDir);
874
+ console.log(' ✓ Dist files copied');
875
+ // 2. Patch openclaw.json
876
+ console.log('Patching OpenClaw config...');
877
+ const config = readJsonFile(configPath);
878
+ const messages = patchConfig(config, pluginId, 'add');
879
+ // 3. Memory slot claim
880
+ if (opts.memory) {
881
+ if (!config.agents || typeof config.agents !== 'object') {
882
+ config.agents = {};
883
+ }
884
+ const agents = config.agents;
885
+ if (!agents.defaults || typeof agents.defaults !== 'object') {
886
+ agents.defaults = {};
887
+ }
888
+ const defaults = agents.defaults;
889
+ if (!defaults.memory || typeof defaults.memory !== 'object') {
890
+ defaults.memory = {};
891
+ }
892
+ const memory = defaults.memory;
893
+ if (!memory[componentName]) {
894
+ memory[componentName] = {};
895
+ messages.push(`Claimed memory slot for "${componentName}"`);
896
+ }
897
+ }
898
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
899
+ for (const msg of messages) {
900
+ console.log(` ✓ ${msg}`);
901
+ }
902
+ // 4. Write initial HEARTBEAT entry
903
+ try {
904
+ const cfgRoot = opts.configRoot;
905
+ const agents = config.agents;
906
+ const defaults = agents?.defaults;
907
+ const ws = opts.workspace ?? defaults?.workspace;
908
+ if (ws) {
909
+ init({ workspacePath: ws, configRoot: cfgRoot });
910
+ const heartbeatPath = join(ws, WORKSPACE_FILES.heartbeat);
911
+ try {
912
+ const existing = existsSync(heartbeatPath)
913
+ ? readFileSync(heartbeatPath, 'utf-8')
914
+ : '';
915
+ const parsed = parseHeartbeat(existing);
916
+ const fullName = `jeeves-${componentName}`;
917
+ // Only add if not already present
918
+ const hasEntry = parsed.entries.some((e) => e.name === fullName);
919
+ if (!hasEntry) {
920
+ parsed.entries.push({
921
+ name: fullName,
922
+ declined: false,
923
+ content: `- Plugin installed. Awaiting service configuration.`,
924
+ });
925
+ const section = buildHeartbeatSection(parsed.entries);
926
+ atomicWrite(heartbeatPath, section);
927
+ console.log(' ✓ HEARTBEAT entry written');
928
+ }
929
+ }
930
+ catch {
931
+ console.log(' ⚠ Could not write HEARTBEAT entry');
932
+ }
933
+ }
934
+ }
935
+ catch {
936
+ // HEARTBEAT is best-effort during install
937
+ }
938
+ console.log();
939
+ console.log(`✅ ${pluginPackage} installed.`);
940
+ });
941
+ program
942
+ .command('uninstall')
943
+ .description(`Uninstall the ${componentName} plugin`)
944
+ .option('-w, --workspace <path>', 'Workspace root path')
945
+ .option('-c, --config-root <path>', 'Platform config root path', configRoot)
946
+ .action(async (opts) => {
947
+ const openClawHome = resolveOpenClawHome();
948
+ const cfgPath = resolveConfigPath(openClawHome);
949
+ // 1. Remove from extensions
950
+ const extensionsDir = join(openClawHome, 'extensions', pluginId);
951
+ if (existsSync(extensionsDir)) {
952
+ rmSync(extensionsDir, { recursive: true, force: true });
953
+ console.log(' ✓ Extension files removed');
954
+ }
955
+ // 2. Unpatch openclaw.json
956
+ if (existsSync(cfgPath)) {
957
+ const config = readJsonFile(cfgPath);
958
+ const messages = patchConfig(config, pluginId, 'remove');
959
+ writeFileSync(cfgPath, JSON.stringify(config, null, 2) + '\n');
960
+ for (const msg of messages) {
961
+ console.log(` ✓ ${msg}`);
962
+ }
963
+ }
964
+ // 3. Remove TOOLS.md section
965
+ try {
966
+ const cfgRoot = opts.configRoot;
967
+ const ws = opts.workspace;
968
+ if (ws) {
969
+ init({ workspacePath: ws, configRoot: cfgRoot });
970
+ const sectionId = componentName.charAt(0).toUpperCase() + componentName.slice(1);
971
+ const toolsPath = join(ws, WORKSPACE_FILES.tools);
972
+ if (existsSync(toolsPath)) {
973
+ await removeManagedSection(toolsPath, {
974
+ sectionId,
975
+ markers: TOOLS_MARKERS,
976
+ });
977
+ console.log(' ✓ TOOLS.md section removed');
978
+ }
979
+ }
980
+ }
981
+ catch {
982
+ console.log(' ⚠ Could not remove TOOLS.md section');
983
+ }
984
+ // 4. Remove component-versions.json entry
985
+ try {
986
+ const cfgRoot = opts.configRoot;
987
+ init({
988
+ workspacePath: opts.workspace ?? '.',
989
+ configRoot: cfgRoot,
990
+ });
991
+ removeComponentVersion(getCoreConfigDir(), componentName);
992
+ console.log(' ✓ Component version entry removed');
993
+ }
994
+ catch {
995
+ console.log(' ⚠ Could not remove component version entry');
996
+ }
997
+ console.log();
998
+ console.log(`✅ ${pluginPackage} uninstalled.`);
999
+ });
1000
+ return program;
1001
+ }
1002
+
1003
+ export { createPluginCli };