@kadi.build/core 0.9.0 → 0.11.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.
Files changed (45) hide show
  1. package/README.md +424 -1
  2. package/agent.json +19 -0
  3. package/dist/agent-json.d.ts +231 -0
  4. package/dist/agent-json.d.ts.map +1 -0
  5. package/dist/agent-json.js +554 -0
  6. package/dist/agent-json.js.map +1 -0
  7. package/dist/client.d.ts +34 -0
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +50 -0
  10. package/dist/client.js.map +1 -1
  11. package/dist/errors.d.ts +1 -1
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js.map +1 -1
  14. package/dist/index.d.ts +5 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +8 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/process-manager.d.ts +235 -0
  19. package/dist/process-manager.d.ts.map +1 -0
  20. package/dist/process-manager.js +647 -0
  21. package/dist/process-manager.js.map +1 -0
  22. package/dist/stdio-framing.d.ts +88 -0
  23. package/dist/stdio-framing.d.ts.map +1 -0
  24. package/dist/stdio-framing.js +194 -0
  25. package/dist/stdio-framing.js.map +1 -0
  26. package/dist/transports/stdio.d.ts.map +1 -1
  27. package/dist/transports/stdio.js +3 -181
  28. package/dist/transports/stdio.js.map +1 -1
  29. package/dist/types.d.ts +256 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/utils.d.ts +107 -0
  32. package/dist/utils.d.ts.map +1 -0
  33. package/dist/utils.js +212 -0
  34. package/dist/utils.js.map +1 -0
  35. package/package.json +3 -1
  36. package/scripts/symlink.mjs +131 -0
  37. package/src/agent-json.ts +655 -0
  38. package/src/client.ts +56 -0
  39. package/src/errors.ts +15 -0
  40. package/src/index.ts +32 -0
  41. package/src/process-manager.ts +821 -0
  42. package/src/stdio-framing.ts +227 -0
  43. package/src/transports/stdio.ts +4 -221
  44. package/src/types.ts +277 -0
  45. package/src/utils.ts +246 -0
