@kadi.build/core 0.12.0 → 0.14.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.
package/src/config.ts ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Configuration loader for KADI agents and abilities.
3
+ *
4
+ * Provides standardized config.yml discovery with a 2-tier fallback:
5
+ * 1. **Project-level** — walk up from startDir looking for config.yml
6
+ * 2. **Global-level** — ~/.kadi/config.yml (shared KADI infrastructure settings)
7
+ *
8
+ * Resolution order per-field (highest-priority first):
9
+ * 1. Environment variables (PREFIX_KEY)
10
+ * 2. Project config.yml section
11
+ * 3. Global config.yml section (~/.kadi/config.yml)
12
+ * 4. Built-in defaults
13
+ *
14
+ * Secrets (API keys, tokens) should NEVER appear in config.yml.
15
+ * Use secret-ability / kadi-secret for credentials.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { loadConfig } from '@kadi.build/core';
20
+ *
21
+ * const { config, configPath, source } = loadConfig({
22
+ * section: 'memory',
23
+ * envPrefix: 'MEMORY',
24
+ * defaults: { database: 'kadi_memory', embedding_model: 'text-embedding-3-small' },
25
+ * });
26
+ * ```
27
+ *
28
+ * TypeScript equivalent of: kadi-core-py/src/kadi/config.py
29
+ */
30
+
31
+ import * as fs from 'node:fs';
32
+ import * as path from 'node:path';
33
+ import * as os from 'node:os';
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════
36
+ // YAML import — optional runtime dependency
37
+ // ═══════════════════════════════════════════════════════════════════════
38
+
39
+ /**
40
+ * We lazy-import js-yaml so that kadi-core does NOT require it as a hard
41
+ * dependency. Consumers that use loadConfig() need js-yaml installed;
42
+ * everything else in kadi-core works without it.
43
+ *
44
+ * In practice every KADI agent/ability already has js-yaml, so this is
45
+ * a formality rather than a real constraint.
46
+ */
47
+ let yaml: { load(content: string): unknown } | null = null;
48
+ let yamlResolved = false;
49
+
50
+ function requireYaml(): typeof yaml {
51
+ if (!yamlResolved) {
52
+ yamlResolved = true;
53
+ try {
54
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
55
+ yaml = require('js-yaml') as typeof yaml;
56
+ } catch {
57
+ try {
58
+ // ESM fallback — createRequire for pure-ESM environments
59
+ const { createRequire } = require('node:module');
60
+ const localRequire = createRequire(process.cwd() + '/');
61
+ yaml = localRequire('js-yaml') as typeof yaml;
62
+ } catch {
63
+ yaml = null;
64
+ }
65
+ }
66
+ }
67
+ return yaml;
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════
71
+ // Constants
72
+ // ═══════════════════════════════════════════════════════════════════════
73
+
74
+ /** Default config filename. */
75
+ const DEFAULT_FILENAME = 'config.yml';
76
+
77
+ /** Global KADI config directory. */
78
+ const GLOBAL_KADI_DIR = path.join(os.homedir(), '.kadi');
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════
81
+ // Types
82
+ // ═══════════════════════════════════════════════════════════════════════
83
+
84
+ /** How the config file was discovered. */
85
+ export type ConfigSource = 'project' | 'global' | 'default';
86
+
87
+ /** Options for {@link loadConfig}. */
88
+ export interface LoadConfigOptions {
89
+ /**
90
+ * YAML section key to extract (e.g. `'tunnel'`, `'memory'`, `'kadi_agent'`).
91
+ * The top-level key in config.yml whose value becomes the config object.
92
+ */
93
+ section: string;
94
+
95
+ /**
96
+ * Directory to start the upward walk from.
97
+ * @default process.cwd()
98
+ */
99
+ startDir?: string;
100
+
101
+ /**
102
+ * Config filename to search for.
103
+ * @default 'config.yml'
104
+ */
105
+ filename?: string;
106
+
107
+ /**
108
+ * Environment variable prefix for automatic overrides.
109
+ *
110
+ * When set, loadConfig will scan `process.env` for keys starting with
111
+ * `PREFIX_` and merge them into the config (highest priority).
112
+ *
113
+ * Mapping: `PREFIX_KEY_NAME` → config field `key_name` (lowercased).
114
+ *
115
+ * @example envPrefix: 'MEMORY' → MEMORY_DATABASE overrides config.database
116
+ */
117
+ envPrefix?: string;
118
+
119
+ /**
120
+ * Built-in defaults (lowest priority).
121
+ * These are used when neither env vars nor config.yml provide a value.
122
+ */
123
+ defaults?: Record<string, unknown>;
124
+ }
125
+
126
+ /** Result of {@link loadConfig}. */
127
+ export interface ConfigResult<T = Record<string, unknown>> {
128
+ /** Merged config object: env > project config.yml > global config.yml > defaults. */
129
+ config: T;
130
+ /** Absolute path to the config.yml that was loaded, or null if none found. */
131
+ configPath: string | null;
132
+ /** Where the primary config was found. */
133
+ source: ConfigSource;
134
+ }
135
+
136
+ // ═══════════════════════════════════════════════════════════════════════
137
+ // File discovery
138
+ // ═══════════════════════════════════════════════════════════════════════
139
+
140
+ /**
141
+ * Walk up the directory tree looking for a file.
142
+ *
143
+ * @param filename - File to search for (default: `'config.yml'`)
144
+ * @param startDir - Directory to start from (default: `process.cwd()`)
145
+ * @returns Absolute path if found, `null` otherwise.
146
+ */
147
+ export function findConfigFile(
148
+ filename: string = DEFAULT_FILENAME,
149
+ startDir?: string,
150
+ ): string | null {
151
+ let dir = path.resolve(startDir ?? process.cwd());
152
+
153
+ // eslint-disable-next-line no-constant-condition
154
+ while (true) {
155
+ const candidate = path.join(dir, filename);
156
+ try {
157
+ if (fs.statSync(candidate).isFile()) {
158
+ return candidate;
159
+ }
160
+ } catch {
161
+ // Not found, keep walking
162
+ }
163
+ const parent = path.dirname(dir);
164
+ if (parent === dir) break; // Filesystem root
165
+ dir = parent;
166
+ }
167
+
168
+ return null;
169
+ }
170
+
171
+ /**
172
+ * Find the global KADI config file.
173
+ *
174
+ * @param filename - Config filename (default: `'config.yml'`)
175
+ * @returns Absolute path to `~/.kadi/<filename>` if it exists, `null` otherwise.
176
+ */
177
+ export function findGlobalConfigFile(
178
+ filename: string = DEFAULT_FILENAME,
179
+ ): string | null {
180
+ const globalPath = path.join(GLOBAL_KADI_DIR, filename);
181
+ try {
182
+ if (fs.statSync(globalPath).isFile()) {
183
+ return globalPath;
184
+ }
185
+ } catch {
186
+ // Not found
187
+ }
188
+ return null;
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════════════════
192
+ // Section extraction
193
+ // ═══════════════════════════════════════════════════════════════════════
194
+
195
+ /**
196
+ * Parse a YAML file and extract a top-level section.
197
+ */
198
+ function readSection(
199
+ filePath: string,
200
+ section: string,
201
+ ): Record<string, unknown> {
202
+ const lib = requireYaml();
203
+ if (!lib) return {};
204
+
205
+ try {
206
+ const content = fs.readFileSync(filePath, 'utf8');
207
+ const parsed = lib.load(content) as Record<string, unknown> | null;
208
+ if (!parsed || typeof parsed !== 'object') return {};
209
+ const sectionData = parsed[section];
210
+ if (!sectionData || typeof sectionData !== 'object') return {};
211
+ return sectionData as Record<string, unknown>;
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ // ═══════════════════════════════════════════════════════════════════════
218
+ // Environment variable overlay
219
+ // ═══════════════════════════════════════════════════════════════════════
220
+
221
+ /**
222
+ * Build an overrides object from environment variables matching a prefix.
223
+ *
224
+ * `PREFIX_KEY_NAME=value` → `{ key_name: value }`
225
+ *
226
+ * Numeric strings are auto-coerced to numbers.
227
+ * `'true'` / `'false'` are coerced to booleans.
228
+ */
229
+ function envOverrides(
230
+ prefix: string,
231
+ knownKeys?: Set<string>,
232
+ ): Record<string, unknown> {
233
+ const result: Record<string, unknown> = {};
234
+ const prefixUpper = prefix.toUpperCase() + '_';
235
+
236
+ for (const [envKey, envVal] of Object.entries(process.env)) {
237
+ if (!envKey.startsWith(prefixUpper) || envVal === undefined) continue;
238
+
239
+ const configKey = envKey.slice(prefixUpper.length).toLowerCase();
240
+
241
+ // Skip if we have a known-keys set and this key isn't in it
242
+ if (knownKeys && !knownKeys.has(configKey)) continue;
243
+
244
+ result[configKey] = coerce(envVal);
245
+ }
246
+
247
+ return result;
248
+ }
249
+
250
+ /**
251
+ * Coerce a string environment value to its natural JS type.
252
+ */
253
+ function coerce(value: string): unknown {
254
+ if (value === 'true') return true;
255
+ if (value === 'false') return false;
256
+ if (value === '') return value;
257
+
258
+ // Attempt numeric coercion only for things that look numeric
259
+ const num = Number(value);
260
+ if (!Number.isNaN(num) && value.trim() !== '') return num;
261
+
262
+ return value;
263
+ }
264
+
265
+ // ═══════════════════════════════════════════════════════════════════════
266
+ // Main API
267
+ // ═══════════════════════════════════════════════════════════════════════
268
+
269
+ /**
270
+ * Load configuration for a KADI agent or ability.
271
+ *
272
+ * Resolution order per-field (highest-priority first):
273
+ * 1. Environment variables (`envPrefix` + `_KEY`)
274
+ * 2. Project `config.yml` section (walk-up from startDir)
275
+ * 3. Global `~/.kadi/config.yml` section
276
+ * 4. Built-in `defaults`
277
+ *
278
+ * @typeParam T - The shape of the merged config object.
279
+ * @param options - Configuration loading options.
280
+ * @returns Merged config with metadata about where it was found.
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * const { config } = loadConfig<TunnelConfig>({
285
+ * section: 'tunnel',
286
+ * envPrefix: 'KADI_TUNNEL',
287
+ * defaults: { default_service: 'kadi', server_port: 7000 },
288
+ * });
289
+ * ```
290
+ */
291
+ export function loadConfig<T = Record<string, unknown>>(
292
+ options: LoadConfigOptions,
293
+ ): ConfigResult<T> {
294
+ const {
295
+ section,
296
+ startDir,
297
+ filename = DEFAULT_FILENAME,
298
+ envPrefix,
299
+ defaults = {},
300
+ } = options;
301
+
302
+ // ── Layer 4: Defaults ──────────────────────────────────────────────
303
+ let merged: Record<string, unknown> = { ...defaults };
304
+
305
+ // ── Layer 3: Global config.yml (~/.kadi/config.yml) ────────────────
306
+ let configPath: string | null = null;
307
+ let source: ConfigSource = 'default';
308
+
309
+ const globalPath = findGlobalConfigFile(filename);
310
+ if (globalPath) {
311
+ const globalSection = readSection(globalPath, section);
312
+ if (Object.keys(globalSection).length > 0) {
313
+ merged = shallowMerge(merged, globalSection);
314
+ configPath = globalPath;
315
+ source = 'global';
316
+ }
317
+ }
318
+
319
+ // ── Layer 2: Project config.yml (walk-up) ──────────────────────────
320
+ const projectPath = findConfigFile(filename, startDir);
321
+ if (projectPath) {
322
+ // Only use project config if it's not the same file as global
323
+ const isDistinct = !globalPath || path.resolve(projectPath) !== path.resolve(globalPath);
324
+ if (isDistinct) {
325
+ const projectSection = readSection(projectPath, section);
326
+ if (Object.keys(projectSection).length > 0) {
327
+ merged = shallowMerge(merged, projectSection);
328
+ configPath = projectPath;
329
+ source = 'project';
330
+ }
331
+ }
332
+ }
333
+
334
+ // ── Layer 1: Environment variables (highest priority) ──────────────
335
+ if (envPrefix) {
336
+ const env = envOverrides(envPrefix);
337
+ if (Object.keys(env).length > 0) {
338
+ merged = shallowMerge(merged, env);
339
+ }
340
+ }
341
+
342
+ return {
343
+ config: merged as T,
344
+ configPath,
345
+ source,
346
+ };
347
+ }
348
+
349
+ // ═══════════════════════════════════════════════════════════════════════
350
+ // Helpers
351
+ // ═══════════════════════════════════════════════════════════════════════
352
+
353
+ /**
354
+ * Shallow merge — source keys overwrite target keys.
355
+ * Only copies defined (non-undefined) values.
356
+ */
357
+ function shallowMerge(
358
+ target: Record<string, unknown>,
359
+ source: Record<string, unknown>,
360
+ ): Record<string, unknown> {
361
+ const result = { ...target };
362
+ for (const [key, val] of Object.entries(source)) {
363
+ if (val !== undefined) {
364
+ result[key] = val;
365
+ }
366
+ }
367
+ return result;
368
+ }
package/src/index.ts CHANGED
@@ -122,6 +122,10 @@ export { ProcessManager, ManagedProcess } from './process-manager.js';
122
122
  // Dot-path utilities
123
123
  export { getByPath, setByPath, deleteByPath, deepMerge } from './utils.js';
124
124
 
125
+ // Config loader (walk-up config.yml + ~/.kadi/ global fallback)
126
+ export { loadConfig, findConfigFile, findGlobalConfigFile } from './config.js';
127
+ export type { LoadConfigOptions, ConfigResult, ConfigSource } from './config.js';
128
+
125
129
  // Stdio framing (for advanced use cases — building custom bridge protocols)
126
130
  export { StdioMessageReader, StdioMessageWriter } from './stdio-framing.js';
127
131