@hilbras/omninode 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # OmniNode
2
2
 
3
+ [![CI](https://github.com/Hilbras/OmniNode/actions/workflows/ci.yml/badge.svg)](https://github.com/Hilbras/OmniNode/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@hilbras/omninode)](https://www.npmjs.com/package/@hilbras/omninode)
5
+ [![npm downloads](https://img.shields.io/npm/dm/@hilbras/omninode)](https://www.npmjs.com/package/@hilbras/omninode)
6
+ [![Node.js support](https://img.shields.io/node/v/@hilbras/omninode)](https://www.npmjs.com/package/@hilbras/omninode)
7
+ [![License: MIT](https://img.shields.io/github/license/Hilbras/OmniNode)](LICENSE)
8
+ [![Roadmap phase](https://img.shields.io/badge/phase-0_%2F_11-Foundation-8A2BE2)](docs/DEVELOPMENT_PLAN.md)
9
+
3
10
  **OmniNode** is a provider-agnostic multi-AI orchestration platform. It coordinates
4
11
  CLI-based AI agents, AI providers, persistent memory, roles, reports and planning
5
12
  into a unified execution system — so multiple AI systems can collaborate on complex
@@ -11,23 +18,31 @@ existing intelligence; it does not try to become another model.
11
18
 
12
19
  ## Status
13
20
 
14
- **v0.1.0 — Phase 0: Foundation** (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
21
+ **v0.3.0 — Phase 2: OmniHilbras Integration** (see [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md)
15
22
  for the full plan and roadmap).
16
23
 
17
24
  What exists today:
18
25
 
19
26
  - Core type contracts: provider, model, agent, role, task, report, pipeline,
20
27
  protocol, memory (`src/types/`)
28
+ - **Provider system** (`src/providers/`, `src/registry/`):
29
+ - OpenAI-compatible adapter — works with OpenAI, OpenRouter, local runtimes
30
+ (Ollama, LM Studio, vLLM) and custom gateways exposing `/models` and `/chat/completions`
31
+ - Model discovery with normalization into the model registry (`provider:model` keys)
32
+ - Health checks: connectivity, authentication and server-error classification
33
+ - Authentication from environment variables only — keys never touch config files
34
+ - Chat completions (`IChatProvider`) — the foundation for the planner phases
35
+ - Provider factory mapping config to adapters; `omnihilbras` gets a
36
+ dedicated adapter implementing the plan's connect flow —
37
+ Connect → Authenticate → Fetch Models → Validate Models → Register
21
38
  - Typed error hierarchy (`src/errors/`)
22
39
  - Leveled logger with pluggable sink (`src/logger/`)
23
40
  - Configuration system: `omninode.yaml` with `${ENV_VAR}` expansion and strict
24
41
  validation (`src/config/`)
25
- - CLI with `init`, `provider list`, `agent list`, `role list`, and honest
26
- stubs for the phase-dependent commands (`src/cli/`)
42
+ - CLI: `init`, `provider add/list/test`, `models`, `agent list`, `role list`,
43
+ and honest stubs for the phase-dependent commands (`src/cli/`)
27
44
  - Test infrastructure (Vitest) and CI (GitHub Actions)
28
45
 
29
- Nothing here depends on any provider or agent — that is the point of Phase 0.
30
-
31
46
  ## Requirements
32
47
 
33
48
  - Node.js >= 20
@@ -46,8 +61,17 @@ npm run omninode -- --help
46
61
  Start a project:
47
62
 
48
63
  ```bash
49
- omninode init # writes omninode.yaml in the current directory
50
- omninode provider list # validates and lists configured providers
64
+ omninode init # writes omninode.yaml in the current directory
65
+
66
+ # Register a provider and verify it end to end
67
+ omninode provider add --name my-gateway --type openai-compatible \
68
+ --base-url https://example.com/v1 --api-key-env-var MY_API_KEY
69
+ omninode provider test my-gateway
70
+
71
+ # Discover models into the registry
72
+ omninode models
73
+ omninode models my-gateway --capability chat
74
+
51
75
  omninode agent list
52
76
  omninode role list
53
77
  ```
@@ -81,9 +105,9 @@ Phases 0–11 are laid out in [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.m
81
105
 
82
106
  | Version | Milestone |
83
107
  | --- | --- |
84
- | v0.1.0 | Foundation (this release) |
108
+ | v0.1.0 | Foundation |
85
109
  | v0.2.0 | Provider System |
86
- | v0.3.0 | OmniHilbras Integration (optional, like every provider) |
110
+ | **v0.3.0** | **OmniHilbras Integration (current)** |
87
111
  | v0.4.0 | Agent System |
88
112
  | v0.5.0 | Roles & Tasks |
89
113
  | v0.6.0 | Pipeline Engine |
@@ -105,6 +129,24 @@ npm install -g @hilbras/omninode
105
129
 
106
130
  Or use it as a library: `npm install @hilbras/omninode`.
107
131
 
132
+ ## Connecting OmniHilbras
133
+
134
+ OmniHilbras is one provider source among many — always optional. The core
135
+ architectural rule stands: OmniNode runs with **zero Hilbras dependencies**.
136
+
137
+ The dedicated adapter implements the plan's connect flow on top of the
138
+ gateway's OpenAI-compatible API surface and tags discovered models with
139
+ `gateway: omnihilbras` metadata:
140
+
141
+ ```bash
142
+ omninode provider add --name omnihilbras --type omnihilbras \
143
+ --base-url https://your-omnihbras-host/v1 \
144
+ --api-key-env-var OMNIHILBRAS_API_KEY
145
+
146
+ omninode provider test omnihilbras --connect # full connect flow + registration
147
+ omninode models omnihilbras # list the gateway's models
148
+ ```
149
+
108
150
  ## License
109
151
 
110
152
  [MIT](LICENSE)
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "0.1.0";
9
+ var OMNINODE_VERSION = "0.3.0";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -24,6 +24,11 @@ var ConfigError = class extends OmniNodeError {
24
24
  super(code, message, options);
25
25
  }
26
26
  };
27
+ var ProviderError = class extends OmniNodeError {
28
+ constructor(code, message, options = {}) {
29
+ super(code, message, options);
30
+ }
31
+ };
27
32
  function isOmniNodeError(value) {
28
33
  return value instanceof OmniNodeError;
29
34
  }
@@ -238,6 +243,380 @@ function registerProjectCommands(program) {
238
243
  project.command("init").description("Alias of `omninode init`.").option("-f, --force", "Overwrite an existing omninode.yaml.").option("-n, --name <name>", "Project name to write into the configuration.", "My Project").action(async (options) => runInit(options));
239
244
  }
240
245
 
246
+ // src/cli/commands/providers.ts
247
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
248
+ import { parseDocument } from "yaml";
249
+
250
+ // src/providers/auth.ts
251
+ function resolveApiKey(config, env = process.env) {
252
+ if (!config.apiKeyEnvVar) return void 0;
253
+ const value = env[config.apiKeyEnvVar];
254
+ if (!value) {
255
+ throw new ProviderError(
256
+ "PROVIDER_AUTH_FAILED",
257
+ `Environment variable "${config.apiKeyEnvVar}" (referenced by provider "${config.name}") is not set.`
258
+ );
259
+ }
260
+ return value;
261
+ }
262
+ function authHeaders(config, env = process.env) {
263
+ const key = resolveApiKey(config, env);
264
+ const base = { ...config.headers ?? {} };
265
+ return key ? { ...base, authorization: `Bearer ${key}` } : base;
266
+ }
267
+
268
+ // src/providers/http.ts
269
+ var DEFAULT_TIMEOUT_MS = 3e4;
270
+ async function requestJson(url, options = {}) {
271
+ const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
272
+ let response;
273
+ try {
274
+ response = await fetch(url, {
275
+ method,
276
+ headers: {
277
+ accept: "application/json",
278
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
279
+ ...headers
280
+ },
281
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
282
+ signal: AbortSignal.timeout(timeoutMs)
283
+ });
284
+ } catch (error) {
285
+ const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
286
+ throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
287
+ cause: error
288
+ });
289
+ }
290
+ let parsed;
291
+ try {
292
+ parsed = await response.json();
293
+ } catch (error) {
294
+ throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
295
+ cause: error,
296
+ details: { status: response.status }
297
+ });
298
+ }
299
+ return { status: response.status, body: parsed };
300
+ }
301
+
302
+ // src/logger/index.ts
303
+ var LEVEL_ORDER = {
304
+ debug: 10,
305
+ info: 20,
306
+ warn: 30,
307
+ error: 40
308
+ };
309
+ var ConsoleLogSink = class {
310
+ constructor(stream = process.stderr) {
311
+ this.stream = stream;
312
+ }
313
+ stream;
314
+ write(_level, line) {
315
+ this.stream.write(line + "\n");
316
+ }
317
+ };
318
+ var Logger = class _Logger {
319
+ level;
320
+ sink;
321
+ context;
322
+ constructor(options = {}) {
323
+ this.level = options.level ?? "info";
324
+ this.sink = options.sink ?? new ConsoleLogSink();
325
+ this.context = options.context ?? {};
326
+ }
327
+ child(context) {
328
+ return new _Logger({
329
+ level: this.level,
330
+ sink: this.sink,
331
+ context: { ...this.context, ...context }
332
+ });
333
+ }
334
+ debug(message, data) {
335
+ this.log("debug", message, data);
336
+ }
337
+ info(message, data) {
338
+ this.log("info", message, data);
339
+ }
340
+ warn(message, data) {
341
+ this.log("warn", message, data);
342
+ }
343
+ error(message, data) {
344
+ this.log("error", message, data);
345
+ }
346
+ log(level, message, data) {
347
+ if (LEVEL_ORDER[level] < LEVEL_ORDER[this.level]) return;
348
+ const parts = [(/* @__PURE__ */ new Date()).toISOString(), level.toUpperCase(), message];
349
+ const merged = { ...this.context, ...data };
350
+ if (Object.keys(merged).length > 0) {
351
+ parts.push(JSON.stringify(merged));
352
+ }
353
+ this.sink.write(level, parts.join(" "));
354
+ }
355
+ };
356
+ var logger = new Logger();
357
+
358
+ // src/providers/openai-compatible/index.ts
359
+ var OpenAICompatibleProvider = class {
360
+ config;
361
+ log;
362
+ constructor(config, log = logger) {
363
+ this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
364
+ this.log = log.child({ provider: config.name });
365
+ }
366
+ async listModels() {
367
+ const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
368
+ headers: authHeaders(this.config)
369
+ });
370
+ if (status === 401 || status === 403) {
371
+ throw authFailed(this.config, status);
372
+ }
373
+ if (status !== 200) {
374
+ throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
375
+ }
376
+ const entries = extractModelEntries(body, this.config);
377
+ const models = [];
378
+ let skipped = 0;
379
+ for (const entry2 of entries) {
380
+ if (typeof entry2.id !== "string" || entry2.id.length === 0) {
381
+ skipped += 1;
382
+ continue;
383
+ }
384
+ models.push({
385
+ provider: this.config.name,
386
+ id: entry2.id,
387
+ displayName: entry2.id,
388
+ status: "available",
389
+ metadata: {
390
+ ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
391
+ ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
392
+ }
393
+ });
394
+ }
395
+ if (skipped > 0) {
396
+ this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
397
+ }
398
+ return models;
399
+ }
400
+ async healthCheck() {
401
+ const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
402
+ try {
403
+ const started = Date.now();
404
+ const models = await this.listModels();
405
+ return {
406
+ name: this.config.name,
407
+ type: this.config.type,
408
+ baseUrl: this.config.baseUrl,
409
+ health: "healthy",
410
+ connected: true,
411
+ modelCount: models.length,
412
+ lastCheckedAt,
413
+ message: `Discovered ${models.length} model(s) in ${Date.now() - started}ms.`
414
+ };
415
+ } catch (error) {
416
+ return {
417
+ name: this.config.name,
418
+ type: this.config.type,
419
+ baseUrl: this.config.baseUrl,
420
+ health: inferHealth(error),
421
+ connected: false,
422
+ modelCount: 0,
423
+ lastCheckedAt,
424
+ message: error instanceof Error ? error.message : String(error)
425
+ };
426
+ }
427
+ }
428
+ async chat(request) {
429
+ const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
430
+ method: "POST",
431
+ headers: authHeaders(this.config),
432
+ body: {
433
+ model: request.model,
434
+ messages: request.messages,
435
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
436
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
437
+ ...request.stop !== void 0 ? { stop: request.stop } : {}
438
+ }
439
+ });
440
+ if (status === 401 || status === 403) throw authFailed(this.config, status);
441
+ if (status === 404) {
442
+ throw new ProviderError(
443
+ "MODEL_NOT_FOUND",
444
+ `Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
445
+ { details: { status } }
446
+ );
447
+ }
448
+ if (status !== 200) {
449
+ throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
450
+ }
451
+ const payload = body;
452
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
453
+ const first = choices[0];
454
+ if (!first || typeof first.message?.content !== "string") {
455
+ throw unavailable(this.config, "Chat completion response had no message content.", status);
456
+ }
457
+ const usage = payload.usage ?? {};
458
+ return {
459
+ model: typeof payload.model === "string" ? payload.model : request.model,
460
+ content: first.message.content,
461
+ finishReason: typeof first.finish_reason === "string" ? first.finish_reason : void 0,
462
+ usage: {
463
+ promptTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : void 0,
464
+ completionTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : void 0,
465
+ totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : void 0
466
+ },
467
+ raw: body
468
+ };
469
+ }
470
+ };
471
+ function normalizeBaseUrl(baseUrl) {
472
+ return baseUrl.replace(/\/+$/, "");
473
+ }
474
+ function extractModelEntries(body, config) {
475
+ if (Array.isArray(body)) return body;
476
+ if (body !== null && typeof body === "object" && Array.isArray(body.data)) {
477
+ return body.data;
478
+ }
479
+ throw new ProviderError(
480
+ "PROVIDER_UNAVAILABLE",
481
+ `Provider "${config.name}" returned an unrecognized model list shape.`,
482
+ { details: { endpoint: "models" } }
483
+ );
484
+ }
485
+ function authFailed(config, status) {
486
+ const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
487
+ return new ProviderError(
488
+ "PROVIDER_AUTH_FAILED",
489
+ `Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
490
+ { details: { status } }
491
+ );
492
+ }
493
+ function unavailable(config, message, status) {
494
+ return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
495
+ details: { status }
496
+ });
497
+ }
498
+ function inferHealth(error) {
499
+ const status = error instanceof ProviderError && typeof error.details?.status === "number" ? error.details.status : void 0;
500
+ return status !== void 0 && status >= 500 ? "degraded" : "unreachable";
501
+ }
502
+
503
+ // src/providers/omnihilbras/index.ts
504
+ var OmniHilbrasProvider = class extends OpenAICompatibleProvider {
505
+ /**
506
+ * Run the full connect flow. Throws on authentication or transport failure
507
+ * (use healthCheck() for a non-throwing probe).
508
+ */
509
+ async connect(registry) {
510
+ const apiKey = resolveApiKey(this.config);
511
+ const discovered = await this.listModels();
512
+ const seen = /* @__PURE__ */ new Set();
513
+ const models = [];
514
+ const duplicates = [];
515
+ for (const model of discovered) {
516
+ if (seen.has(model.id)) {
517
+ duplicates.push(model.id);
518
+ continue;
519
+ }
520
+ seen.add(model.id);
521
+ models.push({
522
+ ...model,
523
+ provider: this.config.name,
524
+ status: "available",
525
+ metadata: { ...model.metadata, gateway: "omnihilbras" }
526
+ });
527
+ }
528
+ if (registry) {
529
+ for (const model of models) registry.register(model);
530
+ }
531
+ const message = `Authenticated${apiKey ? " with API key" : " (no API key configured)"}; validated ${models.length} unique model(s)` + (duplicates.length > 0 ? `, skipped ${duplicates.length} duplicate(s)` : "") + ".";
532
+ const status = {
533
+ name: this.config.name,
534
+ type: this.config.type,
535
+ baseUrl: this.config.baseUrl,
536
+ health: "healthy",
537
+ connected: true,
538
+ modelCount: models.length,
539
+ lastCheckedAt: (/* @__PURE__ */ new Date()).toISOString(),
540
+ message
541
+ };
542
+ return { status, models, duplicates, registered: registry ? models.length : 0 };
543
+ }
544
+ };
545
+
546
+ // src/providers/factory.ts
547
+ var OPENAI_COMPATIBLE_TYPES = [
548
+ "openai-compatible",
549
+ "openrouter",
550
+ "local",
551
+ "custom"
552
+ ];
553
+ function createProvider(config) {
554
+ if (config.type === "omnihilbras") {
555
+ return new OmniHilbrasProvider(config);
556
+ }
557
+ if (OPENAI_COMPATIBLE_TYPES.includes(config.type)) {
558
+ return new OpenAICompatibleProvider(config);
559
+ }
560
+ throw new OmniNodeError("NOT_IMPLEMENTED", `Provider type "${config.type}" has no adapter yet.`);
561
+ }
562
+
563
+ // src/registry/index.ts
564
+ function registryKey(provider, id) {
565
+ return `${provider}:${id}`;
566
+ }
567
+ var ModelRegistry = class {
568
+ models = /* @__PURE__ */ new Map();
569
+ register(model) {
570
+ this.models.set(registryKey(model.provider, model.id), model);
571
+ }
572
+ get(provider, id) {
573
+ return this.models.get(registryKey(provider, id));
574
+ }
575
+ list(filter = {}) {
576
+ return [...this.models.values()].filter((model) => filter.provider ? model.provider === filter.provider : true).filter(
577
+ (model) => filter.capability ? model.capabilities?.[filter.capability] === true : true
578
+ ).sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
579
+ }
580
+ /** Availability validation (§27 Phase 1): present in the registry and not retired. */
581
+ isAvailable(provider, id) {
582
+ const model = this.get(provider, id);
583
+ return model !== void 0 && (model.status ?? "available") === "available";
584
+ }
585
+ /** Discover models from one provider and register them. */
586
+ async discoverFrom(provider) {
587
+ const discovered = await provider.listModels();
588
+ for (const model of discovered) this.register(model);
589
+ return discovered;
590
+ }
591
+ /** Discover from many providers; a failing provider does not abort the rest. */
592
+ async discoverAll(providers) {
593
+ return Promise.all(
594
+ providers.map(async (provider) => {
595
+ try {
596
+ return {
597
+ provider: provider.config.name,
598
+ models: await this.discoverFrom(provider)
599
+ };
600
+ } catch (error) {
601
+ return {
602
+ provider: provider.config.name,
603
+ models: [],
604
+ error: error instanceof Error ? error : new Error(String(error))
605
+ };
606
+ }
607
+ })
608
+ );
609
+ }
610
+ };
611
+
612
+ // src/cli/commands/shared.ts
613
+ function notImplemented(command, when) {
614
+ return new OmniNodeError(
615
+ "NOT_IMPLEMENTED",
616
+ `${command} is planned for ${when}. Edit omninode.yaml directly for now.`
617
+ );
618
+ }
619
+
241
620
  // src/cli/commands/providers.ts
242
621
  function registerProviderCommands(program) {
243
622
  const provider = program.command("provider").description("Manage AI providers configured in omninode.yaml.");
@@ -246,7 +625,7 @@ function registerProviderCommands(program) {
246
625
  const providers = config.project.providers;
247
626
  if (providers.length === 0) {
248
627
  console.log(
249
- "No providers configured. Add one under `project.providers` in omninode.yaml."
628
+ "No providers configured. Add one with `omninode provider add` or edit omninode.yaml."
250
629
  );
251
630
  return;
252
631
  }
@@ -258,15 +637,157 @@ function registerProviderCommands(program) {
258
637
  );
259
638
  }
260
639
  });
261
- provider.command("add").description("Add a provider (planned for Phase 1 \u2014 Provider Infrastructure).").action(() => {
262
- throw notImplemented("omninode provider add", "Phase 1 (Provider Infrastructure)");
640
+ provider.command("add").description(
641
+ "Add a provider to omninode.yaml. OpenAI-compatible gateways (including OpenRouter and local runtimes) are supported today; OmniHilbras gets its own adapter in Phase 2."
642
+ ).requiredOption("--name <name>", "Unique provider name used in configuration and the model registry.").requiredOption("--base-url <url>", "Provider base URL, e.g. https://example.com/v1").option(
643
+ "--type <type>",
644
+ "Provider type: openai-compatible, omnihilbras, openrouter, local or custom.",
645
+ "openai-compatible"
646
+ ).option(
647
+ "--api-key-env-var <envVar>",
648
+ "Environment variable holding the API key. Keys are never written to the config file."
649
+ ).action(async (options) => addProvider(options));
650
+ provider.command("test <name>").description("Test connectivity, authentication and model discovery for a configured provider.").option(
651
+ "--connect",
652
+ "Run the full provider connect flow (authenticate, fetch, validate, register) where the adapter supports it."
653
+ ).action(async (name, options) => testProvider(name, options));
654
+ provider.command("remove <name>").description("Remove a provider (planned for Phase 1 follow-up).").action(() => {
655
+ throw notImplemented("omninode provider remove", "a Phase 1 follow-up");
263
656
  });
264
657
  }
265
- function notImplemented(command, phase) {
266
- return new OmniNodeError(
267
- "NOT_IMPLEMENTED",
268
- `${command} is planned for ${phase}. Edit omninode.yaml directly for now.`
269
- );
658
+ async function addProvider(options) {
659
+ const configPath = findConfigFile();
660
+ if (!configPath) {
661
+ throw new OmniNodeError("CONFIG_NOT_FOUND", "No omninode.yaml found. Run `omninode init` first.");
662
+ }
663
+ const candidate = providerConfigSchema.safeParse({
664
+ name: options.name,
665
+ type: options.type,
666
+ base_url: options.baseUrl,
667
+ ...options.apiKeyEnvVar !== void 0 ? { api_key_env_var: options.apiKeyEnvVar } : {}
668
+ });
669
+ if (!candidate.success) {
670
+ const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
671
+ throw new OmniNodeError("CLI_USAGE", `Invalid provider options: ${issues}`);
672
+ }
673
+ const newProvider = candidate.data;
674
+ const existing = loadConfig();
675
+ if (existing.project.providers.some((p) => p.name === newProvider.name)) {
676
+ throw new OmniNodeError(
677
+ "CLI_USAGE",
678
+ `Provider "${newProvider.name}" already exists in ${configPath}.`
679
+ );
680
+ }
681
+ const doc = parseDocument(readFileSync2(configPath, "utf8"));
682
+ if (doc.getIn(["project", "providers"]) === void 0) {
683
+ doc.setIn(["project", "providers"], [newProvider]);
684
+ } else {
685
+ doc.addIn(["project", "providers"], newProvider);
686
+ }
687
+ const validated = appConfigSchema.safeParse(doc.toJS());
688
+ if (!validated.success) {
689
+ const issues = validated.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
690
+ throw new ConfigError(
691
+ "CONFIG_INVALID",
692
+ `Editing ${configPath} would produce an invalid configuration: ${issues}`
693
+ );
694
+ }
695
+ writeFileSync2(configPath, doc.toString(), "utf8");
696
+ console.log(`Added provider "${newProvider.name}" (${newProvider.type}) to ${configPath}.`);
697
+ if (newProvider.api_key_env_var) {
698
+ console.log(
699
+ `Set $${newProvider.api_key_env_var} in your environment, then run: omninode provider test ${newProvider.name}`
700
+ );
701
+ } else {
702
+ console.log(`Run \`omninode provider test ${newProvider.name}\` to verify connectivity.`);
703
+ }
704
+ }
705
+ async function testProvider(name, options = {}) {
706
+ const config = loadConfig();
707
+ const providerConfig = config.project.providers.find((p) => p.name === name);
708
+ if (!providerConfig) {
709
+ const available = config.project.providers.map((p) => p.name).join(", ") || "(none)";
710
+ throw new ProviderError(
711
+ "PROVIDER_NOT_FOUND",
712
+ `Provider "${name}" is not configured. Configured providers: ${available}.`
713
+ );
714
+ }
715
+ const provider = createProvider(providerConfig);
716
+ console.log(`Testing provider "${name}" (${providerConfig.type}) at ${provider.config.baseUrl}`);
717
+ if (options.connect && provider instanceof OmniHilbrasProvider) {
718
+ const registry = new ModelRegistry();
719
+ try {
720
+ const result = await provider.connect(registry);
721
+ printProviderStatus(result.status);
722
+ console.log(` registered: ${result.registered}`);
723
+ } catch (error) {
724
+ console.log(` connect failed: ${error instanceof Error ? error.message : String(error)}`);
725
+ process.exitCode = 1;
726
+ }
727
+ return;
728
+ }
729
+ const health = await provider.healthCheck();
730
+ printProviderStatus(health);
731
+ if (!health.connected) process.exitCode = 1;
732
+ }
733
+ function printProviderStatus(status) {
734
+ console.log(` health: ${status.health}`);
735
+ console.log(` connected: ${status.connected}`);
736
+ console.log(` models: ${status.modelCount}`);
737
+ if (status.message) console.log(` detail: ${status.message}`);
738
+ }
739
+
740
+ // src/cli/commands/models.ts
741
+ var CAPABILITIES = [
742
+ "chat",
743
+ "coding",
744
+ "tools",
745
+ "vision",
746
+ "embeddings"
747
+ ];
748
+ function registerModelsCommand(program) {
749
+ program.command("models").description("Discover and list models from configured providers (model registry, \xA78).").argument("[provider]", "Only discover from this provider.").option("-c, --capability <capability>", `Filter by capability: ${CAPABILITIES.join(", ")}.`).action(async (providerName, options) => {
750
+ const capability = options.capability;
751
+ if (capability !== void 0 && !CAPABILITIES.includes(capability)) {
752
+ throw new OmniNodeError(
753
+ "CLI_USAGE",
754
+ `Unknown capability "${options.capability}". Valid capabilities: ${CAPABILITIES.join(", ")}.`
755
+ );
756
+ }
757
+ const config = loadConfig();
758
+ const selected = config.project.providers.filter(
759
+ (p) => providerName ? p.name === providerName : true
760
+ );
761
+ if (selected.length === 0) {
762
+ throw new OmniNodeError(
763
+ "PROVIDER_NOT_FOUND",
764
+ providerName ? `Provider "${providerName}" is not configured.` : "No providers configured. Add one with `omninode provider add`."
765
+ );
766
+ }
767
+ const registry = new ModelRegistry();
768
+ for (const providerConfig of selected) {
769
+ try {
770
+ const provider = createProvider(providerConfig);
771
+ const discovered = await registry.discoverFrom(provider);
772
+ console.log(`${providerConfig.name}: ${discovered.length} model(s)`);
773
+ } catch (error) {
774
+ const message = error instanceof Error ? error.message : String(error);
775
+ console.log(`${providerConfig.name}: discovery failed \u2014 ${message}`);
776
+ }
777
+ }
778
+ const models = registry.list({
779
+ ...providerName !== void 0 ? { provider: providerName } : {},
780
+ ...capability !== void 0 ? { capability } : {}
781
+ });
782
+ if (models.length === 0) {
783
+ console.log("No models found.");
784
+ return;
785
+ }
786
+ console.log("");
787
+ for (const model of models) {
788
+ console.log(`${model.provider}:${model.id}`);
789
+ }
790
+ });
270
791
  }
271
792
 
272
793
  // src/cli/commands/agents.ts
@@ -345,6 +866,7 @@ function createProgram() {
345
866
  registerRoleCommands(program);
346
867
  registerTaskCommands(program);
347
868
  registerReportCommands(program);
869
+ registerModelsCommand(program);
348
870
  return program;
349
871
  }
350
872
  async function run(argv) {