@@ -0,0 +1,655 @@
1
+ /**
2
+ * AgentJsonManager for kadi-core
3
+ *
4
+ * Provides unified read/write access to agent.json files across the ecosystem.
5
+ * Three target locations:
6
+ *
7
+ * 1. **Project root** — the agent's own config (`/my-agent/agent.json`)
8
+ * 2. **Installed ability** — an ability's config (`/my-agent/abilities/calculator@1.0.0/agent.json`)
9
+ * 3. **KADI home** — global CLI config (`~/.kadi/agent.json`)
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import { AgentJsonManager } from '@kadi.build/core';
14
+ *
15
+ * const ajm = new AgentJsonManager();
16
+ *
17
+ * // Read
18
+ * const config = await ajm.readProject();
19
+ * const deploy = await ajm.readProject('deploy.local');
20
+ * const abilityConfig = await ajm.readAbility('calculator');
21
+ *
22
+ * // Write
23
+ * await ajm.writeProject('deploy.staging', { target: 'akash' });
24
+ * await ajm.deleteProject('deploy.staging');
25
+ * ```
26
+ */
27
+
28
+ import { readFile, writeFile, rename, mkdir } from 'fs/promises';
29
+ import { existsSync } from 'fs';
30
+ import { join, dirname } from 'path';
31
+ import { homedir } from 'os';
32
+ import { randomBytes } from 'crypto';
33
+ import type {
34
+ AgentJson,
35
+ AgentJsonManagerOptions,
36
+ ReadAbilityOptions,
37
+ AbilityInfo,
38
+ AgentJsonPaths,
39
+ AgentLockFile,
40
+ AbilityLockEntry,
41
+ } from './types.js';
42
+ import { KadiError } from './errors.js';
43
+ import { findProjectRoot, readLockFile, getInstalledAbilityNames } from './lockfile.js';
44
+ import { getByPath, setByPath, deleteByPath } from './utils.js';
45
+
46
+ // ═══════════════════════════════════════════════════════════════════════
47
+ // CONSTANTS
48
+ // ═══════════════════════════════════════════════════════════════════════
49
+
50
+ const AGENT_JSON_FILENAME = 'agent.json';
51
+ const DEFAULT_KADI_HOME = join(homedir(), '.kadi');
52
+
53
+ // ═══════════════════════════════════════════════════════════════════════
54
+ // AGENT JSON MANAGER
55
+ // ═══════════════════════════════════════════════════════════════════════
56
+
57
+ export class AgentJsonManager {
58
+ private readonly options: Required<AgentJsonManagerOptions>;
59
+
60
+ /** Cached project root (resolved lazily) */
61
+ private resolvedProjectRoot: string | null = null;
62
+
63
+ constructor(options: AgentJsonManagerOptions = {}) {
64
+ this.options = {
65
+ projectRoot: options.projectRoot ?? '',
66
+ kadiHome: options.kadiHome ?? DEFAULT_KADI_HOME,
67
+ createOnWrite: options.createOnWrite ?? true,
68
+ };
69
+ }
70
+
71
+ // ─────────────────────────────────────────────────────────────────
72
+ // PROJECT ROOT RESOLUTION
73
+ // ─────────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Get the resolved project root path.
77
+ * Auto-detects if not explicitly set.
78
+ */
79
+ private async getProjectRoot(): Promise<string> {
80
+ if (this.resolvedProjectRoot) {
81
+ return this.resolvedProjectRoot;
82
+ }
83
+
84
+ if (this.options.projectRoot) {
85
+ this.resolvedProjectRoot = this.options.projectRoot;
86
+ } else {
87
+ this.resolvedProjectRoot = await findProjectRoot();
88
+ }
89
+
90
+ return this.resolvedProjectRoot;
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────
94
+ // READ API
95
+ // ─────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Read the project root agent.json.
99
+ *
100
+ * @param field - Optional dot-notation path to a specific field
101
+ * @returns The full agent.json object, or a specific field value
102
+ * @throws KadiError with code AGENT_JSON_NOT_FOUND, AGENT_JSON_PARSE_ERROR,
103
+ * or AGENT_JSON_FIELD_NOT_FOUND
104
+ *
105
+ * @example
106
+ * ```typescript
107
+ * const config = await ajm.readProject(); // Full object
108
+ * const name = await ajm.readProject('name'); // 'my-agent'
109
+ * const deploy = await ajm.readProject('deploy.local'); // { target: 'docker', ... }
110
+ * ```
111
+ */
112
+ async readProject(field?: string): Promise<unknown> {
113
+ const root = await this.getProjectRoot();
114
+ const filePath = join(root, AGENT_JSON_FILENAME);
115
+ const data = await this.readJsonFile(filePath, 'project');
116
+
117
+ if (!field) return data;
118
+ return this.resolveField(data, field, filePath);
119
+ }
120
+
121
+ /**
122
+ * Read an installed ability's agent.json.
123
+ *
124
+ * Resolution order for version ambiguity:
125
+ * 1. If `options.version` specified → exact match
126
+ * 2. Prefer `isTopLevel: true` entries (direct dependencies)
127
+ * 3. Use highest semver version
128
+ *
129
+ * @param name - Ability name (without version)
130
+ * @param options - Version targeting and field selection
131
+ * @returns The full agent.json object, or a specific field value
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const config = await ajm.readAbility('calculator');
136
+ * const scripts = await ajm.readAbility('calculator', { field: 'scripts' });
137
+ * const specific = await ajm.readAbility('secret-ability', { version: '0.9.0' });
138
+ * ```
139
+ */
140
+ async readAbility(name: string, options: ReadAbilityOptions = {}): Promise<unknown> {
141
+ const abilityPath = await this.resolveAbilityDir(name, options.version);
142
+ const filePath = join(abilityPath, AGENT_JSON_FILENAME);
143
+ const data = await this.readJsonFile(filePath, `ability "${name}"`);
144
+
145
+ if (!options.field) return data;
146
+ return this.resolveField(data, options.field, filePath);
147
+ }
148
+
149
+ /**
150
+ * Read the global KADI home agent.json (~/.kadi/agent.json).
151
+ *
152
+ * @param field - Optional dot-notation path to a specific field
153
+ * @returns The full agent.json object, or a specific field value
154
+ *
155
+ * @example
156
+ * ```typescript
157
+ * const global = await ajm.readHome();
158
+ * const plugins = await ajm.readHome('cliPlugins');
159
+ * ```
160
+ */
161
+ async readHome(field?: string): Promise<unknown> {
162
+ const filePath = join(this.options.kadiHome, AGENT_JSON_FILENAME);
163
+ const data = await this.readJsonFile(filePath, 'home (~/.kadi)');
164
+
165
+ if (!field) return data;
166
+ return this.resolveField(data, field, filePath);
167
+ }
168
+
169
+ // ─────────────────────────────────────────────────────────────────
170
+ // WRITE API
171
+ // ─────────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Write a value to the project root agent.json.
175
+ *
176
+ * Uses deep-merge semantics for objects (preserves sibling keys).
177
+ * Scalars and arrays are directly replaced.
178
+ *
179
+ * @param path - Dot-notation path to the field to set
180
+ * @param value - The value to write
181
+ *
182
+ * @example
183
+ * ```typescript
184
+ * await ajm.writeProject('version', '2.0.0');
185
+ * await ajm.writeProject('deploy.staging', { target: 'akash', network: 'mainnet' });
186
+ * await ajm.writeProject('build.arm64', { from: 'node:22-slim', platform: 'linux/arm64' });
187
+ * ```
188
+ */
189
+ async writeProject(path: string, value: unknown): Promise<void> {
190
+ const root = await this.getProjectRoot();
191
+ const filePath = join(root, AGENT_JSON_FILENAME);
192
+ await this.writeField(filePath, path, value, 'project');
193
+ }
194
+
195
+ /**
196
+ * Write a value to an installed ability's agent.json.
197
+ *
198
+ * @param name - Ability name
199
+ * @param path - Dot-notation path to the field to set
200
+ * @param value - The value to write
201
+ * @param version - Optional specific version to target
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * await ajm.writeAbility('calculator', 'scripts.dev', 'node dist/index.js --debug');
206
+ * ```
207
+ */
208
+ async writeAbility(name: string, path: string, value: unknown, version?: string): Promise<void> {
209
+ const abilityPath = await this.resolveAbilityDir(name, version);
210
+ const filePath = join(abilityPath, AGENT_JSON_FILENAME);
211
+ await this.writeField(filePath, path, value, `ability "${name}"`);
212
+ }
213
+
214
+ /**
215
+ * Write a value to the global KADI home agent.json.
216
+ *
217
+ * @param path - Dot-notation path to the field to set
218
+ * @param value - The value to write
219
+ *
220
+ * @example
221
+ * ```typescript
222
+ * await ajm.writeHome('cliPlugins.my-plugin', { version: '1.0.0' });
223
+ * ```
224
+ */
225
+ async writeHome(path: string, value: unknown): Promise<void> {
226
+ const filePath = join(this.options.kadiHome, AGENT_JSON_FILENAME);
227
+ await this.writeField(filePath, path, value, 'home (~/.kadi)');
228
+ }
229
+
230
+ // ─────────────────────────────────────────────────────────────────
231
+ // DELETE API
232
+ // ─────────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Delete a field from the project root agent.json.
236
+ *
237
+ * @param path - Dot-notation path to the field to delete
238
+ * @returns true if the field was found and deleted
239
+ *
240
+ * @example
241
+ * ```typescript
242
+ * await ajm.deleteProject('deploy.staging');
243
+ * ```
244
+ */
245
+ async deleteProject(path: string): Promise<boolean> {
246
+ const root = await this.getProjectRoot();
247
+ const filePath = join(root, AGENT_JSON_FILENAME);
248
+ return this.deleteField(filePath, path);
249
+ }
250
+
251
+ /**
252
+ * Delete a field from an ability's agent.json.
253
+ */
254
+ async deleteAbility(name: string, path: string, version?: string): Promise<boolean> {
255
+ const abilityPath = await this.resolveAbilityDir(name, version);
256
+ const filePath = join(abilityPath, AGENT_JSON_FILENAME);
257
+ return this.deleteField(filePath, path);
258
+ }
259
+
260
+ /**
261
+ * Delete a field from the global KADI home agent.json.
262
+ */
263
+ async deleteHome(path: string): Promise<boolean> {
264
+ const filePath = join(this.options.kadiHome, AGENT_JSON_FILENAME);
265
+ return this.deleteField(filePath, path);
266
+ }
267
+
268
+ // ─────────────────────────────────────────────────────────────────
269
+ // DISCOVERY API
270
+ // ─────────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * List all installed abilities with their name, version, and path.
274
+ *
275
+ * @returns Array of AbilityInfo objects
276
+ *
277
+ * @example
278
+ * ```typescript
279
+ * const abilities = await ajm.listAbilities();
280
+ * // [
281
+ * // { name: 'calculator', version: '1.0.0', path: '/abs/path/abilities/calculator@1.0.0' },
282
+ * // { name: 'secret-ability', version: '0.9.0', path: '/abs/path/abilities/secret-ability@0.9.0' },
283
+ * // ]
284
+ * ```
285
+ */
286
+ async listAbilities(): Promise<AbilityInfo[]> {
287
+ const root = await this.getProjectRoot();
288
+ let lockFile: AgentLockFile;
289
+
290
+ try {
291
+ lockFile = await readLockFile(root);
292
+ } catch {
293
+ return []; // No lock file → no installed abilities
294
+ }
295
+
296
+ const abilities: AbilityInfo[] = [];
297
+
298
+ for (const [key, entry] of Object.entries(lockFile.abilities)) {
299
+ const lastAt = key.lastIndexOf('@');
300
+ const name = lastAt !== -1 ? key.substring(0, lastAt) : key;
301
+ abilities.push({
302
+ name,
303
+ version: entry.version,
304
+ path: join(root, entry.resolved),
305
+ });
306
+ }
307
+
308
+ return abilities;
309
+ }
310
+
311
+ /**
312
+ * Check if a specific ability is installed.
313
+ *
314
+ * @param name - Ability name (without version)
315
+ * @returns true if the ability exists in the lock file
316
+ */
317
+ async hasAbility(name: string): Promise<boolean> {
318
+ const root = await this.getProjectRoot();
319
+ let lockFile: AgentLockFile;
320
+
321
+ try {
322
+ lockFile = await readLockFile(root);
323
+ } catch {
324
+ return false;
325
+ }
326
+
327
+ return this.findAbilityEntries(lockFile, name).length > 0;
328
+ }
329
+
330
+ /**
331
+ * Get all installed versions of a specific ability.
332
+ *
333
+ * @param name - Ability name (without version)
334
+ * @returns Array of version strings
335
+ *
336
+ * @example
337
+ * ```typescript
338
+ * const versions = await ajm.getAbilityVersions('secret-ability');
339
+ * // ['0.7.0', '0.9.0']
340
+ * ```
341
+ */
342
+ async getAbilityVersions(name: string): Promise<string[]> {
343
+ const root = await this.getProjectRoot();
344
+ let lockFile: AgentLockFile;
345
+
346
+ try {
347
+ lockFile = await readLockFile(root);
348
+ } catch {
349
+ return [];
350
+ }
351
+
352
+ return this.findAbilityEntries(lockFile, name).map(e => e.version);
353
+ }
354
+
355
+ /**
356
+ * Get resolved paths for all known agent.json files.
357
+ *
358
+ * @returns Object with project, home, and abilities paths
359
+ */
360
+ async getPaths(): Promise<AgentJsonPaths> {
361
+ const root = await this.getProjectRoot();
362
+ const abilities: Record<string, string> = {};
363
+
364
+ try {
365
+ const lockFile = await readLockFile(root);
366
+ for (const [key, entry] of Object.entries(lockFile.abilities)) {
367
+ abilities[key] = join(root, entry.resolved, AGENT_JSON_FILENAME);
368
+ }
369
+ } catch {
370
+ // No lock file — abilities stays empty
371
+ }
372
+
373
+ return {
374
+ project: join(root, AGENT_JSON_FILENAME),
375
+ home: join(this.options.kadiHome, AGENT_JSON_FILENAME),
376
+ abilities,
377
+ };
378
+ }
379
+
380
+ // ─────────────────────────────────────────────────────────────────
381
+ // INTERNAL: FILE I/O
382
+ // ─────────────────────────────────────────────────────────────────
383
+
384
+ /**
385
+ * Read and parse a JSON file.
386
+ */
387
+ private async readJsonFile(filePath: string, label: string): Promise<AgentJson> {
388
+ let content: string;
389
+ try {
390
+ content = await readFile(filePath, 'utf-8');
391
+ } catch {
392
+ throw new KadiError(
393
+ `${label} agent.json not found`,
394
+ 'AGENT_JSON_NOT_FOUND',
395
+ {
396
+ path: filePath,
397
+ hint: label === 'project'
398
+ ? 'Make sure you are in a KADI project directory with an agent.json file'
399
+ : `The ${label} agent.json was expected at: ${filePath}`,
400
+ },
401
+ );
402
+ }
403
+
404
+ try {
405
+ return JSON.parse(content) as AgentJson;
406
+ } catch {
407
+ throw new KadiError(
408
+ `Failed to parse ${label} agent.json`,
409
+ 'AGENT_JSON_PARSE_ERROR',
410
+ {
411
+ path: filePath,
412
+ hint: 'The file contains invalid JSON. Check for syntax errors.',
413
+ },
414
+ );
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Resolve a field from a parsed agent.json using dot-path notation.
420
+ */
421
+ private resolveField(data: AgentJson, field: string, filePath: string): unknown {
422
+ const value = getByPath(data as Record<string, unknown>, field);
423
+
424
+ if (value === undefined) {
425
+ throw new KadiError(
426
+ `Field "${field}" not found in agent.json`,
427
+ 'AGENT_JSON_FIELD_NOT_FOUND',
428
+ {
429
+ path: filePath,
430
+ available: Object.keys(data),
431
+ hint: `Available top-level keys: ${Object.keys(data).join(', ')}`,
432
+ },
433
+ );
434
+ }
435
+
436
+ return value;
437
+ }
438
+
439
+ /**
440
+ * Write a field to a JSON file using dot-path notation.
441
+ * Atomic write: writes to a temp file then renames.
442
+ */
443
+ private async writeField(
444
+ filePath: string,
445
+ path: string,
446
+ value: unknown,
447
+ label: string,
448
+ ): Promise<void> {
449
+ // Read existing or create new
450
+ let data: Record<string, unknown>;
451
+
452
+ try {
453
+ const content = await readFile(filePath, 'utf-8');
454
+ data = JSON.parse(content) as Record<string, unknown>;
455
+ } catch {
456
+ if (!this.options.createOnWrite) {
457
+ throw new KadiError(
458
+ `${label} agent.json not found (createOnWrite is false)`,
459
+ 'AGENT_JSON_NOT_FOUND',
460
+ { path: filePath },
461
+ );
462
+ }
463
+ data = {};
464
+ }
465
+
466
+ // Apply the change
467
+ setByPath(data, path, value);
468
+
469
+ // Atomic write
470
+ await this.atomicWriteJson(filePath, data);
471
+ }
472
+
473
+ /**
474
+ * Delete a field from a JSON file.
475
+ */
476
+ private async deleteField(filePath: string, path: string): Promise<boolean> {
477
+ let content: string;
478
+ try {
479
+ content = await readFile(filePath, 'utf-8');
480
+ } catch {
481
+ return false;
482
+ }
483
+
484
+ let data: Record<string, unknown>;
485
+ try {
486
+ data = JSON.parse(content) as Record<string, unknown>;
487
+ } catch {
488
+ return false;
489
+ }
490
+
491
+ const deleted = deleteByPath(data, path);
492
+ if (deleted) {
493
+ await this.atomicWriteJson(filePath, data);
494
+ }
495
+
496
+ return deleted;
497
+ }
498
+
499
+ /**
500
+ * Write JSON to a file atomically (write temp → rename).
501
+ */
502
+ private async atomicWriteJson(filePath: string, data: Record<string, unknown>): Promise<void> {
503
+ const dir = dirname(filePath);
504
+ const tmpPath = join(dir, `.agent.json.${randomBytes(6).toString('hex')}.tmp`);
505
+
506
+ try {
507
+ // Ensure directory exists
508
+ if (!existsSync(dir)) {
509
+ await mkdir(dir, { recursive: true });
510
+ }
511
+
512
+ const json = JSON.stringify(data, null, 2) + '\n';
513
+ await writeFile(tmpPath, json, 'utf-8');
514
+ await rename(tmpPath, filePath);
515
+ } catch (error) {
516
+ // Attempt cleanup of temp file
517
+ try {
518
+ const { unlink } = await import('fs/promises');
519
+ await unlink(tmpPath);
520
+ } catch {
521
+ // Ignore cleanup failure
522
+ }
523
+
524
+ throw new KadiError(
525
+ 'Failed to write agent.json',
526
+ 'AGENT_JSON_WRITE_ERROR',
527
+ {
528
+ path: filePath,
529
+ reason: error instanceof Error ? error.message : String(error),
530
+ },
531
+ );
532
+ }
533
+ }
534
+
535
+ // ─────────────────────────────────────────────────────────────────
536
+ // INTERNAL: ABILITY RESOLUTION
537
+ // ─────────────────────────────────────────────────────────────────
538
+
539
+ /**
540
+ * Resolve the directory path for an installed ability.
541
+ * Handles version ambiguity with smart defaults.
542
+ */
543
+ private async resolveAbilityDir(name: string, version?: string): Promise<string> {
544
+ const root = await this.getProjectRoot();
545
+ const lockFile = await readLockFile(root);
546
+ const entries = this.findAbilityEntries(lockFile, name);
547
+
548
+ if (entries.length === 0) {
549
+ const available = getInstalledAbilityNames(lockFile);
550
+ throw new KadiError(
551
+ `Ability "${name}" not found in agent-lock.json`,
552
+ 'ABILITY_NOT_FOUND',
553
+ {
554
+ abilityName: name,
555
+ searched: join(root, 'agent-lock.json'),
556
+ available: available.length > 0 ? available : undefined,
557
+ hint: `Run \`kadi install ${name}\` to install it`,
558
+ },
559
+ );
560
+ }
561
+
562
+ let selected: { entry: AbilityLockEntry; key: string };
563
+
564
+ if (version) {
565
+ // Exact version requested
566
+ const exact = entries.find(e => e.version === version);
567
+ if (!exact) {
568
+ throw new KadiError(
569
+ `Ability "${name}" version "${version}" not found`,
570
+ 'ABILITY_NOT_FOUND',
571
+ {
572
+ abilityName: name,
573
+ available: entries.map(e => e.version),
574
+ hint: `Available versions: ${entries.map(e => e.version).join(', ')}`,
575
+ },
576
+ );
577
+ }
578
+ selected = exact;
579
+ } else if (entries.length === 1) {
580
+ // Only one version — no ambiguity
581
+ selected = entries[0]!;
582
+ } else {
583
+ // Multiple versions — prefer isTopLevel
584
+ const topLevel = entries.filter(e => e.entry.isTopLevel);
585
+
586
+ if (topLevel.length === 1) {
587
+ selected = topLevel[0]!;
588
+ } else if (topLevel.length > 1) {
589
+ // Multiple top-level — pick highest semver
590
+ selected = this.highestVersion(topLevel);
591
+ } else {
592
+ // No top-level — ambiguous
593
+ throw new KadiError(
594
+ `Multiple versions of "${name}" installed — specify a version`,
595
+ 'AGENT_JSON_VERSION_AMBIGUOUS',
596
+ {
597
+ abilityName: name,
598
+ available: entries.map(e => e.version),
599
+ hint: `Installed versions: ${entries.map(e => e.version).join(', ')}. Use { version: "x.y.z" } to select one.`,
600
+ },
601
+ );
602
+ }
603
+ }
604
+
605
+ return join(root, selected.entry.resolved);
606
+ }
607
+
608
+ /**
609
+ * Find all lock file entries matching an ability name.
610
+ */
611
+ private findAbilityEntries(
612
+ lockFile: AgentLockFile,
613
+ name: string,
614
+ ): Array<{ entry: AbilityLockEntry; key: string; version: string }> {
615
+ const results: Array<{ entry: AbilityLockEntry; key: string; version: string }> = [];
616
+
617
+ for (const [key, entry] of Object.entries(lockFile.abilities)) {
618
+ const lastAt = key.lastIndexOf('@');
619
+ const entryName = lastAt !== -1 ? key.substring(0, lastAt) : key;
620
+
621
+ if (entryName === name) {
622
+ results.push({ entry, key, version: entry.version });
623
+ }
624
+ }
625
+
626
+ return results;
627
+ }
628
+
629
+ /**
630
+ * Pick the entry with the highest semver version.
631
+ * Simple comparison — splits on dots and compares numerically.
632
+ */
633
+ private highestVersion<T extends { version: string }>(entries: T[]): T {
634
+ return entries.reduce((highest, current) => {
635
+ return this.compareSemver(current.version, highest.version) > 0
636
+ ? current
637
+ : highest;
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Compare two semver strings. Returns > 0 if a > b, < 0 if a < b, 0 if equal.
643
+ */
644
+ private compareSemver(a: string, b: string): number {
645
+ const aParts = a.split('.').map(Number);
646
+ const bParts = b.split('.').map(Number);
647
+
648
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
649
+ const aVal = aParts[i] ?? 0;
650
+ const bVal = bParts[i] ?? 0;
651
+ if (aVal !== bVal) return aVal - bVal;
652
+ }
653
+ return 0;
654
+ }
655
+ }