@hilbras/omninode 0.1.0 → 0.2.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,30 @@ 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.2.0 — Phase 1: Provider Infrastructure** (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 dedicated
36
+ adapter in Phase 2)
21
37
  - Typed error hierarchy (`src/errors/`)
22
38
  - Leveled logger with pluggable sink (`src/logger/`)
23
39
  - Configuration system: `omninode.yaml` with `${ENV_VAR}` expansion and strict
24
40
  validation (`src/config/`)
25
- - CLI with `init`, `provider list`, `agent list`, `role list`, and honest
26
- stubs for the phase-dependent commands (`src/cli/`)
41
+ - CLI: `init`, `provider add/list/test`, `models`, `agent list`, `role list`,
42
+ and honest stubs for the phase-dependent commands (`src/cli/`)
27
43
  - Test infrastructure (Vitest) and CI (GitHub Actions)
28
44
 
29
- Nothing here depends on any provider or agent — that is the point of Phase 0.
30
-
31
45
  ## Requirements
32
46
 
33
47
  - Node.js >= 20
@@ -46,8 +60,17 @@ npm run omninode -- --help
46
60
  Start a project:
47
61
 
48
62
  ```bash
49
- omninode init # writes omninode.yaml in the current directory
50
- omninode provider list # validates and lists configured providers
63
+ omninode init # writes omninode.yaml in the current directory
64
+
65
+ # Register a provider and verify it end to end
66
+ omninode provider add --name my-gateway --type openai-compatible \
67
+ --base-url https://example.com/v1 --api-key-env-var MY_API_KEY
68
+ omninode provider test my-gateway
69
+
70
+ # Discover models into the registry
71
+ omninode models
72
+ omninode models my-gateway --capability chat
73
+
51
74
  omninode agent list
52
75
  omninode role list
53
76
  ```
@@ -81,8 +104,8 @@ Phases 0–11 are laid out in [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.m
81
104
 
82
105
  | Version | Milestone |
83
106
  | --- | --- |
84
- | v0.1.0 | Foundation (this release) |
85
- | v0.2.0 | Provider System |
107
+ | v0.1.0 | Foundation |
108
+ | **v0.2.0** | **Provider System (current)** |
86
109
  | v0.3.0 | OmniHilbras Integration (optional, like every provider) |
87
110
  | v0.4.0 | Agent System |
88
111
  | v0.5.0 | Roles & Tasks |
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.2.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,291 @@ 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/factory.ts
504
+ var OPENAI_COMPATIBLE_TYPES = [
505
+ "openai-compatible",
506
+ "openrouter",
507
+ "local",
508
+ "custom"
509
+ ];
510
+ function createProvider(config) {
511
+ if (OPENAI_COMPATIBLE_TYPES.includes(config.type)) {
512
+ return new OpenAICompatibleProvider(config);
513
+ }
514
+ if (config.type === "omnihilbras") {
515
+ throw new OmniNodeError(
516
+ "NOT_IMPLEMENTED",
517
+ `Provider type "omnihilbras" is planned for Phase 2 (OmniHilbras Integration). If your OmniHilbras endpoint is OpenAI-compatible, use type "openai-compatible" for now.`
518
+ );
519
+ }
520
+ throw new OmniNodeError("NOT_IMPLEMENTED", `Provider type "${config.type}" has no adapter yet.`);
521
+ }
522
+
523
+ // src/cli/commands/shared.ts
524
+ function notImplemented(command, when) {
525
+ return new OmniNodeError(
526
+ "NOT_IMPLEMENTED",
527
+ `${command} is planned for ${when}. Edit omninode.yaml directly for now.`
528
+ );
529
+ }
530
+
241
531
  // src/cli/commands/providers.ts
242
532
  function registerProviderCommands(program) {
243
533
  const provider = program.command("provider").description("Manage AI providers configured in omninode.yaml.");
@@ -246,7 +536,7 @@ function registerProviderCommands(program) {
246
536
  const providers = config.project.providers;
247
537
  if (providers.length === 0) {
248
538
  console.log(
249
- "No providers configured. Add one under `project.providers` in omninode.yaml."
539
+ "No providers configured. Add one with `omninode provider add` or edit omninode.yaml."
250
540
  );
251
541
  return;
252
542
  }
@@ -258,15 +548,188 @@ function registerProviderCommands(program) {
258
548
  );
259
549
  }
260
550
  });
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)");
551
+ provider.command("add").description(
552
+ "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."
553
+ ).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(
554
+ "--type <type>",
555
+ "Provider type: openai-compatible, omnihilbras, openrouter, local or custom.",
556
+ "openai-compatible"
557
+ ).option(
558
+ "--api-key-env-var <envVar>",
559
+ "Environment variable holding the API key. Keys are never written to the config file."
560
+ ).action(async (options) => addProvider(options));
561
+ provider.command("test <name>").description("Test connectivity, authentication and model discovery for a configured provider.").action(async (name) => testProvider(name));
562
+ provider.command("remove <name>").description("Remove a provider (planned for Phase 1 follow-up).").action(() => {
563
+ throw notImplemented("omninode provider remove", "a Phase 1 follow-up");
263
564
  });
