feat(cli): fase 9.4 — plugin system (omniroute-cmd-*)

Adds plugin discovery, loading, and management to the omniroute CLI.

- bin/cli/plugins.mjs: discoverPlugins / loadPlugins / buildPluginContext
- bin/cli/commands/plugin.mjs: list / install / remove / info / search / update / scaffold
- examples/omniroute-cmd-hello/: minimal working plugin example
- docs/dev/plugins.md: plugin API contract and authoring guide
- .env.example + ENVIRONMENT.md: document OMNIROUTE_PLUGIN_PATH
This commit is contained in:
diegosouzapw 2026-05-15 05:11:18 -03:00
parent cd62899f31
commit b3e5ee3333
12 changed files with 654 additions and 1 deletions

View file

@ -800,6 +800,10 @@ APP_LOG_TO_FILE=true
# Set to 1 to print retry/backoff details to stderr during CLI commands.
# OMNIROUTE_VERBOSE=0
# Custom directory for CLI plugin discovery (omniroute-cmd-* packages).
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
# OMNIROUTE_PLUGIN_PATH=
# ── Prompt cache (system prompt deduplication) ──
# Used by: open-sse/services — caches identical system prompts across requests.
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)

View file

@ -4,7 +4,6 @@ import { t } from "../i18n.mjs";
export function registerDashboard(program) {
program
.command("dashboard")
.alias("open")
.description(t("dashboard.description"))
.option("--url", t("dashboard.urlOnly"))
.option("--port <port>", "Port the server is running on", "20128")

192
bin/cli/commands/plugin.mjs Normal file
View file

@ -0,0 +1,192 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
import { discoverPlugins } from "../plugins.mjs";
const TEMPLATE_INDEX = `export const meta = {
name: "PLUGIN_NAME",
version: "0.1.0",
description: "OmniRoute plugin",
omnirouteApi: ">=4.0.0",
};
export function register(program, ctx) {
program
.command("PLUGIN_NAME")
.description(meta.description)
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
// ctx provides: ctx.apiFetch, ctx.emit, ctx.t, ctx.withSpinner, ctx.baseUrl, ctx.apiKey
const res = await ctx.apiFetch("/api/health", { baseUrl: gOpts.baseUrl, apiKey: gOpts.apiKey });
const data = await res.json();
ctx.emit(data, gOpts);
});
}
`;
export function registerPlugin(program) {
const plugin = program
.command("plugin")
.description(t("plugin.description") || "Manage CLI plugins (omniroute-cmd-*)");
plugin
.command("list")
.description(t("plugin.list") || "List installed plugins")
.action(async (opts, cmd) => {
const plugins = await discoverPlugins();
emit(
plugins.map((p) => ({ name: p.name, version: p.version, description: p.description })),
cmd.optsWithGlobals()
);
if (plugins.length === 0) {
process.stdout.write("No plugins installed.\n");
process.stdout.write(`Install: omniroute plugin install <name>\n`);
}
});
plugin
.command("install <name>")
.description(t("plugin.install") || "Install a plugin")
.option("-y, --yes", "Skip confirmation prompt")
.action(async (name, opts) => {
const isLocal = name.startsWith("./") || name.startsWith("/") || name.startsWith("../");
const pkgName = isLocal ? name : `omniroute-cmd-${name}`;
if (!opts.yes) {
process.stderr.write(
`⚠ WARNING: Plugins run with the same privileges as omniroute CLI.\n` +
` Only install plugins from sources you trust.\n` +
` Installing: ${pkgName}\n` +
` Pass --yes to skip this prompt.\n`
);
// In non-interactive mode, require explicit --yes
if (!process.stdin.isTTY) {
process.stderr.write("Non-interactive mode: use --yes to confirm.\n");
process.exit(1);
}
}
try {
execSync(`npm install -g ${pkgName}`, { stdio: "inherit" });
process.stdout.write(`\n✓ Installed: ${pkgName}\n`);
} catch {
process.stderr.write(`✗ Failed to install ${pkgName}\n`);
process.exit(1);
}
});
plugin
.command("remove <name>")
.alias("uninstall")
.description(t("plugin.remove") || "Remove a plugin")
.option("-y, --yes", "Skip confirmation")
.action(async (name, opts) => {
const pkgName = name.startsWith("omniroute-cmd-") ? name : `omniroute-cmd-${name}`;
if (!opts.yes) {
process.stderr.write(`Removing: ${pkgName} — pass --yes to confirm.\n`);
if (!process.stdin.isTTY) {
process.exit(1);
}
}
try {
execSync(`npm uninstall -g ${pkgName}`, { stdio: "inherit" });
process.stdout.write(`✓ Removed: ${pkgName}\n`);
} catch {
process.stderr.write(`✗ Failed to remove ${pkgName}\n`);
process.exit(1);
}
});
plugin
.command("info <name>")
.description(t("plugin.info") || "Show plugin details")
.action(async (name, opts, cmd) => {
const plugins = await discoverPlugins();
const p = plugins.find((x) => x.name === name || x.name === `omniroute-cmd-${name}`);
if (!p) {
process.stderr.write(`Plugin '${name}' not found.\n`);
process.exit(1);
}
emit(p.pkg, cmd.optsWithGlobals());
});
plugin
.command("search [query]")
.description(t("plugin.search") || "Search npm for available plugins")
.action(async (query) => {
const q = query ? `omniroute-cmd-${query}` : "omniroute-cmd";
const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(q)}&size=50`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
const data = await res.json();
const rows = (data.objects || []).map((o) => ({
name: o.package.name,
version: o.package.version,
description: o.package.description,
}));
if (rows.length === 0) {
process.stdout.write(`No plugins found for '${query || "omniroute-cmd"}'.\n`);
} else {
rows.forEach((r) =>
process.stdout.write(` ${r.name}@${r.version} ${r.description || ""}\n`)
);
}
} catch (err) {
process.stderr.write(`Search failed: ${err.message}\n`);
process.exit(1);
}
});
plugin
.command("update [name]")
.description(t("plugin.update") || "Update installed plugin(s)")
.action(async (name) => {
const pkg = name ? `omniroute-cmd-${name}` : "omniroute-cmd-*";
try {
execSync(`npm update -g ${pkg}`, { stdio: "inherit" });
process.stdout.write(`✓ Updated: ${pkg}\n`);
} catch {
process.stderr.write(`✗ Update failed\n`);
process.exit(1);
}
});
plugin
.command("scaffold <name>")
.description(t("plugin.scaffold") || "Scaffold a new plugin boilerplate")
.action(async (name) => {
const safeName = name.replace(/[^a-z0-9-]/g, "-");
const dir = join(process.cwd(), `omniroute-cmd-${safeName}`);
if (existsSync(dir)) {
process.stderr.write(`Directory already exists: ${dir}\n`);
process.exit(1);
}
mkdirSync(dir, { recursive: true });
writeFileSync(
join(dir, "package.json"),
JSON.stringify(
{
name: `omniroute-cmd-${safeName}`,
version: "0.1.0",
type: "module",
main: "index.mjs",
description: `OmniRoute CLI plugin: ${safeName}`,
engines: { omniroute: ">=4.0.0" },
keywords: ["omniroute-plugin", "omniroute-cmd"],
},
null,
2
) + "\n"
);
writeFileSync(join(dir, "index.mjs"), TEMPLATE_INDEX.replace(/PLUGIN_NAME/g, safeName));
writeFileSync(
join(dir, "README.md"),
`# omniroute-cmd-${safeName}\n\nAn OmniRoute CLI plugin.\n\n## Install\n\n\`\`\`bash\nomniroute plugin install ${safeName}\n\`\`\`\n`
);
process.stdout.write(`✓ Scaffolded: ${dir}\n`);
process.stdout.write(` Run: cd ${dir} && omniroute plugin install .\n`);
});
}

