@karmaniverous/jeeves 0.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.
@@ -0,0 +1,1341 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+ import require$$0 from 'commander';
4
+ import { createRequire } from 'node:module';
5
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, cpSync, rmSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { z } from 'zod';
8
+ import { fileURLToPath } from 'node:url';
9
+ import Handlebars from 'handlebars';
10
+ import { execSync } from 'node:child_process';
11
+ import { lock } from 'proper-lockfile';
12
+ import { gte } from 'semver';
13
+
14
+ function getDefaultExportFromCjs (x) {
15
+ return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x;
16
+ }
17
+
18
+ var extraTypings = {exports: {}};
19
+
20
+ var hasRequiredExtraTypings;
21
+
22
+ function requireExtraTypings () {
23
+ if (hasRequiredExtraTypings) return extraTypings.exports;
24
+ hasRequiredExtraTypings = 1;
25
+ (function (module, exports$1) {
26
+ const commander = require$$0;
27
+
28
+ exports$1 = module.exports = {};
29
+
30
+ // Return a different global program than commander,
31
+ // and don't also return it as default export.
32
+ exports$1.program = new commander.Command();
33
+
34
+ /**
35
+ * Expose classes. The FooT versions are just types, so return Commander original implementations!
36
+ */
37
+
38
+ exports$1.Argument = commander.Argument;
39
+ exports$1.Command = commander.Command;
40
+ exports$1.CommanderError = commander.CommanderError;
41
+ exports$1.Help = commander.Help;
42
+ exports$1.InvalidArgumentError = commander.InvalidArgumentError;
43
+ exports$1.InvalidOptionArgumentError = commander.InvalidArgumentError; // Deprecated
44
+ exports$1.Option = commander.Option;
45
+
46
+ exports$1.createCommand = (name) => new commander.Command(name);
47
+ exports$1.createOption = (flags, description) =>
48
+ new commander.Option(flags, description);
49
+ exports$1.createArgument = (name, description) =>
50
+ new commander.Argument(name, description);
51
+ } (extraTypings, extraTypings.exports));
52
+ return extraTypings.exports;
53
+ }
54
+
55
+ var extraTypingsExports = requireExtraTypings();
56
+ var extraTypingsCommander = /*@__PURE__*/getDefaultExportFromCjs(extraTypingsExports);
57
+
58
+ // wrapper to provide named exports for ESM.
59
+ const {
60
+ program,
61
+ createCommand,
62
+ createArgument,
63
+ createOption,
64
+ CommanderError,
65
+ InvalidArgumentError,
66
+ InvalidOptionArgumentError, // deprecated old name
67
+ Command,
68
+ Argument,
69
+ Option,
70
+ Help,
71
+ } = extraTypingsCommander;
72
+
73
+ /**
74
+ * Comment markers for managed content blocks.
75
+ *
76
+ * @remarks
77
+ * Managed content in TOOLS.md, SOUL.md, and AGENTS.md is enclosed
78
+ * in HTML comment markers. Content between markers is refreshed
79
+ * atomically on each writer cycle. User content outside the markers
80
+ * is never touched.
81
+ */
82
+ /** Default markers for TOOLS.md managed block. */
83
+ const TOOLS_MARKERS = {
84
+ /** BEGIN comment marker text. */
85
+ begin: 'BEGIN JEEVES PLATFORM TOOLS — DO NOT EDIT THIS SECTION',
86
+ /** END comment marker text. */
87
+ end: 'END JEEVES PLATFORM TOOLS',
88
+ /** H1 title prepended in section mode. */
89
+ title: 'Jeeves Platform Tools',
90
+ };
91
+ /** Default markers for SOUL.md managed block. */
92
+ const SOUL_MARKERS = {
93
+ /** BEGIN comment marker text. */
94
+ begin: 'BEGIN JEEVES SOUL — DO NOT EDIT THIS SECTION',
95
+ /** END comment marker text. */
96
+ end: 'END JEEVES SOUL',
97
+ };
98
+ /** Default markers for AGENTS.md managed block. */
99
+ const AGENTS_MARKERS = {
100
+ /** BEGIN comment marker text. */
101
+ begin: 'BEGIN JEEVES AGENTS — DO NOT EDIT THIS SECTION',
102
+ /** END comment marker text. */
103
+ end: 'END JEEVES AGENTS',
104
+ };
105
+ /**
106
+ * Regex pattern to extract version stamp from a BEGIN marker comment.
107
+ *
108
+ * @remarks
109
+ * Format: `\<!-- BEGIN MARKER | core:X.Y.Z | ISO-TIMESTAMP --\>`
110
+ * Captures: [1] marker text, [2] version, [3] timestamp
111
+ */
112
+ const VERSION_STAMP_PATTERN = /<!--\s*(.+?)\s*\|\s*core:(\S+)\s*\|\s*(\S+)\s*-->/;
113
+ /** Staleness threshold for version-stamp convergence in milliseconds. */
114
+ const STALENESS_THRESHOLD_MS = 5 * 60 * 1000;
115
+ /** Warning text prepended inside managed block when cleanup is needed. */
116
+ const CLEANUP_FLAG = '> ⚠️ CLEANUP NEEDED: Orphaned Jeeves content may exist below this managed section. Review everything after the END marker and remove any content that duplicates what appears above.';
117
+
118
+ /**
119
+ * Directory and file path conventions for the Jeeves platform.
120
+ */
121
+ /** Core config directory name within the config root. */
122
+ const CORE_CONFIG_DIR = 'jeeves-core';
123
+ /** Prefix for component config directories: `jeeves-{name}`. */
124
+ const COMPONENT_CONFIG_PREFIX = 'jeeves-';
125
+ /** Default workspace file names. */
126
+ const WORKSPACE_FILES = {
127
+ /** TOOLS.md — live platform state and component sections. */
128
+ tools: 'TOOLS.md',
129
+ /** SOUL.md — professional discipline and behavioral foundations. */
130
+ soul: 'SOUL.md',
131
+ /** AGENTS.md — operational protocols and memory architecture. */
132
+ agents: 'AGENTS.md',
133
+ };
134
+ /** Templates directory name within core config. */
135
+ const TEMPLATES_DIR = 'templates';
136
+ /** Registry cache file name. */
137
+ const REGISTRY_CACHE_FILE = 'registry-cache.json';
138
+ /** Core config file name. */
139
+ const CONFIG_FILE = 'config.json';
140
+
141
+ /**
142
+ * Default port assignments for Jeeves platform services.
143
+ *
144
+ * @remarks
145
+ * Each port number is a historical reference:
146
+ * - 1934: Wodehouse's *Thank You, Jeeves*; Popper's *Logic of Scientific Discovery*
147
+ * - 1936: Turing's "On Computable Numbers"; Church's lambda calculus
148
+ * - 1937: Turing's paper in *Proceedings of the London Mathematical Society*
149
+ * - 1938: Wodehouse's *The Code of the Woosters*; Shannon's relay/switching paper
150
+ */
151
+ /** Default port for jeeves-server. */
152
+ const SERVER_PORT = 1934;
153
+ /** Default port for jeeves-watcher. */
154
+ const WATCHER_PORT = 1936;
155
+ /** Default port for jeeves-runner. */
156
+ const RUNNER_PORT = 1937;
157
+ /** Default port for jeeves-meta. */
158
+ const META_PORT = 1938;
159
+ /** Map of service names to their default ports. */
160
+ const DEFAULT_PORTS = {
161
+ server: SERVER_PORT,
162
+ watcher: WATCHER_PORT,
163
+ runner: RUNNER_PORT,
164
+ meta: META_PORT,
165
+ };
166
+
167
+ /**
168
+ * Managed section IDs and their stable ordering for TOOLS.md.
169
+ *
170
+ * @remarks
171
+ * Section ordering is fixed to prevent diff churn regardless of which
172
+ * component writes last. Sections always appear in this order.
173
+ */
174
+ /** Known section IDs for TOOLS.md managed block. */
175
+ const SECTION_IDS = {
176
+ /** Platform health and guidance section. */
177
+ Platform: 'Platform',
178
+ /** Watcher index stats and search configuration. */
179
+ Watcher: 'Watcher',
180
+ /** Server export capabilities and connected services. */
181
+ Server: 'Server',
182
+ /** Runner job status and active scripts. */
183
+ Runner: 'Runner',
184
+ /** Meta synthesis entity summary and tools. */
185
+ Meta: 'Meta',
186
+ };
187
+ /**
188
+ * Stable ordering of sections within the managed TOOLS.md block.
189
+ * Sections always appear in this order regardless of write order.
190
+ */
191
+ const SECTION_ORDER = [
192
+ SECTION_IDS.Platform,
193
+ SECTION_IDS.Watcher,
194
+ SECTION_IDS.Server,
195
+ SECTION_IDS.Runner,
196
+ SECTION_IDS.Meta,
197
+ ];
198
+
199
+ /**
200
+ * Core library version, read from package.json at runtime.
201
+ *
202
+ * @remarks
203
+ * Used for version-stamp convergence (Decision 21). The version stamp
204
+ * on managed content reflects the actual published library version,
205
+ * enabling higher-version writers to take precedence.
206
+ */
207
+ const require$1 = createRequire(import.meta.url);
208
+ const pkg = require$1('../../package.json');
209
+ /** The core library version from package.json. */
210
+ const CORE_VERSION = pkg.version;
211
+
212
+ /**
213
+ * Core configuration schema and resolution.
214
+ *
215
+ * @remarks
216
+ * Core config lives at `{configRoot}/jeeves-core/config.json`.
217
+ * Config resolution order:
218
+ * 1. Component's own config file
219
+ * 2. Core config file
220
+ * 3. Hardcoded library defaults
221
+ */
222
+ /** Zod schema for a service entry in core config. */
223
+ const serviceEntrySchema = z.object({
224
+ /** Service URL (must be a valid URL). */
225
+ url: z.string().url().describe('Service URL'),
226
+ });
227
+ /** Zod schema for the core config file. */
228
+ const coreConfigSchema = z.object({
229
+ /** JSON Schema pointer for IDE autocomplete. */
230
+ $schema: z.string().optional().describe('JSON Schema pointer'),
231
+ /** Owner identity keys (canonical identityLinks references). */
232
+ owners: z.array(z.string()).default([]).describe('Owner identity keys'),
233
+ /** Service URL overrides keyed by service name. */
234
+ services: z
235
+ .record(z.string(), serviceEntrySchema)
236
+ .default({})
237
+ .describe('Service URL overrides'),
238
+ /** Registry cache configuration. */
239
+ registryCache: z
240
+ .object({
241
+ /** Cache TTL in seconds for npm registry queries. */
242
+ ttlSeconds: z
243
+ .number()
244
+ .int()
245
+ .positive()
246
+ .default(3600)
247
+ .describe('Cache TTL in seconds'),
248
+ })
249
+ .default({})
250
+ .describe('Registry cache settings'),
251
+ });
252
+ /**
253
+ * Generate a JSON Schema from the Zod schema for `$schema` pointer support.
254
+ *
255
+ * @returns A JSON Schema object.
256
+ */
257
+ function generateJsonSchema() {
258
+ return {
259
+ $schema: 'http://json-schema.org/draft-07/schema#',
260
+ title: 'Jeeves Core Configuration',
261
+ type: 'object',
262
+ properties: {
263
+ $schema: { type: 'string' },
264
+ owners: {
265
+ type: 'array',
266
+ items: { type: 'string' },
267
+ default: [],
268
+ },
269
+ services: {
270
+ type: 'object',
271
+ additionalProperties: {
272
+ type: 'object',
273
+ properties: {
274
+ url: { type: 'string', format: 'uri' },
275
+ },
276
+ required: ['url'],
277
+ },
278
+ default: {},
279
+ },
280
+ registryCache: {
281
+ type: 'object',
282
+ properties: {
283
+ ttlSeconds: {
284
+ type: 'integer',
285
+ minimum: 1,
286
+ default: 3600,
287
+ },
288
+ },
289
+ default: {},
290
+ },
291
+ },
292
+ };
293
+ }
294
+ /**
295
+ * Load and parse a config file, returning undefined if missing or invalid.
296
+ *
297
+ * @param configDir - Directory containing config.json.
298
+ * @returns Parsed config or undefined.
299
+ */
300
+ function loadConfig(configDir) {
301
+ const configPath = join(configDir, CONFIG_FILE);
302
+ if (!existsSync(configPath))
303
+ return undefined;
304
+ try {
305
+ const raw = readFileSync(configPath, 'utf-8');
306
+ const parsed = JSON.parse(raw);
307
+ return coreConfigSchema.parse(parsed);
308
+ }
309
+ catch {
310
+ return undefined;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Workspace and config root initialization.
316
+ *
317
+ * @remarks
318
+ * `init()` must be called once before any other core library functions.
319
+ * It caches `workspacePath` and `configRoot` at module level.
320
+ * Core derives all namespaced paths from these values:
321
+ * - `{configRoot}/jeeves-core/` for core config
322
+ * - `{configRoot}/jeeves-{name}/` for each component
323
+ */
324
+ let state;
325
+ /**
326
+ * Initialize the core library with workspace and config root paths.
327
+ *
328
+ * @param options - Workspace and config root paths.
329
+ */
330
+ function init(options) {
331
+ state = {
332
+ workspacePath: options.workspacePath,
333
+ configRoot: options.configRoot,
334
+ coreConfigDir: join(options.configRoot, CORE_CONFIG_DIR),
335
+ };
336
+ }
337
+ /**
338
+ * Get the cached workspace path.
339
+ *
340
+ * @throws Error if `init()` has not been called.
341
+ */
342
+ function getWorkspacePath() {
343
+ if (!state)
344
+ throw new Error('jeeves-core: init() must be called first');
345
+ return state.workspacePath;
346
+ }
347
+ /**
348
+ * Get the core config directory path.
349
+ *
350
+ * @throws Error if `init()` has not been called.
351
+ */
352
+ function getCoreConfigDir() {
353
+ if (!state)
354
+ throw new Error('jeeves-core: init() must be called first');
355
+ return state.coreConfigDir;
356
+ }
357
+ /**
358
+ * Derive the component config directory from the component name.
359
+ *
360
+ * @param componentName - The component name (e.g., 'watcher', 'runner').
361
+ * @returns Absolute path to the component's config directory.
362
+ * @throws Error if `init()` has not been called.
363
+ */
364
+ function getComponentConfigDir(componentName) {
365
+ if (!state)
366
+ throw new Error('jeeves-core: init() must be called first');
367
+ return join(state.configRoot, `${COMPONENT_CONFIG_PREFIX}${componentName}`);
368
+ }
369
+
370
+ /**
371
+ * Service URL resolution.
372
+ *
373
+ * @remarks
374
+ * Resolves the URL for a named Jeeves service using the following
375
+ * resolution order:
376
+ * 1. Consumer's own component config
377
+ * 2. Core config (`{configRoot}/jeeves-core/config.json`)
378
+ * 3. Default port constants
379
+ */
380
+ /**
381
+ * Resolve the URL for a named Jeeves service.
382
+ *
383
+ * @param serviceName - The service name (e.g., 'watcher', 'runner').
384
+ * @param consumerName - Optional consumer component name for config override.
385
+ * @returns The resolved service URL.
386
+ * @throws Error if `init()` has not been called or the service is unknown.
387
+ */
388
+ function getServiceUrl(serviceName, consumerName) {
389
+ // 2. Check core config
390
+ const coreDir = getCoreConfigDir();
391
+ const coreConfig = loadConfig(coreDir);
392
+ const coreUrl = coreConfig?.services[serviceName]?.url;
393
+ if (coreUrl)
394
+ return coreUrl;
395
+ // 3. Fall back to port constants
396
+ const port = DEFAULT_PORTS[serviceName];
397
+ if (port !== undefined) {
398
+ return `http://127.0.0.1:${String(port)}`;
399
+ }
400
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
401
+ }
402
+
403
+ /**
404
+ * HTTP health probing for Jeeves platform services.
405
+ *
406
+ * @remarks
407
+ * Probes service ports for health endpoints (HTTP GET to /status or /health).
408
+ * Returns structured health data for rendering into TOOLS.md Platform section.
409
+ */
410
+ /**
411
+ * Extract port number from a URL string.
412
+ *
413
+ * @param url - Service URL.
414
+ * @returns Port number.
415
+ */
416
+ function extractPort(url) {
417
+ try {
418
+ const parsed = new URL(url);
419
+ return parsed.port ? parseInt(parsed.port, 10) : 80;
420
+ }
421
+ catch {
422
+ return 0;
423
+ }
424
+ }
425
+ /**
426
+ * Probe a single service for health.
427
+ *
428
+ * @param serviceName - The service name (e.g., 'server', 'watcher').
429
+ * @param consumerName - Optional consumer name for URL resolution.
430
+ * @param timeoutMs - Request timeout in milliseconds (default 3000).
431
+ * @returns Probe result.
432
+ */
433
+ async function probeService(serviceName, consumerName, timeoutMs = 3000) {
434
+ const url = getServiceUrl(serviceName);
435
+ const port = extractPort(url);
436
+ const endpoints = ['/status', '/health'];
437
+ for (const endpoint of endpoints) {
438
+ try {
439
+ const controller = new AbortController();
440
+ const timeout = setTimeout(() => {
441
+ controller.abort();
442
+ }, timeoutMs);
443
+ const response = await fetch(`${url}${endpoint}`, {
444
+ signal: controller.signal,
445
+ });
446
+ clearTimeout(timeout);
447
+ if (response.ok) {
448
+ let version;
449
+ try {
450
+ const body = await response.json();
451
+ if (typeof body === 'object' &&
452
+ body !== null &&
453
+ 'version' in body &&
454
+ typeof body['version'] === 'string') {
455
+ version = body['version'];
456
+ }
457
+ }
458
+ catch {
459
+ // Non-JSON response is fine — we just don't get version info
460
+ }
461
+ return { name: serviceName, port, healthy: true, version };
462
+ }
463
+ }
464
+ catch {
465
+ // Try next endpoint
466
+ }
467
+ }
468
+ return { name: serviceName, port, healthy: false };
469
+ }
470
+ /**
471
+ * Probe all known Jeeves services for health.
472
+ *
473
+ * @param consumerName - Optional consumer name for URL resolution.
474
+ * @param timeoutMs - Request timeout in milliseconds (default 3000).
475
+ * @returns Array of probe results for all services.
476
+ */
477
+ async function probeAllServices(consumerName, timeoutMs = 3000) {
478
+ const serviceNames = Object.keys(DEFAULT_PORTS);
479
+ const results = await Promise.all(serviceNames.map((name) => probeService(name, consumerName, timeoutMs)));
480
+ return results;
481
+ }
482
+
483
+ /**
484
+ * Registry version cache for npm package update awareness.
485
+ *
486
+ * @remarks
487
+ * Caches the latest npm registry version in a local JSON file
488
+ * to avoid expensive `npm view` calls on every refresh cycle.
489
+ */
490
+ /**
491
+ * Check the npm registry for the latest version of a package.
492
+ *
493
+ * @param packageName - The npm package name (e.g., '\@karmaniverous/jeeves').
494
+ * @param cacheDir - Directory to store the cache file.
495
+ * @param ttlSeconds - Cache TTL in seconds (default 3600).
496
+ * @returns The latest version string, or undefined if the check fails.
497
+ */
498
+ function checkRegistryVersion(packageName, cacheDir, ttlSeconds = 3600) {
499
+ const cachePath = join(cacheDir, REGISTRY_CACHE_FILE);
500
+ // Check cache first
501
+ if (existsSync(cachePath)) {
502
+ try {
503
+ const raw = readFileSync(cachePath, 'utf-8');
504
+ const entry = JSON.parse(raw);
505
+ const age = Date.now() - new Date(entry.checkedAt).getTime();
506
+ if (age < ttlSeconds * 1000) {
507
+ return entry.version;
508
+ }
509
+ }
510
+ catch {
511
+ // Cache corrupt — proceed with fresh check
512
+ }
513
+ }
514
+ // Query npm registry
515
+ try {
516
+ const result = execSync(`npm view ${packageName} version`, {
517
+ encoding: 'utf-8',
518
+ timeout: 15_000,
519
+ stdio: ['pipe', 'pipe', 'pipe'],
520
+ }).trim();
521
+ if (!result)
522
+ return undefined;
523
+ // Write cache
524
+ if (!existsSync(cacheDir)) {
525
+ mkdirSync(cacheDir, { recursive: true });
526
+ }
527
+ const entry = {
528
+ version: result,
529
+ checkedAt: new Date().toISOString(),
530
+ };
531
+ writeFileSync(cachePath, JSON.stringify(entry, null, 2), 'utf-8');
532
+ return result;
533
+ }
534
+ catch {
535
+ return undefined;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Similarity-based cleanup detection for orphaned managed content.
541
+ *
542
+ * @remarks
543
+ * Uses Jaccard similarity on 3-word shingles (Decision 22) to detect
544
+ * when orphaned managed content exists in the user content zone.
545
+ */
546
+ /** Default similarity threshold for cleanup detection. */
547
+ const DEFAULT_THRESHOLD = 0.15;
548
+ /**
549
+ * Generate a set of n-word shingles from text.
550
+ *
551
+ * @param text - Input text.
552
+ * @param n - Shingle size (default 3).
553
+ * @returns Set of n-word shingles.
554
+ */
555
+ function shingles(text, n = 3) {
556
+ const words = text.toLowerCase().split(/\s+/).filter(Boolean);
557
+ const set = new Set();
558
+ for (let i = 0; i <= words.length - n; i++) {
559
+ set.add(words.slice(i, i + n).join(' '));
560
+ }
561
+ return set;
562
+ }
563
+ /**
564
+ * Compute Jaccard similarity between two sets.
565
+ *
566
+ * @param a - First set.
567
+ * @param b - Second set.
568
+ * @returns Jaccard similarity coefficient (0 to 1).
569
+ */
570
+ function jaccard(a, b) {
571
+ if (a.size === 0 && b.size === 0)
572
+ return 0;
573
+ let intersection = 0;
574
+ for (const item of a) {
575
+ if (b.has(item))
576
+ intersection++;
577
+ }
578
+ return intersection / (a.size + b.size - intersection);
579
+ }
580
+ /**
581
+ * Check whether user content contains orphaned managed content.
582
+ *
583
+ * @param managedContent - The current managed block content.
584
+ * @param userContent - Content below the END marker.
585
+ * @param threshold - Jaccard threshold (default 0.15).
586
+ * @returns `true` if cleanup is needed.
587
+ */
588
+ function needsCleanup(managedContent, userContent, threshold = DEFAULT_THRESHOLD) {
589
+ if (!userContent.trim())
590
+ return false;
591
+ return jaccard(shingles(managedContent), shingles(userContent)) > threshold;
592
+ }
593
+
594
+ /**
595
+ * Stable section ordering for managed TOOLS.md blocks.
596
+ *
597
+ * @remarks
598
+ * Sorts sections by the canonical SECTION_ORDER: known sections
599
+ * appear in their defined order, unknown sections are appended after.
600
+ * Used by both parseManaged (for consistent output) and
601
+ * updateManagedSection (for reassembly).
602
+ */
603
+ /**
604
+ * Sort sections in place by stable ordering.
605
+ *
606
+ * @param sections - Array of managed sections to sort.
607
+ * @returns The sorted array (same reference, mutated in place).
608
+ */
609
+ function sortSectionsByOrder(sections) {
610
+ return sections.sort((a, b) => {
611
+ const aIdx = SECTION_ORDER.indexOf(a.id);
612
+ const bIdx = SECTION_ORDER.indexOf(b.id);
613
+ const aOrder = aIdx === -1 ? SECTION_ORDER.length : aIdx;
614
+ const bOrder = bIdx === -1 ? SECTION_ORDER.length : bIdx;
615
+ return aOrder - bOrder;
616
+ });
617
+ }
618
+
619
+ /**
620
+ * Parse managed block from file content.
621
+ *
622
+ * @remarks
623
+ * Extracts managed content delimited by comment markers, parses H2
624
+ * sections within the block, and returns the structured result plus
625
+ * user content outside the markers.
626
+ */
627
+ /**
628
+ * Build regex patterns for the given markers.
629
+ *
630
+ * @param markers - Begin/end marker strings.
631
+ * @returns Object with begin and end regex patterns.
632
+ */
633
+ function buildMarkerPatterns(markers) {
634
+ const escapedBegin = markers.begin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
635
+ const escapedEnd = markers.end.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
636
+ return {
637
+ beginRe: new RegExp(`^<!--\\s*${escapedBegin}(?:\\s*\\|[^>]*)?\\s*(?:—[^>]*)?\\s*-->\\s*$`, 'm'),
638
+ endRe: new RegExp(`^<!--\\s*${escapedEnd}\\s*-->\\s*$`, 'm'),
639
+ };
640
+ }
641
+ /**
642
+ * Parse H2 sections from managed block content.
643
+ *
644
+ * @param content - Raw managed block content.
645
+ * @returns Array of parsed sections in stable order.
646
+ */
647
+ function parseSections(content) {
648
+ const lines = content.split('\n');
649
+ const sections = [];
650
+ let currentId;
651
+ let currentLines = [];
652
+ for (const line of lines) {
653
+ const h2Match = /^## (.+)$/.exec(line);
654
+ if (h2Match) {
655
+ if (currentId !== undefined) {
656
+ sections.push({
657
+ id: currentId,
658
+ content: currentLines.join('\n').trim(),
659
+ });
660
+ }
661
+ currentId = h2Match[1];
662
+ currentLines = [];
663
+ }
664
+ else if (currentId !== undefined) {
665
+ currentLines.push(line);
666
+ }
667
+ }
668
+ if (currentId !== undefined) {
669
+ sections.push({
670
+ id: currentId,
671
+ content: currentLines.join('\n').trim(),
672
+ });
673
+ }
674
+ return sortSectionsByOrder(sections);
675
+ }
676
+ /**
677
+ * Parse a managed block from file content.
678
+ *
679
+ * @param fileContent - Full file content.
680
+ * @param markers - Optional custom markers (defaults to TOOLS markers).
681
+ * @returns Parsed result with sections, version stamp, and user content.
682
+ */
683
+ function parseManaged(fileContent, markers = TOOLS_MARKERS) {
684
+ const { beginRe, endRe } = buildMarkerPatterns(markers);
685
+ const beginMatch = beginRe.exec(fileContent);
686
+ if (!beginMatch) {
687
+ return {
688
+ found: false,
689
+ versionStamp: undefined,
690
+ managedContent: '',
691
+ sections: [],
692
+ beforeContent: '',
693
+ userContent: fileContent,
694
+ };
695
+ }
696
+ const endMatch = endRe.exec(fileContent.slice(beginMatch.index + beginMatch[0].length));
697
+ if (!endMatch) {
698
+ // Corrupt: BEGIN without END — treat as fresh file
699
+ return {
700
+ found: false,
701
+ versionStamp: undefined,
702
+ managedContent: '',
703
+ sections: [],
704
+ beforeContent: '',
705
+ userContent: fileContent,
706
+ };
707
+ }
708
+ const beforeContent = fileContent.slice(0, beginMatch.index).trim();
709
+ const managedStart = beginMatch.index + beginMatch[0].length;
710
+ const managedEnd = managedStart + endMatch.index;
711
+ const managedContent = fileContent.slice(managedStart, managedEnd).trim();
712
+ const afterEnd = managedStart + endMatch.index + endMatch[0].length;
713
+ const userContent = fileContent.slice(afterEnd).trim();
714
+ // Extract version stamp from BEGIN marker line
715
+ let versionStamp;
716
+ const stampMatch = VERSION_STAMP_PATTERN.exec(beginMatch[0]);
717
+ if (stampMatch?.[2] && stampMatch[3]) {
718
+ versionStamp = {
719
+ version: stampMatch[2],
720
+ timestamp: stampMatch[3],
721
+ };
722
+ }
723
+ const sections = parseSections(managedContent);
724
+ return {
725
+ found: true,
726
+ versionStamp,
727
+ managedContent,
728
+ sections,
729
+ beforeContent,
730
+ userContent,
731
+ };
732
+ }
733
+
734
+ /**
735
+ * Version-stamp parsing and convergence logic.
736
+ *
737
+ * @remarks
738
+ * When multiple component plugins bundle different core library versions,
739
+ * they independently maintain shared managed content. The version-stamp
740
+ * mechanism ensures convergence without coordination state.
741
+ */
742
+ /**
743
+ * Format the BEGIN marker comment with a version stamp.
744
+ *
745
+ * @param markerText - The marker text (e.g., 'BEGIN JEEVES PLATFORM TOOLS').
746
+ * @param version - The core library version.
747
+ * @returns Formatted comment line.
748
+ */
749
+ function formatBeginMarker(markerText, version) {
750
+ const timestamp = new Date().toISOString();
751
+ return `<!-- ${markerText} | core:${version} | ${timestamp} -->`;
752
+ }
753
+ /**
754
+ * Format the END marker comment.
755
+ *
756
+ * @param markerText - The marker text (e.g., 'END JEEVES PLATFORM TOOLS').
757
+ * @returns Formatted comment line.
758
+ */
759
+ function formatEndMarker(markerText) {
760
+ return `<!-- ${markerText} -->`;
761
+ }
762
+ /**
763
+ * Determine whether this writer should proceed based on version-stamp
764
+ * convergence rules.
765
+ *
766
+ * @param myVersion - The current core library version.
767
+ * @param existing - The existing version stamp (if any).
768
+ * @param stalenessThresholdMs - Staleness threshold in ms (default: 5 min).
769
+ * @returns `true` if the writer should proceed with the write.
770
+ */
771
+ function shouldWrite(myVersion, existing, stalenessThresholdMs = STALENESS_THRESHOLD_MS) {
772
+ // No existing stamp — always write
773
+ if (!existing)
774
+ return true;
775
+ // My version >= stamped version — always write (I'm current or newer)
776
+ if (gte(myVersion, existing.version))
777
+ return true;
778
+ // My version < stamped version — check staleness
779
+ const stampAge = Date.now() - new Date(existing.timestamp).getTime();
780
+ return stampAge >= stalenessThresholdMs;
781
+ }
782
+
783
+ /**
784
+ * Generic managed-section writer with block and section modes.
785
+ *
786
+ * @remarks
787
+ * Supports two modes:
788
+ * - `block`: Replaces the entire managed block (SOUL.md, AGENTS.md).
789
+ * - `section`: Upserts a named H2 section within the managed block (TOOLS.md).
790
+ *
791
+ * Provides file-level locking, version-stamp convergence, and atomic writes.
792
+ */
793
+ /** Default core version when none provided. */
794
+ const DEFAULT_VERSION = '0.0.0';
795
+ /** Stale lock threshold in ms (2 minutes). */
796
+ const STALE_LOCK_MS = 120_000;
797
+ /**
798
+ * Update a managed section in a file.
799
+ *
800
+ * @param filePath - Absolute path to the target file.
801
+ * @param content - New content to write.
802
+ * @param options - Write mode and optional configuration.
803
+ */
804
+ async function updateManagedSection(filePath, content, options = {}) {
805
+ const { mode = 'block', sectionId, markers = TOOLS_MARKERS, coreVersion = DEFAULT_VERSION, stalenessThresholdMs, } = options;
806
+ if (mode === 'section' && !sectionId) {
807
+ throw new Error('sectionId is required when mode is "section"');
808
+ }
809
+ const dir = dirname(filePath);
810
+ if (!existsSync(dir)) {
811
+ mkdirSync(dir, { recursive: true });
812
+ }
813
+ // Create file if it doesn't exist
814
+ if (!existsSync(filePath)) {
815
+ writeFileSync(filePath, '', 'utf-8');
816
+ }
817
+ let release;
818
+ try {
819
+ release = await lock(filePath, {
820
+ stale: STALE_LOCK_MS,
821
+ retries: { retries: 5, minTimeout: 100, maxTimeout: 1000 },
822
+ });
823
+ const fileContent = readFileSync(filePath, 'utf-8');
824
+ const parsed = parseManaged(fileContent, markers);
825
+ // Version-stamp convergence check (block mode only).
826
+ // In section mode, components always write their own sections — the version
827
+ // stamp governs shared content convergence, not component-specific sections.
828
+ if (mode === 'block' &&
829
+ !shouldWrite(coreVersion, parsed.versionStamp, stalenessThresholdMs)) {
830
+ return;
831
+ }
832
+ let newManagedBody;
833
+ if (mode === 'block') {
834
+ newManagedBody = content;
835
+ }
836
+ else {
837
+ // Section mode: upsert the named section
838
+ const sections = [...parsed.sections];
839
+ const existingIdx = sections.findIndex((s) => s.id === sectionId);
840
+ if (existingIdx >= 0) {
841
+ sections[existingIdx] = { id: sectionId, content };
842
+ }
843
+ else {
844
+ sections.push({ id: sectionId, content });
845
+ }
846
+ sortSectionsByOrder(sections);
847
+ const sectionText = sections
848
+ .map((s) => `## ${s.id}\n\n${s.content}`)
849
+ .join('\n\n');
850
+ // Prepend H1 title if markers specify one (e.g., "# Jeeves Platform Tools")
851
+ newManagedBody = markers.title
852
+ ? `# ${markers.title}\n\n${sectionText}`
853
+ : sectionText;
854
+ }
855
+ // Cleanup detection
856
+ const userContent = parsed.userContent;
857
+ const cleanupNeeded = needsCleanup(newManagedBody, userContent);
858
+ // Build the full managed block
859
+ const beginLine = formatBeginMarker(markers.begin, coreVersion);
860
+ const endLine = formatEndMarker(markers.end);
861
+ const parts = [];
862
+ if (parsed.beforeContent) {
863
+ parts.push(parsed.beforeContent);
864
+ parts.push('');
865
+ }
866
+ parts.push(beginLine);
867
+ if (cleanupNeeded) {
868
+ parts.push('');
869
+ parts.push(CLEANUP_FLAG);
870
+ }
871
+ parts.push('');
872
+ parts.push(newManagedBody);
873
+ parts.push('');
874
+ parts.push(endLine);
875
+ if (userContent) {
876
+ parts.push('');
877
+ parts.push(userContent);
878
+ }
879
+ parts.push('');
880
+ const newFileContent = parts.join('\n');
881
+ // Atomic write: write to temp file, then rename
882
+ const tempPath = join(dir, `.${String(Date.now())}.tmp`);
883
+ writeFileSync(tempPath, newFileContent, 'utf-8');
884
+ renameSync(tempPath, filePath);
885
+ }
886
+ catch (err) {
887
+ // Log warning but don't throw — writer cycles are periodic
888
+ const message = err instanceof Error ? err.message : String(err);
889
+ console.warn(`jeeves-core: updateManagedSection failed for ${filePath}: ${message}`);
890
+ }
891
+ finally {
892
+ if (release) {
893
+ try {
894
+ await release();
895
+ }
896
+ catch {
897
+ // Lock already released or file deleted — safe to ignore
898
+ }
899
+ }
900
+ }
901
+ }
902
+
903
+ /**
904
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
905
+ *
906
+ * @remarks
907
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
908
+ * Probes service ports for health, reads content files from the package's
909
+ * `content/` directory, renders the Platform template with live service data,
910
+ * and writes managed sections using `updateManagedSection`.
911
+ */
912
+ /**
913
+ * Resolve the package's content directory.
914
+ *
915
+ * @returns Absolute path to the content/ directory.
916
+ */
917
+ function getContentDir() {
918
+ const thisFile = fileURLToPath(import.meta.url);
919
+ // From src/platform/refreshPlatformContent.ts → ../../content/
920
+ // From dist/platform/refreshPlatformContent.js → ../../content/
921
+ return join(dirname(thisFile), '..', '..', 'content');
922
+ }
923
+ /**
924
+ * Read a content file from the package's content/ directory.
925
+ *
926
+ * @param fileName - File name within content/.
927
+ * @returns File content as string, or empty string if missing.
928
+ */
929
+ function readContentFile(fileName) {
930
+ const filePath = join(getContentDir(), fileName);
931
+ if (!existsSync(filePath)) {
932
+ console.warn(`jeeves-core: content file missing: ${filePath}`);
933
+ return '';
934
+ }
935
+ return readFileSync(filePath, 'utf-8');
936
+ }
937
+ /**
938
+ * Copy templates from content/templates/ to the core config directory.
939
+ *
940
+ * @param coreConfigDir - Core config directory path.
941
+ */
942
+ function copyTemplates(coreConfigDir) {
943
+ const sourceDir = join(getContentDir(), 'templates');
944
+ if (!existsSync(sourceDir))
945
+ return;
946
+ const destDir = join(coreConfigDir, TEMPLATES_DIR);
947
+ if (!existsSync(destDir)) {
948
+ mkdirSync(destDir, { recursive: true });
949
+ }
950
+ cpSync(sourceDir, destDir, { recursive: true });
951
+ }
952
+ /** Whether Handlebars helpers have been registered. */
953
+ let helpersRegistered = false;
954
+ /**
955
+ * Register Handlebars helpers used in the Platform template.
956
+ */
957
+ function registerHelpers() {
958
+ if (helpersRegistered)
959
+ return;
960
+ helpersRegistered = true;
961
+ Handlebars.registerHelper('gt', (a, b) => typeof a === 'number' && typeof b === 'number' && a > b);
962
+ }
963
+ /**
964
+ * Refresh platform content: SOUL.md, AGENTS.md, and TOOLS.md Platform section.
965
+ *
966
+ * @param options - Configuration for the refresh cycle.
967
+ */
968
+ async function refreshPlatformContent(options) {
969
+ const { coreVersion, componentName, stalenessThresholdMs, probeTimeoutMs = 3000, skipRegistryCheck = false, } = options;
970
+ const workspacePath = getWorkspacePath();
971
+ const coreConfigDir = getCoreConfigDir();
972
+ // 1. Probe all services
973
+ const probeResults = await probeAllServices(undefined, probeTimeoutMs);
974
+ const unhealthyServices = probeResults.filter((r) => !r.healthy);
975
+ // 2. Build version info (registry check for the core package)
976
+ let availableVersion;
977
+ if (!skipRegistryCheck) {
978
+ const cacheDir = componentName
979
+ ? getComponentConfigDir(componentName)
980
+ : coreConfigDir;
981
+ availableVersion = checkRegistryVersion('@karmaniverous/jeeves', cacheDir);
982
+ }
983
+ const versionInfo = probeResults.map((r) => ({
984
+ name: r.name,
985
+ serviceVersion: r.version,
986
+ coreVersion,
987
+ availableVersion: availableVersion && availableVersion !== coreVersion
988
+ ? availableVersion
989
+ : undefined,
990
+ }));
991
+ // 3. Check if templates are available
992
+ const templatePath = join(coreConfigDir, TEMPLATES_DIR);
993
+ const templatesAvailable = existsSync(templatePath);
994
+ // 4. Render Platform template
995
+ registerHelpers();
996
+ const templateSrc = readContentFile('tools-platform.md');
997
+ const template = Handlebars.compile(templateSrc);
998
+ const templateData = {
999
+ services: probeResults,
1000
+ unhealthyServices,
1001
+ versionInfo: versionInfo.some((v) => v.serviceVersion)
1002
+ ? versionInfo
1003
+ : undefined,
1004
+ templatesAvailable,
1005
+ templatePath,
1006
+ };
1007
+ const platformContent = template(templateData);
1008
+ // 5. Write TOOLS.md Platform section
1009
+ const toolsPath = join(workspacePath, WORKSPACE_FILES.tools);
1010
+ await updateManagedSection(toolsPath, platformContent, {
1011
+ mode: 'section',
1012
+ sectionId: 'Platform',
1013
+ markers: TOOLS_MARKERS,
1014
+ coreVersion,
1015
+ stalenessThresholdMs,
1016
+ });
1017
+ // 6. Write SOUL.md managed block
1018
+ const soulContent = readContentFile('soul-section.md');
1019
+ const soulPath = join(workspacePath, WORKSPACE_FILES.soul);
1020
+ await updateManagedSection(soulPath, soulContent, {
1021
+ mode: 'block',
1022
+ markers: SOUL_MARKERS,
1023
+ coreVersion,
1024
+ stalenessThresholdMs,
1025
+ });
1026
+ // 7. Write AGENTS.md managed block
1027
+ const agentsContent = readContentFile('agents-section.md');
1028
+ const agentsPath = join(workspacePath, WORKSPACE_FILES.agents);
1029
+ await updateManagedSection(agentsPath, agentsContent, {
1030
+ mode: 'block',
1031
+ markers: AGENTS_MARKERS,
1032
+ coreVersion,
1033
+ stalenessThresholdMs,
1034
+ });
1035
+ // 8. Copy templates to config dir
1036
+ copyTemplates(coreConfigDir);
1037
+ }
1038
+
1039
+ /**
1040
+ * One-shot content seeding used by the CLI install command.
1041
+ *
1042
+ * @remarks
1043
+ * Seeds SOUL.md, AGENTS.md, and TOOLS.md Platform section using the same
1044
+ * `updateManagedSection()` code path as writer cycles. Also copies templates
1045
+ * and creates core config with defaults if missing.
1046
+ */
1047
+ /**
1048
+ * Create the core config file with defaults if it doesn't already exist.
1049
+ *
1050
+ * @param coreConfigDir - Path to the core config directory.
1051
+ */
1052
+ function ensureCoreConfig(coreConfigDir) {
1053
+ if (!existsSync(coreConfigDir)) {
1054
+ mkdirSync(coreConfigDir, { recursive: true });
1055
+ }
1056
+ const configPath = join(coreConfigDir, CONFIG_FILE);
1057
+ if (existsSync(configPath))
1058
+ return;
1059
+ const defaults = coreConfigSchema.parse({});
1060
+ const configWithSchema = {
1061
+ $schema: './config.schema.json',
1062
+ ...defaults,
1063
+ };
1064
+ writeFileSync(configPath, JSON.stringify(configWithSchema, null, 2), 'utf-8');
1065
+ // Write JSON schema file alongside config
1066
+ const schemaPath = join(coreConfigDir, 'config.schema.json');
1067
+ const jsonSchema = generateJsonSchema();
1068
+ writeFileSync(schemaPath, JSON.stringify(jsonSchema, null, 2), 'utf-8');
1069
+ }
1070
+ /**
1071
+ * Seed all platform content into the workspace.
1072
+ *
1073
+ * @remarks
1074
+ * Uses the same `updateManagedSection()` code path as writer cycles.
1075
+ * Creates core config with defaults if missing. Copies templates.
1076
+ * Jaccard cleanup detection runs automatically via `updateManagedSection`.
1077
+ *
1078
+ * @param options - Seeding configuration.
1079
+ */
1080
+ async function seedContent(options) {
1081
+ const coreConfigDir = getCoreConfigDir();
1082
+ // Ensure core config exists
1083
+ ensureCoreConfig(coreConfigDir);
1084
+ // Seed content via the same code path as writer cycles
1085
+ await refreshPlatformContent({
1086
+ coreVersion: options.coreVersion,
1087
+ probeTimeoutMs: options.probeTimeoutMs ?? 3000,
1088
+ skipRegistryCheck: options.skipRegistryCheck ?? true,
1089
+ });
1090
+ }
1091
+
1092
+ /**
1093
+ * Shared CLI defaults and option registration for Jeeves CLI commands.
1094
+ *
1095
+ * @remarks
1096
+ * All three CLI commands (install, uninstall, status) share the same
1097
+ * `--workspace` and `--config-root` options with the same defaults.
1098
+ * This module centralizes them to eliminate duplication.
1099
+ */
1100
+ /** Default workspace path (current directory). */
1101
+ const DEFAULT_WORKSPACE = '.';
1102
+ /** Default config root path. */
1103
+ const DEFAULT_CONFIG_ROOT = './config';
1104
+ /**
1105
+ * Initialize core from standard CLI options.
1106
+ *
1107
+ * @param opts - Parsed Commander options with workspace and configRoot.
1108
+ */
1109
+ function initFromOptions(opts) {
1110
+ init({ workspacePath: opts.workspace, configRoot: opts.configRoot });
1111
+ }
1112
+
1113
+ /**
1114
+ * CLI install command: seed platform content into the workspace.
1115
+ *
1116
+ * @remarks
1117
+ * Seeds SOUL.md, AGENTS.md, TOOLS.md Platform section using the same
1118
+ * `updateManagedSection()` code path as writer cycles. Copies templates
1119
+ * to config dir. Creates core config with defaults if missing.
1120
+ * Jaccard cleanup detection runs on install (Decision 22).
1121
+ */
1122
+ /**
1123
+ * Register the install subcommand on the parent CLI program.
1124
+ *
1125
+ * @param program - The parent Commander program.
1126
+ */
1127
+ function registerInstallCommand(program) {
1128
+ program
1129
+ .command('install')
1130
+ .description('Seed Jeeves platform content into the workspace')
1131
+ .option('-w, --workspace <path>', 'Workspace root path', DEFAULT_WORKSPACE)
1132
+ .option('-c, --config-root <path>', 'Platform config root path', DEFAULT_CONFIG_ROOT)
1133
+ .action(async (opts) => {
1134
+ console.log('Jeeves platform install');
1135
+ console.log(` Workspace: ${opts.workspace}`);
1136
+ console.log(` Config root: ${opts.configRoot}`);
1137
+ console.log();
1138
+ initFromOptions(opts);
1139
+ await seedContent({
1140
+ coreVersion: CORE_VERSION,
1141
+ skipRegistryCheck: true,
1142
+ });
1143
+ console.log('✅ Platform content seeded successfully.');
1144
+ console.log(' - SOUL.md managed section written');
1145
+ console.log(' - AGENTS.md managed section written');
1146
+ console.log(' - TOOLS.md Platform section written');
1147
+ console.log(' - Templates copied to config directory');
1148
+ console.log(' - Core config created (if not present)');
1149
+ });
1150
+ }
1151
+
1152
+ /**
1153
+ * CLI status command: probe all service ports and report health summary.
1154
+ *
1155
+ * @remarks
1156
+ * Displays a table of all Jeeves platform services with port and
1157
+ * health status. Exits with code 0 if all services are healthy,
1158
+ * code 1 if any are unreachable.
1159
+ */
1160
+ /**
1161
+ * Register the status subcommand on the parent CLI program.
1162
+ *
1163
+ * @param program - The parent Commander program.
1164
+ */
1165
+ function registerStatusCommand(program) {
1166
+ program
1167
+ .command('status')
1168
+ .description('Probe all Jeeves service ports and report health summary')
1169
+ .option('-w, --workspace <path>', 'Workspace root path', DEFAULT_WORKSPACE)
1170
+ .option('-c, --config-root <path>', 'Platform config root path', DEFAULT_CONFIG_ROOT)
1171
+ .option('-t, --timeout <ms>', 'Probe timeout in milliseconds', '3000')
1172
+ .action(async (opts) => {
1173
+ const timeoutMs = parseInt(opts.timeout, 10);
1174
+ initFromOptions(opts);
1175
+ console.log('Jeeves Platform Status');
1176
+ console.log('='.repeat(60));
1177
+ console.log();
1178
+ const probeResults = await probeAllServices(undefined, timeoutMs);
1179
+ const nameWidth = 10;
1180
+ const portWidth = 6;
1181
+ const statusWidth = 30;
1182
+ const header = [
1183
+ 'Service'.padEnd(nameWidth),
1184
+ 'Port'.padEnd(portWidth),
1185
+ 'Status'.padEnd(statusWidth),
1186
+ ].join(' ');
1187
+ const separator = [
1188
+ '-'.repeat(nameWidth),
1189
+ '-'.repeat(portWidth),
1190
+ '-'.repeat(statusWidth),
1191
+ ].join(' ');
1192
+ console.log(header);
1193
+ console.log(separator);
1194
+ let allHealthy = true;
1195
+ for (const r of probeResults) {
1196
+ let status;
1197
+ if (r.healthy) {
1198
+ status = r.version ? `✅ Running (v${r.version})` : '✅ Running';
1199
+ }
1200
+ else {
1201
+ status = r.error ? `❌ ${r.error}` : '❌ Down';
1202
+ allHealthy = false;
1203
+ }
1204
+ const row = [
1205
+ r.name.padEnd(nameWidth),
1206
+ String(r.port).padEnd(portWidth),
1207
+ status.padEnd(statusWidth),
1208
+ ].join(' ');
1209
+ console.log(row);
1210
+ }
1211
+ console.log();
1212
+ const healthy = probeResults.filter((r) => r.healthy).length;
1213
+ const total = probeResults.length;
1214
+ console.log(`${String(healthy)}/${String(total)} services healthy`);
1215
+ if (!allHealthy) {
1216
+ process.exitCode = 1;
1217
+ }
1218
+ });
1219
+ }
1220
+
1221
+ /**
1222
+ * Shared helpers for the uninstall command.
1223
+ *
1224
+ * @remarks
1225
+ * Extracted for testability — these are the core uninstall operations.
1226
+ */
1227
+ /**
1228
+ * Remove managed block from a file, keeping user content.
1229
+ *
1230
+ * @param filePath - Absolute path to the workspace file.
1231
+ * @param markers - Begin/end marker pair.
1232
+ */
1233
+ function removeManagedBlockFromFile(filePath, markers) {
1234
+ if (!existsSync(filePath))
1235
+ return;
1236
+ const content = readFileSync(filePath, 'utf-8');
1237
+ const parsed = parseManaged(content, markers);
1238
+ if (!parsed.found)
1239
+ return;
1240
+ // Reconstruct file with only user content
1241
+ const parts = [];
1242
+ if (parsed.beforeContent) {
1243
+ parts.push(parsed.beforeContent);
1244
+ }
1245
+ if (parsed.userContent) {
1246
+ if (parts.length > 0)
1247
+ parts.push('');
1248
+ parts.push(parsed.userContent);
1249
+ }
1250
+ const newContent = parts.join('\n').trim() + '\n';
1251
+ writeFileSync(filePath, newContent, 'utf-8');
1252
+ }
1253
+
1254
+ /**
1255
+ * CLI uninstall command: remove managed sections and platform artifacts.
1256
+ *
1257
+ * @remarks
1258
+ * Removes managed sections from SOUL.md, AGENTS.md, TOOLS.md.
1259
+ * Removes templates and config dir artifacts. Warns if services
1260
+ * still responding on known ports.
1261
+ */
1262
+ /**
1263
+ * Register the uninstall subcommand on the parent CLI program.
1264
+ *
1265
+ * @param program - The parent Commander program.
1266
+ */
1267
+ function registerUninstallCommand(program) {
1268
+ program
1269
+ .command('uninstall')
1270
+ .description('Remove Jeeves managed sections and platform artifacts')
1271
+ .option('-w, --workspace <path>', 'Workspace root path', DEFAULT_WORKSPACE)
1272
+ .option('-c, --config-root <path>', 'Platform config root path', DEFAULT_CONFIG_ROOT)
1273
+ .action(async (opts) => {
1274
+ console.log('Jeeves platform uninstall');
1275
+ console.log(` Workspace: ${opts.workspace}`);
1276
+ console.log(` Config root: ${opts.configRoot}`);
1277
+ console.log();
1278
+ initFromOptions(opts);
1279
+ const wsPath = getWorkspacePath();
1280
+ const coreConfigDir = getCoreConfigDir();
1281
+ // Remove managed sections from workspace files
1282
+ const toolsPath = join(wsPath, WORKSPACE_FILES.tools);
1283
+ removeManagedBlockFromFile(toolsPath, TOOLS_MARKERS);
1284
+ console.log(' ✓ TOOLS.md managed section removed');
1285
+ const soulPath = join(wsPath, WORKSPACE_FILES.soul);
1286
+ removeManagedBlockFromFile(soulPath, SOUL_MARKERS);
1287
+ console.log(' ✓ SOUL.md managed section removed');
1288
+ const agentsPath = join(wsPath, WORKSPACE_FILES.agents);
1289
+ removeManagedBlockFromFile(agentsPath, AGENTS_MARKERS);
1290
+ console.log(' ✓ AGENTS.md managed section removed');
1291
+ // Remove templates directory
1292
+ const templatesDir = join(coreConfigDir, TEMPLATES_DIR);
1293
+ if (existsSync(templatesDir)) {
1294
+ rmSync(templatesDir, { recursive: true, force: true });
1295
+ console.log(' ✓ Templates removed');
1296
+ }
1297
+ // Remove config schema file
1298
+ const schemaPath = join(coreConfigDir, 'config.schema.json');
1299
+ if (existsSync(schemaPath)) {
1300
+ rmSync(schemaPath);
1301
+ console.log(' ✓ Config schema removed');
1302
+ }
1303
+ console.log();
1304
+ // Warn if services still responding
1305
+ try {
1306
+ const probeResults = await probeAllServices(undefined, 2000);
1307
+ const running = probeResults.filter((r) => r.healthy);
1308
+ if (running.length > 0) {
1309
+ console.log('⚠️ The following services are still responding:');
1310
+ for (const r of running) {
1311
+ const ver = r.version ? ` (v${r.version})` : '';
1312
+ console.log(` - ${r.name} on port ${String(r.port)}${ver}`);
1313
+ }
1314
+ console.log(' Consider stopping them before fully removing Jeeves.');
1315
+ console.log();
1316
+ }
1317
+ }
1318
+ catch {
1319
+ // Probe failure is non-fatal during uninstall
1320
+ }
1321
+ console.log('✅ Jeeves platform artifacts removed.');
1322
+ });
1323
+ }
1324
+
1325
+ /**
1326
+ * Jeeves CLI — platform content seeding, teardown, and status.
1327
+ *
1328
+ * @remarks
1329
+ * Entry point for the `jeeves` CLI command. Provides install, uninstall,
1330
+ * and status subcommands.
1331
+ */
1332
+ const cli = new Command()
1333
+ .name('jeeves')
1334
+ .description('Jeeves AI assistant platform — shared library and CLI')
1335
+ .version(CORE_VERSION)
1336
+ .enablePositionalOptions()
1337
+ .passThroughOptions();
1338
+ registerInstallCommand(cli);
1339
+ registerUninstallCommand(cli);
1340
+ registerStatusCommand(cli);
1341
+ cli.parse();