264
565
  }
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
- );
566
+ async function addProvider(options) {
567
+ const configPath = findConfigFile();
568
+ if (!configPath) {
569
+ throw new OmniNodeError("CONFIG_NOT_FOUND", "No omninode.yaml found. Run `omninode init` first.");
570
+ }
571
+ const candidate = providerConfigSchema.safeParse({
572
+ name: options.name,
573
+ type: options.type,
574
+ base_url: options.baseUrl,
575
+ ...options.apiKeyEnvVar !== void 0 ? { api_key_env_var: options.apiKeyEnvVar } : {}
576
+ });
577
+ if (!candidate.success) {
578
+ const issues = candidate.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
579
+ throw new OmniNodeError("CLI_USAGE", `Invalid provider options: ${issues}`);
580
+ }
581
+ const newProvider = candidate.data;
582
+ const existing = loadConfig();
583
+ if (existing.project.providers.some((p) => p.name === newProvider.name)) {
584
+ throw new OmniNodeError(
585
+ "CLI_USAGE",
586
+ `Provider "${newProvider.name}" already exists in ${configPath}.`
587
+ );
588
+ }
589
+ const doc = parseDocument(readFileSync2(configPath, "utf8"));
590
+ if (doc.getIn(["project", "providers"]) === void 0) {
591
+ doc.setIn(["project", "providers"], [newProvider]);
592
+ } else {
593
+ doc.addIn(["project", "providers"], newProvider);
594
+ }
595
+ const validated = appConfigSchema.safeParse(doc.toJS());
596
+ if (!validated.success) {
597
+ const issues = validated.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
598
+ throw new ConfigError(
599
+ "CONFIG_INVALID",
600
+ `Editing ${configPath} would produce an invalid configuration: ${issues}`
601
+ );
602
+ }
603
+ writeFileSync2(configPath, doc.toString(), "utf8");
604
+ console.log(`Added provider "${newProvider.name}" (${newProvider.type}) to ${configPath}.`);
605
+ if (newProvider.api_key_env_var) {
606
+ console.log(
607
+ `Set $${newProvider.api_key_env_var} in your environment, then run: omninode provider test ${newProvider.name}`
608
+ );
609
+ } else {
610
+ console.log(`Run \`omninode provider test ${newProvider.name}\` to verify connectivity.`);
611
+ }
612
+ }
613
+ async function testProvider(name) {
614
+ const config = loadConfig();
615
+ const providerConfig = config.project.providers.find((p) => p.name === name);
616
+ if (!providerConfig) {
617
+ const available = config.project.providers.map((p) => p.name).join(", ") || "(none)";
618
+ throw new ProviderError(
619
+ "PROVIDER_NOT_FOUND",
620
+ `Provider "${name}" is not configured. Configured providers: ${available}.`
621
+ );
622
+ }
623
+ const provider = createProvider(providerConfig);
624
+ console.log(`Testing provider "${name}" (${providerConfig.type}) at ${provider.config.baseUrl}`);
625
+ const health = await provider.healthCheck();
626
+ console.log(` health: ${health.health}`);
627
+ console.log(` connected: ${health.connected}`);
628
+ console.log(` models: ${health.modelCount}`);
629
+ if (health.message) console.log(` detail: ${health.message}`);
630
+ if (!health.connected) process.exitCode = 1;
631
+ }
632
+
633
+ // src/registry/index.ts
634
+ function registryKey(provider, id) {
635
+ return `${provider}:${id}`;
636
+ }
637
+ var ModelRegistry = class {
638
+ models = /* @__PURE__ */ new Map();
639
+ register(model) {
640
+ this.models.set(registryKey(model.provider, model.id), model);
641
+ }
642
+ get(provider, id) {
643
+ return this.models.get(registryKey(provider, id));
644
+ }
645
+ list(filter = {}) {
646
+ return [...this.models.values()].filter((model) => filter.provider ? model.provider === filter.provider : true).filter(
647
+ (model) => filter.capability ? model.capabilities?.[filter.capability] === true : true
648
+ ).sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
649
+ }
650
+ /** Availability validation (§27 Phase 1): present in the registry and not retired. */
651
+ isAvailable(provider, id) {
652
+ const model = this.get(provider, id);
653
+ return model !== void 0 && (model.status ?? "available") === "available";
654
+ }
655
+ /** Discover models from one provider and register them. */
656
+ async discoverFrom(provider) {
657
+ const discovered = await provider.listModels();
658
+ for (const model of discovered) this.register(model);
659
+ return discovered;
660
+ }
661
+ /** Discover from many providers; a failing provider does not abort the rest. */
662
+ async discoverAll(providers) {
663
+ return Promise.all(
664
+ providers.map(async (provider) => {
665
+ try {
666
+ return {
667
+ provider: provider.config.name,
668
+ models: await this.discoverFrom(provider)
669
+ };
670
+ } catch (error) {
671
+ return {
672
+ provider: provider.config.name,
673
+ models: [],
674
+ error: error instanceof Error ? error : new Error(String(error))
675
+ };
676
+ }
677
+ })
678
+ );
679
+ }
680
+ };
681
+
682
+ // src/cli/commands/models.ts
683
+ var CAPABILITIES = [
684
+ "chat",
685
+ "coding",
686
+ "tools",
687
+ "vision",
688
+ "embeddings"
689
+ ];
690
+ function registerModelsCommand(program) {
691
+ 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) => {
692
+ const capability = options.capability;
693
+ if (capability !== void 0 && !CAPABILITIES.includes(capability)) {
694
+ throw new OmniNodeError(
695
+ "CLI_USAGE",
696
+ `Unknown capability "${options.capability}". Valid capabilities: ${CAPABILITIES.join(", ")}.`
697
+ );
698
+ }
699
+ const config = loadConfig();
700
+ const selected = config.project.providers.filter(
701
+ (p) => providerName ? p.name === providerName : true
702
+ );
703
+ if (selected.length === 0) {
704
+ throw new OmniNodeError(
705
+ "PROVIDER_NOT_FOUND",
706
+ providerName ? `Provider "${providerName}" is not configured.` : "No providers configured. Add one with `omninode provider add`."
707
+ );
708
+ }
709
+ const registry = new ModelRegistry();
710
+ for (const providerConfig of selected) {
711
+ try {
712
+ const provider = createProvider(providerConfig);
713
+ const discovered = await registry.discoverFrom(provider);
714
+ console.log(`${providerConfig.name}: ${discovered.length} model(s)`);
715
+ } catch (error) {
716
+ const message = error instanceof Error ? error.message : String(error);
717
+ console.log(`${providerConfig.name}: discovery failed \u2014 ${message}`);
718
+ }
719
+ }
720
+ const models = registry.list({
721
+ ...providerName !== void 0 ? { provider: providerName } : {},
722
+ ...capability !== void 0 ? { capability } : {}
723
+ });
724
+ if (models.length === 0) {
725
+ console.log("No models found.");
726
+ return;
727
+ }
728
+ console.log("");
729
+ for (const model of models) {
730
+ console.log(`${model.provider}:${model.id}`);
731
+ }
732
+ });
270
733
  }
271
734
 
272
735
  // src/cli/commands/agents.ts
@@ -345,6 +808,7 @@ function createProgram() {
345
808
  registerRoleCommands(program);
346
809
  registerTaskCommands(program);
347
810
  registerReportCommands(program);
811
+ registerModelsCommand(program);
348
812
  return program;
349
813
  }
350
814
  async function run(argv) {