View file

@ -56,6 +56,7 @@ import { registerTray } from "./tray.mjs";
import { registerAutostart } from "./autostart.mjs";
import { registerRepl } from "./repl.mjs";
import { registerApiCommands } from "../api-commands/registry.mjs";
import { registerPlugin } from "./plugin.mjs";
export function registerCommands(program) {
registerMemory(program);
@ -117,4 +118,5 @@ export function registerCommands(program) {
registerAutostart(program);
registerRepl(program);
registerApiCommands(program);
registerPlugin(program);
}

View file

@ -1112,5 +1112,15 @@
"combo": "Combo name to use",
"system": "System prompt",
"resume": "Resume a saved session by name"
},
"plugin": {
"description": "Manage CLI plugins (omniroute-cmd-*)",
"list": "List installed plugins",
"install": "Install a plugin from npm or local path",
"remove": "Remove an installed plugin",
"info": "Show details for an installed plugin",
"search": "Search npm registry for available plugins",
"update": "Update installed plugin(s)",
"scaffold": "Scaffold a new plugin boilerplate"
}
}

View file

@ -1112,5 +1112,15 @@
"combo": "Nome do combo a usar",
"system": "System prompt",
"resume": "Retomar sessão salva pelo nome"
},
"plugin": {
"description": "Gerenciar plugins CLI (omniroute-cmd-*)",
"list": "Listar plugins instalados",
"install": "Instalar plugin do npm ou caminho local",
"remove": "Remover plugin instalado",
"info": "Mostrar detalhes de um plugin instalado",
"search": "Buscar plugins disponíveis no npm",
"update": "Atualizar plugin(s) instalado(s)",
"scaffold": "Gerar boilerplate de novo plugin"
}
}

