@kadi.build/core 0.3.4 → 0.5.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,493 @@
1
+ /**
2
+ * Lock file resolution for kadi-core v0.1.0
3
+ *
4
+ * This module handles reading agent-lock.json and resolving ability paths.
5
+ * The lock file is generated by kadi-install and contains:
6
+ * - Exact versions of installed abilities
7
+ * - Paths where abilities are installed
8
+ * - Integrity hashes for verification
9
+ *
10
+ * Flow:
11
+ * 1. Find project root (where agent.json lives)
12
+ * 2. Read agent-lock.json from project root
13
+ * 3. Look up ability by name to get its installed path
14
+ */
15
+
16
+ import { readFile } from 'fs/promises';
17
+ import { join, dirname } from 'path';
18
+ import type { AgentLockFile, AbilityLockEntry, ResolvedScript } from './types.js';
19
+ import { KadiError } from './errors.js';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════
22
+ // PROJECT ROOT DISCOVERY
23
+ // ═══════════════════════════════════════════════════════════════════════
24
+
25
+ /**
26
+ * Find the project root by walking up the directory tree.
27
+ * Looks for agent.json, which marks the root of a KADI project.
28
+ *
29
+ * @param startDir - Directory to start searching from (default: process.cwd())
30
+ * @returns Absolute path to the project root
31
+ * @throws KadiError if no agent.json is found
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const root = await findProjectRoot();
36
+ * // '/Users/kassi/my-project'
37
+ *
38
+ * const root = await findProjectRoot('/Users/kassi/my-project/src/deep/folder');
39
+ * // '/Users/kassi/my-project'
40
+ * ```
41
+ */
42
+ export async function findProjectRoot(startDir: string = process.cwd()): Promise<string> {
43
+ let current = startDir;
44
+
45
+ // Walk up the directory tree looking for agent.json
46
+ while (current !== dirname(current)) {
47
+ const agentJsonPath = join(current, 'agent.json');
48
+
49
+ try {
50
+ // Try to read agent.json - if it exists, this is the project root
51
+ await readFile(agentJsonPath);
52
+ return current;
53
+ } catch {
54
+ // File doesn't exist, try parent directory
55
+ current = dirname(current);
56
+ }
57
+ }
58
+
59
+ // Reached filesystem root without finding agent.json
60
+ throw new KadiError(
61
+ 'Could not find project root',
62
+ 'PROJECT_ROOT_NOT_FOUND',
63
+ {
64
+ searched: startDir,
65
+ hint: 'Make sure you are in a KADI project directory with an agent.json file',
66
+ }
67
+ );
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════
71
+ // LOCK FILE READING
72
+ // ═══════════════════════════════════════════════════════════════════════
73
+
74
+ /**
75
+ * Read and parse agent-lock.json from a project root.
76
+ *
77
+ * @param projectRoot - Absolute path to the project root
78
+ * @returns Parsed lock file contents
79
+ * @throws KadiError if lock file doesn't exist or is invalid
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * const lockFile = await readLockFile('/Users/kassi/my-project');
84
+ * console.log(lockFile.abilities);
85
+ * // { 'calculator@1.0.0': { resolved: 'abilities/calculator@1.0.0', ... } }
86
+ * ```
87
+ */
88
+ export async function readLockFile(projectRoot: string): Promise<AgentLockFile> {
89
+ const lockPath = join(projectRoot, 'agent-lock.json');
90
+
91
+ let content: string;
92
+ try {
93
+ content = await readFile(lockPath, 'utf-8');
94
+ } catch (error) {
95
+ throw new KadiError(
96
+ 'agent-lock.json not found',
97
+ 'LOCKFILE_NOT_FOUND',
98
+ {
99
+ searched: lockPath,
100
+ hint: 'Run `kadi install` to generate the lock file',
101
+ alternative: 'Or specify an explicit path when loading abilities',
102
+ }
103
+ );
104
+ }
105
+
106
+ try {
107
+ return JSON.parse(content) as AgentLockFile;
108
+ } catch (error) {
109
+ throw new KadiError(
110
+ 'Failed to parse agent-lock.json',
111
+ 'LOCKFILE_PARSE_ERROR',
112
+ {
113
+ path: lockPath,
114
+ reason: error instanceof Error ? error.message : 'Invalid JSON',
115
+ hint: 'The lock file may be corrupted. Try running `kadi install` again',
116
+ }
117
+ );
118
+ }
119
+ }
120
+
121
+ // ═══════════════════════════════════════════════════════════════════════
122
+ // ABILITY LOOKUP
123
+ // ═══════════════════════════════════════════════════════════════════════
124
+
125
+ /**
126
+ * Find an ability entry in the lock file by name.
127
+ * Keys in the lock file are "name@version", this function matches by name only.
128
+ *
129
+ * @param lockFile - The parsed lock file
130
+ * @param name - Ability name to find (without version)
131
+ * @returns The ability entry, or null if not found
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const entry = findAbilityEntry(lockFile, 'calculator');
136
+ * // Returns entry for 'calculator@1.0.0' (or whatever version is installed)
137
+ * ```
138
+ */
139
+ export function findAbilityEntry(
140
+ lockFile: AgentLockFile,
141
+ name: string
142
+ ): AbilityLockEntry | null {
143
+ // Lock file keys are "name@version", we need to match by name only
144
+ for (const [key, entry] of Object.entries(lockFile.abilities)) {
145
+ // Find the last @ to split name from version
146
+ // (handles scoped packages like @kadi.build/ability@1.0.0)
147
+ const lastAtIndex = key.lastIndexOf('@');
148
+
149
+ if (lastAtIndex === -1) {
150
+ // No @ in key - shouldn't happen, but check anyway
151
+ if (key === name) {
152
+ return entry;
153
+ }
154
+ continue;
155
+ }
156
+
157
+ const abilityName = key.substring(0, lastAtIndex);
158
+ if (abilityName === name) {
159
+ return entry;
160
+ }
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * Get all installed ability names from the lock file.
168
+ * Useful for error messages listing available abilities.
169
+ *
170
+ * @param lockFile - The parsed lock file
171
+ * @returns Array of ability names (without versions)
172
+ */
173
+ export function getInstalledAbilityNames(lockFile: AgentLockFile): string[] {
174
+ const names: string[] = [];
175
+
176
+ for (const key of Object.keys(lockFile.abilities)) {
177
+ const lastAtIndex = key.lastIndexOf('@');
178
+ if (lastAtIndex !== -1) {
179
+ names.push(key.substring(0, lastAtIndex));
180
+ }
181
+ }
182
+
183
+ return names;
184
+ }
185
+
186
+ // ═══════════════════════════════════════════════════════════════════════
187
+ // PATH RESOLUTION
188
+ // ═══════════════════════════════════════════════════════════════════════
189
+
190
+ /**
191
+ * Resolve the absolute path to an installed ability.
192
+ * This is the main function used by loadNative() and loadStdio().
193
+ *
194
+ * Flow:
195
+ * 1. Find project root (if not provided)
196
+ * 2. Read agent-lock.json
197
+ * 3. Look up ability by name
198
+ * 4. Return absolute path to the ability
199
+ *
200
+ * @param name - Ability name to resolve
201
+ * @param projectRoot - Project root (will be auto-detected if not provided)
202
+ * @returns Absolute path to the ability directory
203
+ * @throws KadiError if ability not found
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * const path = await resolveAbilityPath('calculator');
208
+ * // '/Users/kassi/my-project/abilities/calculator@1.0.0'
209
+ * ```
210
+ */
211
+ export async function resolveAbilityPath(
212
+ name: string,
213
+ projectRoot?: string
214
+ ): Promise<string> {
215
+ // Find project root if not provided
216
+ const root = projectRoot ?? await findProjectRoot();
217
+
218
+ // Read lock file
219
+ const lockFile = await readLockFile(root);
220
+
221
+ // Find ability entry
222
+ const entry = findAbilityEntry(lockFile, name);
223
+
224
+ if (!entry) {
225
+ // Build helpful error message with available abilities
226
+ const available = getInstalledAbilityNames(lockFile);
227
+
228
+ throw new KadiError(
229
+ `Ability "${name}" not found in agent-lock.json`,
230
+ 'ABILITY_NOT_FOUND',
231
+ {
232
+ abilityName: name,
233
+ searched: join(root, 'agent-lock.json'),
234
+ available: available.length > 0 ? available : undefined,
235
+ hint: `Run \`kadi install ${name}\` to install it`,
236
+ alternative: 'Or specify an explicit path: { path: "./path/to/ability" }',
237
+ }
238
+ );
239
+ }
240
+
241
+ // Build absolute path from project root + relative path in lock file
242
+ return join(root, entry.resolved);
243
+ }
244
+
245
+ /**
246
+ * Get full ability entry with resolved path.
247
+ * Returns more information than resolveAbilityPath() for cases where
248
+ * you need the version, type, or other metadata.
249
+ *
250
+ * @param name - Ability name to resolve
251
+ * @param projectRoot - Project root (will be auto-detected if not provided)
252
+ * @returns Ability entry with absolute path added
253
+ */
254
+ export async function resolveAbilityEntry(
255
+ name: string,
256
+ projectRoot?: string
257
+ ): Promise<AbilityLockEntry & { absolutePath: string }> {
258
+ const root = projectRoot ?? await findProjectRoot();
259
+ const lockFile = await readLockFile(root);
260
+ const entry = findAbilityEntry(lockFile, name);
261
+
262
+ if (!entry) {
263
+ const available = getInstalledAbilityNames(lockFile);
264
+
265
+ throw new KadiError(
266
+ `Ability "${name}" not found in agent-lock.json`,
267
+ 'ABILITY_NOT_FOUND',
268
+ {
269
+ abilityName: name,
270
+ searched: join(root, 'agent-lock.json'),
271
+ available: available.length > 0 ? available : undefined,
272
+ hint: `Run \`kadi install ${name}\` to install it`,
273
+ alternative: 'Or specify an explicit path: { path: "./path/to/ability" }',
274
+ }
275
+ );
276
+ }
277
+
278
+ return {
279
+ ...entry,
280
+ absolutePath: join(root, entry.resolved),
281
+ };
282
+ }
283
+
284
+ // ═══════════════════════════════════════════════════════════════════════
285
+ // SCRIPT RESOLUTION (for loadStdio)
286
+ // ═══════════════════════════════════════════════════════════════════════
287
+
288
+ /**
289
+ * Parsed script command ready for spawning a child process.
290
+ */
291
+ interface ParsedScript {
292
+ /** The command to execute (e.g., 'python3', 'node') */
293
+ command: string;
294
+
295
+ /** Arguments to pass to the command */
296
+ args: string[];
297
+ }
298
+
299
+ /**
300
+ * Parse a script string into command and arguments.
301
+ *
302
+ * This is a simple parser that splits on whitespace.
303
+ * For most use cases (e.g., "python3 main.py --debug"), this works fine.
304
+ *
305
+ * Note: Does not handle quoted arguments with spaces (e.g., "node 'my file.js'").
306
+ * For complex cases, users should use explicit command/args.
307
+ *
308
+ * @param script - Script string from agent.json (e.g., "python3 main.py --verbose")
309
+ * @returns Parsed command and arguments
310
+ *
311
+ * @example
312
+ * ```typescript
313
+ * parseScriptCommand('python3 main.py --debug')
314
+ * // → { command: 'python3', args: ['main.py', '--debug'] }
315
+ *
316
+ * parseScriptCommand('node dist/server.js')
317
+ * // → { command: 'node', args: ['dist/server.js'] }
318
+ * ```
319
+ */
320
+ export function parseScriptCommand(script: string): ParsedScript {
321
+ // Check for quotes — we can't handle these properly with simple split
322
+ // Users with complex commands should use explicit command/args
323
+ if (script.includes("'") || script.includes('"')) {
324
+ throw new KadiError(
325
+ 'Script contains quotes which are not supported',
326
+ 'INVALID_CONFIG',
327
+ {
328
+ script,
329
+ hint: 'Scripts with quoted arguments cannot be parsed. Provide command and args separately.',
330
+ example: '{ command: "node", args: ["my file.js"] }',
331
+ }
332
+ );
333
+ }
334
+
335
+ // Trim and split on whitespace
336
+ const parts = script.trim().split(/\s+/);
337
+
338
+ // Extract command (first element)
339
+ const command = parts[0];
340
+
341
+ if (!command) {
342
+ throw new KadiError(
343
+ 'Script is empty',
344
+ 'INVALID_CONFIG',
345
+ {
346
+ script,
347
+ hint: 'The script should contain a command (e.g., "python3 main.py")',
348
+ }
349
+ );
350
+ }
351
+
352
+ return {
353
+ command,
354
+ args: parts.slice(1),
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Minimal agent.json structure for script resolution.
360
+ * We only need the scripts section.
361
+ */
362
+ interface AbilityAgentJson {
363
+ name?: string;
364
+ scripts?: Record<string, string>;
365
+ }
366
+
367
+ /**
368
+ * Read an ability's agent.json file.
369
+ *
370
+ * @param abilityPath - Absolute path to the ability directory
371
+ * @returns Parsed agent.json contents
372
+ * @throws KadiError if agent.json doesn't exist or is invalid
373
+ *
374
+ * @example
375
+ * ```typescript
376
+ * const agentJson = await readAbilityAgentJson('/path/to/ability');
377
+ * console.log(agentJson.scripts?.start);
378
+ * // → "python3 main.py"
379
+ * ```
380
+ */
381
+ export async function readAbilityAgentJson(abilityPath: string): Promise<AbilityAgentJson> {
382
+ const agentJsonPath = join(abilityPath, 'agent.json');
383
+
384
+ let content: string;
385
+ try {
386
+ content = await readFile(agentJsonPath, 'utf-8');
387
+ } catch (error) {
388
+ throw new KadiError(
389
+ `Ability is missing agent.json`,
390
+ 'ABILITY_LOAD_FAILED',
391
+ {
392
+ path: agentJsonPath,
393
+ hint: 'The ability directory must contain an agent.json file with a scripts section',
394
+ }
395
+ );
396
+ }
397
+
398
+ try {
399
+ return JSON.parse(content) as AbilityAgentJson;
400
+ } catch (error) {
401
+ throw new KadiError(
402
+ `Failed to parse ability's agent.json`,
403
+ 'ABILITY_LOAD_FAILED',
404
+ {
405
+ path: agentJsonPath,
406
+ reason: error instanceof Error ? error.message : 'Invalid JSON',
407
+ }
408
+ );
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Resolve a script command for an ability.
414
+ *
415
+ * This is the main function used by loadStdio() when in script resolution mode.
416
+ *
417
+ * Flow:
418
+ * 1. Resolve ability path from agent-lock.json
419
+ * 2. Read ability's agent.json
420
+ * 3. Get the requested script (default: 'start')
421
+ * 4. Parse the script into command and args
422
+ * 5. Resolve paths relative to ability directory
423
+ *
424
+ * @param name - Ability name to resolve
425
+ * @param scriptName - Which script to use (default: 'start')
426
+ * @param projectRoot - Project root (auto-detected if not provided)
427
+ * @returns Command, args, and working directory for spawning the process
428
+ *
429
+ * @example
430
+ * ```typescript
431
+ * // Uses scripts.start by default
432
+ * const { command, args, cwd } = await resolveAbilityScript('image-processor');
433
+ * // → { command: 'python3', args: ['main.py'], cwd: '/path/to/ability' }
434
+ *
435
+ * // Use a specific script
436
+ * const { command, args, cwd } = await resolveAbilityScript('image-processor', 'dev');
437
+ * // → { command: 'python3', args: ['main.py', '--debug'], cwd: '/path/to/ability' }
438
+ * ```
439
+ */
440
+ export async function resolveAbilityScript(
441
+ name: string,
442
+ scriptName: string = 'start',
443
+ projectRoot?: string
444
+ ): Promise<ResolvedScript> {
445
+ // Step 1: Resolve ability path from agent-lock.json
446
+ const abilityPath = await resolveAbilityPath(name, projectRoot);
447
+
448
+ // Step 2: Read ability's agent.json
449
+ const agentJson = await readAbilityAgentJson(abilityPath);
450
+
451
+ // Step 3: Get the requested script
452
+ const scripts = agentJson.scripts;
453
+ if (!scripts) {
454
+ throw new KadiError(
455
+ `Ability "${name}" has no scripts section in agent.json`,
456
+ 'INVALID_CONFIG',
457
+ {
458
+ abilityName: name,
459
+ agentJsonPath: join(abilityPath, 'agent.json'),
460
+ hint: 'Add a "scripts" section with a "start" script to agent.json',
461
+ example: '{ "scripts": { "start": "python3 main.py" } }',
462
+ }
463
+ );
464
+ }
465
+
466
+ const script = scripts[scriptName];
467
+ if (!script) {
468
+ // Build helpful error with available scripts
469
+ const availableScripts = Object.keys(scripts).filter(k => scripts[k]); // Filter out empty scripts
470
+
471
+ throw new KadiError(
472
+ `Script "${scriptName}" not found in ability "${name}"`,
473
+ 'INVALID_CONFIG',
474
+ {
475
+ abilityName: name,
476
+ requestedScript: scriptName,
477
+ availableScripts: availableScripts.length > 0 ? availableScripts : undefined,
478
+ hint: availableScripts.length > 0
479
+ ? `Available scripts: ${availableScripts.join(', ')}`
480
+ : 'Add scripts to the ability\'s agent.json',
481
+ }
482
+ );
483
+ }
484
+
485
+ // Step 4: Parse the script into command and args
486
+ const parsed = parseScriptCommand(script);
487
+
488
+ // Step 5: Return with ability path as working directory
489
+ return {
490
+ ...parsed,
491
+ cwd: abilityPath,
492
+ };
493
+ }