Skip to content

Commit 72d0e10

Browse files
Merge pull request #3 from EvilFreelancer/feat/commands
Use commands instead of search
2 parents 257f1fb + 726c051 commit 72d0e10

3 files changed

Lines changed: 188 additions & 47 deletions

File tree

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,19 @@ ocli messages --limit 10
9090

9191
### Command search
9292

93-
When the API surface is too large for `--help`, use the built-in search:
93+
When the API surface is too large for `--help`, use command filtering with `commands`:
9494

9595
```bash
9696
# BM25 natural language search
97-
ocli search --query "upload files"
98-
ocli search -q "list messages" --limit 5
97+
ocli commands --query "upload files"
98+
ocli commands -q "list messages" --limit 5
9999

100100
# Regex pattern matching
101-
ocli search --regex "admin.*get"
102-
ocli search -r "messages" -n 3
101+
ocli commands --regex "admin.*get"
102+
ocli commands -r "messages" -n 3
103103
```
104104

105-
The BM25 engine (ported from [picoclaw](https://github.com/sipeed/picoclaw)) ranks commands by relevance across name, method, path, description, and parameter names. This enables agents to discover the right endpoint without loading all command schemas into context.
105+
The BM25 engine (ported from [picoclaw](https://github.com/sipeed/picoclaw)) ranks commands by relevance across name, method, path, description, and parameter names. This enables agents to discover the right endpoint without loading all command schemas into context. The legacy `ocli search` command is kept as a deprecated alias and internally forwards to `ocli commands` with the same flags.
106106

107107
### Installation and usage via npm and npx
108108

@@ -236,9 +236,8 @@ The `ocli` binary provides the following core commands:
236236
- `ocli profiles show <profile>` - show profile details;
237237
- `ocli profiles remove <profile>` - remove a profile;
238238
- `ocli use <profile>` - set the profile to use when `--profile` is not passed (writes profile name to `.ocli/current`).
239-
- `ocli commands` - list available commands generated from the current profile and its OpenAPI spec.
240-
- `ocli search --query <text>` - BM25-ranked search across commands by name, path, description.
241-
- `ocli search --regex <pattern>` - regex pattern search across commands.
239+
- `ocli commands` - list available commands generated from the current profile and its OpenAPI spec, optionally filter them with `--query` (BM25) or `--regex`.
240+
- `ocli search` - deprecated alias for `ocli commands` with `--query/--regex`, kept for backward compatibility.
242241
- `ocli --version` - print the CLI version baked at build time (derived from the latest git tag when available).
243242

244243
Help:
@@ -275,6 +274,7 @@ The project mirrors parts of the `openapi-to-mcp` architecture but implements a
275274
### Similar projects
276275

277276
- [openapi-cli-generator](https://github.com/danielgtaylor/openapi-cli-generator) - generates a CLI from an OpenAPI 3 specification using code generation.
277+
- [anything-llm-cli](https://github.com/Mintplex-Labs/anything-llm/tree/master/clients/anything-cli) - CLI for interacting with AnythingLLM, can consume HTTP APIs and tools.
278278
- [openapi-commander](https://github.com/bcoughlan/openapi-commander) - Node.js command-line tool generator based on OpenAPI definitions.
279279
- [OpenAPI Generator](https://openapi-generator.tech/docs/usage) - general-purpose OpenAPI code generator that can also generate CLI clients.
280280
- [openapi2cli](https://pypi.org/project/openapi2cli/) - Python tool that builds CLI interfaces for OpenAPI 3 APIs.

src/cli.ts

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -376,9 +376,27 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
376376
)
377377
.command(
378378
"commands",
379-
"List available commands for the current profile",
380-
(y) => y.version(false),
381-
async () => {
379+
"List available commands for the current profile (supports --query/--regex search filters)",
380+
(y) =>
381+
y
382+
.version(false)
383+
.option("query", {
384+
alias: "q",
385+
type: "string",
386+
description: "Natural language search query to filter commands",
387+
})
388+
.option("regex", {
389+
alias: "r",
390+
type: "string",
391+
description: "Regex pattern to filter commands by name, path, or description",
392+
})
393+
.option("limit", {
394+
alias: "n",
395+
type: "number",
396+
default: 10,
397+
description: "Maximum number of results when using query or regex filters",
398+
}),
399+
async (args) => {
382400
const profile = profileStore.getCurrentProfile(cwd);
383401
if (!profile) {
384402
throw new Error("No current profile configured");
@@ -390,33 +408,62 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
390408
return;
391409
}
392410

411+
const hasFilters = Boolean(args.query || args.regex);
412+
413+
if (!hasFilters) {
414+
stdout(`Available commands for profile ${profile.name}:\n\n`);
415+
416+
const maxNameLength = commands.reduce((max, cmd) => (cmd.name.length > max ? cmd.name.length : max), 0);
417+
const padding = maxNameLength + 2;
418+
419+
commands.forEach((cmd) => {
420+
const description = cmd.description ?? "";
421+
const namePadded = cmd.name.padEnd(padding, " ");
422+
stdout(` ${namePadded}${description}\n`);
423+
});
424+
return;
425+
}
426+
427+
const searcher = new CommandSearch();
428+
searcher.load(commands);
429+
430+
const limit = (args.limit as number) ?? 10;
431+
const results = args.query
432+
? searcher.search(args.query as string, limit)
433+
: searcher.searchRegex(args.regex as string, limit);
434+
435+
if (results.length === 0) {
436+
stdout("No commands found.\n");
437+
return;
438+
}
439+
393440
stdout(`Available commands for profile ${profile.name}:\n\n`);
394441

395-
const maxNameLength = commands.reduce((max, cmd) => (cmd.name.length > max ? cmd.name.length : max), 0);
442+
const maxNameLength = results.reduce((max, r) => (r.name.length > max ? r.name.length : max), 0);
396443
const padding = maxNameLength + 2;
397444

398-
commands.forEach((cmd) => {
399-
const description = cmd.description ?? "";
400-
const namePadded = cmd.name.padEnd(padding, " ");
445+
results.forEach((r) => {
446+
const description = r.description ?? "";
447+
const namePadded = r.name.padEnd(padding, " ");
401448
stdout(` ${namePadded}${description}\n`);
402449
});
403450
}
404451
)
405452
.command(
406453
"search",
407-
"Search commands by query (BM25) or regex pattern",
454+
"Deprecated: use 'commands --query/--regex' instead",
408455
(y) =>
409456
y
410457
.version(false)
411458
.option("query", {
412459
alias: "q",
413460
type: "string",
414-
description: "Natural language search query",
461+
description: "Natural language search query (deprecated, use commands --query instead)",
415462
})
416463
.option("regex", {
417464
alias: "r",
418465
type: "string",
419-
description: "Regex pattern to match command name, path, or description",
466+
description: "Regex pattern to match command name, path, or description (deprecated, use commands --regex instead)",
420467
})
421468
.option("limit", {
422469
alias: "n",
@@ -431,38 +478,26 @@ export async function run(argv: string[], options?: RunOptions): Promise<void> {
431478
return true;
432479
}),
433480
async (args) => {
434-
const profile = profileStore.getCurrentProfile(cwd);
435-
if (!profile) {
436-
throw new Error("No current profile configured");
437-
}
438-
439-
const spec = await openapiLoader.loadSpec(profile);
440-
const commands = openapiToCommands.buildCommands(spec, profile);
441-
442-
const searcher = new CommandSearch();
443-
searcher.load(commands);
481+
stdout("Warning: 'search' is deprecated, use 'commands --query/--regex' instead.\n\n");
444482

445483
const limit = (args.limit as number) ?? 10;
446-
const results = args.query
447-
? searcher.search(args.query as string, limit)
448-
: searcher.searchRegex(args.regex as string, limit);
449-
450-
if (results.length === 0) {
451-
stdout("No commands found.\n");
452-
return;
484+
const forwardedArgs: string[] = [];
485+
if (args.query) {
486+
forwardedArgs.push("--query", String(args.query));
453487
}
454-
455-
const maxName = results.reduce((m, r) => (r.name.length > m ? r.name.length : m), 0);
456-
const maxMethod = results.reduce((m, r) => (r.method.length > m ? r.method.length : m), 0);
457-
458-
stdout(`Found ${results.length} command(s):\n\n`);
459-
for (const r of results) {
460-
const name = r.name.padEnd(maxName + 2);
461-
const method = r.method.padEnd(maxMethod + 1);
462-
const desc = r.description ?? "";
463-
const scorePart = r.score < 1 ? ` [score: ${r.score}]` : "";
464-
stdout(` ${name}${method} ${r.path} ${desc}${scorePart}\n`);
488+
if (args.regex) {
489+
forwardedArgs.push("--regex", String(args.regex));
465490
}
491+
forwardedArgs.push("--limit", String(limit));
492+
493+
await run(["commands", ...forwardedArgs], {
494+
cwd,
495+
configLocator,
496+
profileStore,
497+
openapiLoader,
498+
stdout,
499+
httpClient,
500+
});
466501
}
467502
)
468503
.demandCommand(1, "")

tests/cli.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,112 @@ describe("cli", () => {
329329
expect(out).toContain("api_v1_search Search in content index");
330330
});
331331

332+
it("commands supports regex filtering of commands", async () => {
333+
const localDir = `${cwd}/.ocli`;
334+
const profilesPath = `${localDir}/profiles.ini`;
335+
const specPath = "/project/spec.json";
336+
const cachePath = `${localDir}/specs/messages-api.json`;
337+
338+
const spec = {
339+
openapi: "3.0.0",
340+
paths: {
341+
"/messages": {
342+
get: {
343+
summary: "List messages",
344+
},
345+
},
346+
"/status": {
347+
get: {
348+
summary: "Get status",
349+
},
350+
},
351+
},
352+
};
353+
354+
const iniContent = [
355+
"[messages-api]",
356+
"api_base_url = https://api.example.com",
357+
"api_basic_auth = ",
358+
"api_bearer_token = ",
359+
`openapi_spec_source = ${specPath}`,
360+
`openapi_spec_cache = ${cachePath}`,
361+
"include_endpoints = get:/messages,get:/status",
362+
"exclude_endpoints = ",
363+
"",
364+
].join("\n");
365+
366+
const log: string[] = [];
367+
const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, {
368+
[profilesPath]: iniContent,
369+
[`${localDir}/current`]: "messages-api",
370+
[specPath]: JSON.stringify(spec),
371+
});
372+
373+
await run(["commands", "--regex", "messages"], {
374+
cwd,
375+
profileStore,
376+
openapiLoader,
377+
stdout: (msg: string) => log.push(msg),
378+
});
379+
380+
const out = log.join("");
381+
expect(out).toContain("messages List messages");
382+
expect(out).not.toContain("status");
383+
});
384+
385+
it("commands supports BM25 query filtering of commands", async () => {
386+
const localDir = `${cwd}/.ocli`;
387+
const profilesPath = `${localDir}/profiles.ini`;
388+
const specPath = "/project/spec.json";
389+
const cachePath = `${localDir}/specs/messages-api.json`;
390+
391+
const spec = {
392+
openapi: "3.0.0",
393+
paths: {
394+
"/messages": {
395+
get: {
396+
summary: "List messages",
397+
},
398+
},
399+
"/status": {
400+
get: {
401+
summary: "Get status",
402+
},
403+
},
404+
},
405+
};
406+
407+
const iniContent = [
408+
"[messages-api]",
409+
"api_base_url = https://api.example.com",
410+
"api_basic_auth = ",
411+
"api_bearer_token = ",
412+
`openapi_spec_source = ${specPath}`,
413+
`openapi_spec_cache = ${cachePath}`,
414+
"include_endpoints = get:/messages,get:/status",
415+
"exclude_endpoints = ",
416+
"",
417+
].join("\n");
418+
419+
const log: string[] = [];
420+
const { profileStore, openapiLoader } = createCliDeps(cwd, homeDir, {
421+
[profilesPath]: iniContent,
422+
[`${localDir}/current`]: "messages-api",
423+
[specPath]: JSON.stringify(spec),
424+
});
425+
426+
await run(["commands", "--query", "list messages"], {
427+
cwd,
428+
profileStore,
429+
openapiLoader,
430+
stdout: (msg: string) => log.push(msg),
431+
});
432+
433+
const out = log.join("");
434+
expect(out).toContain("messages List messages");
435+
expect(out).not.toContain("status");
436+
});
437+
332438
it("commands add method suffix when spec path has multiple methods even if only one is included", async () => {
333439
const localDir = `${cwd}/.ocli`;
334440
const profilesPath = `${localDir}/profiles.ini`;

0 commit comments

Comments
 (0)