90
bin/cli/plugins.mjs Normal file
View file

@ -0,0 +1,90 @@
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { pathToFileURL } from "node:url";
const PLUGIN_PREFIX_RE = /^(@[^/]+\/)?omniroute-cmd-/;
function getPluginDirs() {
return [join(homedir(), ".omniroute", "plugins"), process.env.OMNIROUTE_PLUGIN_PATH].filter(
Boolean
);
}
export async function discoverPlugins() {
const found = [];
for (const dir of getPluginDirs()) {
if (!existsSync(dir)) continue;
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const pkgPath = join(dir, entry.name, "package.json");
if (!existsSync(pkgPath)) continue;
let pkg;
try {
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
} catch {
continue;
}
if (!pkg.name || !PLUGIN_PREFIX_RE.test(pkg.name)) continue;
found.push({
name: pkg.name,
version: pkg.version || "0.0.0",
description: pkg.description || "",
dir: join(dir, entry.name),
pkg,
});
}
}
return found;
}
export function buildPluginContext(opts = {}) {
return {
apiFetch: async (...args) => {
const { apiFetch } = await import("./api.mjs");
return apiFetch(...args);
},
emit: async (...args) => {
const { emit } = await import("./output.mjs");
return emit(...args);
},
t: async (...args) => {
const { t } = await import("./i18n.mjs");
return t(...args);
},
withSpinner: async (...args) => {
const { withSpinner } = await import("./spinner.mjs");
return withSpinner(...args);
},
baseUrl: opts.baseUrl,
apiKey: opts.apiKey,
};
}
export async function loadPlugins(program, ctx = {}) {
const plugins = await discoverPlugins();
for (const p of plugins) {
try {
const entryPath = join(p.dir, p.pkg.main || "index.mjs");
if (!existsSync(entryPath)) {
process.stderr.write(`[plugin] ${p.name}: entry file not found (${entryPath})\n`);
continue;
}
const mod = await import(pathToFileURL(entryPath).href);
if (typeof mod.register === "function") {
mod.register(program, buildPluginContext(ctx));
} else {
process.stderr.write(`[plugin] ${p.name}: no register() export — skipping\n`);
}
} catch (err) {
process.stderr.write(`[plugin] Failed to load ${p.name}: ${err.message}\n`);
}
}
return plugins.length;
}

