@karmaniverous/jeeves 0.4.0 → 0.4.2
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/dist/cli/jeeves/index.js +85 -5
- package/dist/cli/plugin/index.js +1003 -0
- package/dist/cli/service/index.js +929 -0
- package/dist/index.d.ts +476 -171
- package/dist/index.js +3101 -1872
- package/package.json +1 -1
|
@@ -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.1` 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.1';
|
|
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 };
|