108
docs/dev/plugins.md Normal file
View file

@ -0,0 +1,108 @@
# OmniRoute CLI Plugin System
Extend the `omniroute` CLI without modifying its core. Plugins follow the `omniroute-cmd-*` naming convention, similar to `gh extension` or `kubectl plugin`.
## Quick start
```bash
# Install a plugin from npm
omniroute plugin install stripe
# Install a local plugin in development
omniroute plugin install ./my-plugin
# List installed plugins
omniroute plugin list
# Scaffold a new plugin
omniroute plugin scaffold myplugin
cd omniroute-cmd-myplugin
omniroute plugin install .
```
## Plugin anatomy
A plugin is an npm package named `omniroute-cmd-<name>` (or `@scope/omniroute-cmd-<name>`).
```
omniroute-cmd-myplugin/
├── package.json # must have "type": "module" and "main": "index.mjs"
├── index.mjs # exports register(program, ctx) + optional meta
└── README.md
```
### `package.json`
```json
{
"name": "omniroute-cmd-myplugin",
"version": "0.1.0",
"type": "module",
"main": "index.mjs",
"engines": { "omniroute": ">=4.0.0" },
"keywords": ["omniroute-plugin", "omniroute-cmd"]
}
```
### `index.mjs`
```js
export const meta = {
name: "myplugin",
version: "0.1.0",
description: "My plugin for OmniRoute",
omnirouteApi: ">=4.0.0",
};
export function register(program, ctx) {
program
.command("myplugin")
.description(meta.description)
.option("-n, --name <name>")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
const res = await ctx.apiFetch("/api/combos", {
baseUrl: gOpts.baseUrl,
apiKey: gOpts.apiKey,
});
const data = await res.json();
ctx.emit(data, gOpts);
});
}
```
## Plugin context API
The `ctx` object passed to `register(program, ctx)`:
| Property | Type | Description |
| ---------------------------- | ---------------- | -------------------------------------------------- |
| `ctx.apiFetch(path, opts)` | `async function` | Authenticated fetch to the OmniRoute server |
| `ctx.emit(data, opts)` | `function` | Output in table/json/jsonl/csv per `--output` flag |
| `ctx.t(key)` | `async function` | i18n translation lookup |
| `ctx.withSpinner(label, fn)` | `async function` | Wraps async fn with ora spinner |
| `ctx.baseUrl` | `string` | Resolved base URL |
| `ctx.apiKey` | `string \| null` | API key if provided |
## Discovery
Plugins are discovered from:
1. `~/.omniroute/plugins/<name>/` — user-local installs
2. `OMNIROUTE_PLUGIN_PATH` env var — custom directory
Loading errors are caught and printed as warnings — a broken plugin never crashes the CLI.
## Security
Plugins run with the same Node.js process privileges as `omniroute`. Only install plugins from sources you trust. `omniroute plugin install` shows an explicit warning and requires `--yes` or interactive confirmation.
## Publishing
1. Ensure `package.json` has `"keywords": ["omniroute-plugin"]`
2. `npm publish` as normal
3. Users discover via `omniroute plugin search <query>` (searches npm registry)
## Example plugin
See [`examples/omniroute-cmd-hello/`](../../examples/omniroute-cmd-hello/) for a minimal working example.

View file

@ -304,6 +304,7 @@ detection above).
| `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. |
| `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. |
| `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. |
| `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. |
---

View file

@ -0,0 +1,28 @@
export const meta = {
name: "hello",
version: "0.1.0",
description: "Example OmniRoute plugin — greets the user and shows server health",
omnirouteApi: ">=4.0.0",
};
export function register(program, ctx) {
program
.command("hello")
.description(meta.description)
.option("-n, --name <name>", "Name to greet", "World")
.action(async (opts, cmd) => {
const gOpts = cmd.optsWithGlobals();
process.stdout.write(`Hello, ${opts.name}! 👋\n`);
try {
const res = await ctx.apiFetch("/api/health", {
baseUrl: gOpts.baseUrl,
apiKey: gOpts.apiKey,
});
const health = await res.json();
ctx.emit({ greeting: `Hello, ${opts.name}!`, health }, gOpts);
} catch {
ctx.emit({ greeting: `Hello, ${opts.name}!`, health: "unavailable" }, gOpts);
}
});
}

View file

@ -0,0 +1,14 @@
{
"name": "omniroute-cmd-hello",
"version": "0.1.0",
"type": "module",
"main": "index.mjs",
"description": "Example OmniRoute CLI plugin",
"engines": {
"omniroute": ">=4.0.0"
},
"keywords": [
"omniroute-plugin",
"omniroute-cmd"
]
}

View file

@ -0,0 +1,195 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir, homedir } from "node:os";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
test("bin/cli/plugins.mjs exporta discoverPlugins, loadPlugins, buildPluginContext", async () => {
const mod = await import("../../bin/cli/plugins.mjs");
assert.equal(typeof mod.discoverPlugins, "function");
assert.equal(typeof mod.loadPlugins, "function");
assert.equal(typeof mod.buildPluginContext, "function");
});
test("discoverPlugins retorna array vazio se nenhum plugin instalado (diretório não existe)", async () => {
const { discoverPlugins } = await import("../../bin/cli/plugins.mjs");
const orig = process.env.OMNIROUTE_PLUGIN_PATH;
process.env.OMNIROUTE_PLUGIN_PATH = join(tmpdir(), `no-such-dir-${Date.now()}`);
try {
const plugins = await discoverPlugins();
assert.ok(Array.isArray(plugins));
} finally {
if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH;
else process.env.OMNIROUTE_PLUGIN_PATH = orig;
}
});
test("discoverPlugins descobre plugin com package.json válido", async () => {
const { discoverPlugins } = await import("../../bin/cli/plugins.mjs");
const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`);
const pkgDir = join(pluginDir, "omniroute-cmd-test-hello");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "omniroute-cmd-test-hello",
version: "1.0.0",
type: "module",
main: "index.mjs",
})
);
writeFileSync(join(pkgDir, "index.mjs"), `export function register() {}`);
const orig = process.env.OMNIROUTE_PLUGIN_PATH;
process.env.OMNIROUTE_PLUGIN_PATH = pluginDir;
try {
const plugins = await discoverPlugins();
assert.ok(
plugins.some((p) => p.name === "omniroute-cmd-test-hello"),
"deve encontrar o plugin"
);
} finally {
if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH;
else process.env.OMNIROUTE_PLUGIN_PATH = orig;
try {
rmSync(pluginDir, { recursive: true });
} catch {}
}
});
test("discoverPlugins ignora pacotes sem prefixo omniroute-cmd-", async () => {
const { discoverPlugins } = await import("../../bin/cli/plugins.mjs");
const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`);
const pkgDir = join(pluginDir, "some-unrelated-package");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({ name: "some-unrelated-package", version: "1.0.0" })
);
const orig = process.env.OMNIROUTE_PLUGIN_PATH;
process.env.OMNIROUTE_PLUGIN_PATH = pluginDir;
try {
const plugins = await discoverPlugins();
assert.ok(
!plugins.some((p) => p.name === "some-unrelated-package"),
"não deve descobrir pacotes sem prefixo"
);
} finally {
if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH;
else process.env.OMNIROUTE_PLUGIN_PATH = orig;
try {
rmSync(pluginDir, { recursive: true });
} catch {}
}
});
test("loadPlugins não quebra CLI quando plugin tem erro de load (try/catch)", async () => {
const { loadPlugins } = await import("../../bin/cli/plugins.mjs");
const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`);
const pkgDir = join(pluginDir, "omniroute-cmd-broken");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "omniroute-cmd-broken",
version: "1.0.0",
type: "module",
main: "broken.mjs",
})
);
writeFileSync(join(pkgDir, "broken.mjs"), "throw new Error('intentional load error');");
const orig = process.env.OMNIROUTE_PLUGIN_PATH;
process.env.OMNIROUTE_PLUGIN_PATH = pluginDir;
const { Command } = await import("commander");
const prog = new Command();
try {
// Deve não lançar exceção
await assert.doesNotReject(async () => loadPlugins(prog));
} finally {
if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH;
else process.env.OMNIROUTE_PLUGIN_PATH = orig;
try {
rmSync(pluginDir, { recursive: true });
} catch {}
}
});
test("loadPlugins carrega plugin válido e chama register()", async () => {
const { loadPlugins } = await import("../../bin/cli/plugins.mjs");
const pluginDir = join(tmpdir(), `omniroute-plugins-test-${Date.now()}`);
const pkgDir = join(pluginDir, "omniroute-cmd-valid");
mkdirSync(pkgDir, { recursive: true });
writeFileSync(
join(pkgDir, "package.json"),
JSON.stringify({
name: "omniroute-cmd-valid",
version: "1.0.0",
type: "module",
main: "index.mjs",
})
);
// Plugin que adiciona um comando 'testcmd'
writeFileSync(
join(pkgDir, "index.mjs"),
`export function register(program) { program.command('testcmd-from-plugin'); }`
);
const orig = process.env.OMNIROUTE_PLUGIN_PATH;
process.env.OMNIROUTE_PLUGIN_PATH = pluginDir;
const { Command } = await import("commander");
const prog = new Command();
try {
const count = await loadPlugins(prog);
assert.ok(count >= 1, "deve ter carregado pelo menos 1 plugin");
assert.ok(
prog.commands.some((c) => c.name() === "testcmd-from-plugin"),
"comando do plugin deve estar registrado"
);
} finally {
if (orig === undefined) delete process.env.OMNIROUTE_PLUGIN_PATH;
else process.env.OMNIROUTE_PLUGIN_PATH = orig;
try {
rmSync(pluginDir, { recursive: true });
} catch {}
}
});
test("commands/plugin.mjs exporta registerPlugin", async () => {
const mod = await import("../../bin/cli/commands/plugin.mjs");
assert.equal(typeof mod.registerPlugin, "function");
});
test("registerPlugin registra subcomandos: list, install, remove, info, search, update, scaffold", async () => {
const { registerPlugin } = await import("../../bin/cli/commands/plugin.mjs");
const { Command } = await import("commander");
const prog = new Command().exitOverride();
registerPlugin(prog);
const pluginCmd = prog.commands.find((c) => c.name() === "plugin");
assert.ok(pluginCmd, "plugin command deve existir");
const names = pluginCmd.commands.map((c) => c.name());
for (const sub of ["list", "install", "remove", "info", "search", "update", "scaffold"]) {
assert.ok(names.includes(sub), `plugin ${sub} deve existir`);
}
});
test("exemplo omniroute-cmd-hello existe e tem register()", () => {
const examplePath = join(ROOT, "examples", "omniroute-cmd-hello", "index.mjs");
assert.ok(existsSync(examplePath), "exemplo index.mjs deve existir");
const src = readFileSync(examplePath, "utf8");
assert.ok(src.includes("export function register"), "deve exportar register");
assert.ok(src.includes("export const meta"), "deve exportar meta");
});
test("docs/dev/plugins.md existe", () => {
const docPath = join(ROOT, "docs", "dev", "plugins.md");
assert.ok(existsSync(docPath), "docs/dev/plugins.md deve existir");
const src = readFileSync(docPath, "utf8");
assert.ok(src.includes("omniroute-cmd"), "deve mencionar omniroute-cmd");
assert.ok(src.includes("register(program, ctx)"), "deve documentar a API register");
});