@effect-x/ultimate-search 0.1.0 → 0.1.1

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.
Files changed (39) hide show
  1. package/README.md +8 -3
  2. package/dist/cli.js +51 -51
  3. package/dist/cli.js.map +2 -2
  4. package/package.json +17 -13
  5. package/src/cli.ts +21 -0
  6. package/src/commands/fetch.ts +98 -0
  7. package/src/commands/map.ts +103 -0
  8. package/src/commands/mcp/stdio.ts +27 -0
  9. package/src/commands/mcp.ts +7 -0
  10. package/src/commands/root.ts +10 -0
  11. package/src/commands/search/dual.ts +91 -0
  12. package/src/commands/search/grok.ts +70 -0
  13. package/src/commands/search/tavily.ts +102 -0
  14. package/src/commands/search.ts +9 -0
  15. package/src/config/settings.ts +261 -0
  16. package/src/providers/firecrawl/client.ts +75 -0
  17. package/src/providers/firecrawl/schema.ts +31 -0
  18. package/src/providers/grok/client.ts +77 -0
  19. package/src/providers/grok/schema.ts +109 -0
  20. package/src/providers/tavily/client.ts +143 -0
  21. package/src/providers/tavily/schema.ts +207 -0
  22. package/src/services/dual-search.ts +150 -0
  23. package/src/services/firecrawl-fetch.ts +104 -0
  24. package/src/services/grok-search.ts +68 -0
  25. package/src/services/read-only-mcp.ts +278 -0
  26. package/src/services/tavily-extract.ts +66 -0
  27. package/src/services/tavily-map.ts +38 -0
  28. package/src/services/tavily-search.ts +40 -0
  29. package/src/services/web-fetch-schema.ts +74 -0
  30. package/src/services/web-fetch.ts +105 -0
  31. package/src/shared/cli-flags.ts +25 -0
  32. package/src/shared/command-output.ts +21 -0
  33. package/src/shared/effect.ts +52 -0
  34. package/src/shared/errors.ts +56 -0
  35. package/src/shared/output.ts +210 -0
  36. package/src/shared/provider-http-client.ts +73 -0
  37. package/src/shared/render-error.ts +101 -0
  38. package/src/shared/schema.ts +42 -0
  39. package/src/shared/tracing.ts +53 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-x/ultimate-search",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "CLI-first web search and MCP server for agents, built with Effect.",
5
5
  "keywords": [
6
6
  "agent",
@@ -12,12 +12,23 @@
12
12
  "tavily"
13
13
  ],
14
14
  "license": "MIT",
15
- "author": "effect-x contributors",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/effect-anything/ultimate-search"
18
+ },
19
+ "homepage": "https://github.com/effect-anything/ultimate-search#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/effect-anything/ultimate-search/issues"
22
+ },
23
+ "contributors": [
24
+ "xesrevinu"
25
+ ],
16
26
  "bin": {
17
- "ultimate-search": "./dist/cli.js"
27
+ "ultimate-search": "dist/cli.js"
18
28
  },
19
29
  "files": [
20
30
  "dist",
31
+ "src",
21
32
  "README.md",
22
33
  "LICENSE",
23
34
  "SKILL.md"
@@ -33,13 +44,7 @@
33
44
  "madge": "madge --circular --ts-config ./tsconfig.json --no-color --no-spinner --extensions 'ts,tsx' ./src",
34
45
  "check": "concurrently \"oxlint\" \"tsgo --noEmit\" \"bun run test --watch false\" \"bun run madge\" \"effect-language-service diagnostics --project tsconfig.json\"",
35
46
  "fix": "concurrently \"oxlint --fix\" \"oxfmt\"",
36
- "build": "node ./tools/build-cli.mjs",
37
- "prepack": "bun run build",
38
- "pack:check": "npm pack --dry-run",
39
- "release:check": "bun run check && bun run pack:check",
40
- "release:version": "changeset version",
41
- "release:publish": "changeset publish",
42
- "prepublishOnly": "bun run release:check"
47
+ "build": "node ./tools/build-cli.mjs"
43
48
  },
44
49
  "dependencies": {
45
50
  "@effect/platform-node": "4.0.0-beta.31"
@@ -60,7 +65,6 @@
60
65
  "typescript": "^5.9.3"
61
66
  },
62
67
  "engines": {
63
- "node": ">=20"
64
- },
65
- "packageManager": "bun@1.3.10"
68
+ "node": ">=24"
69
+ }
66
70
  }
package/src/cli.ts ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node";
3
+ import { ConfigProvider, Effect, Layer, Logger } from "effect";
4
+ import { Command } from "effect/unstable/cli";
5
+ import PackageJson from "../package.json" with { type: "json" };
6
+ import { commandRoot } from "./commands/root";
7
+ import { CliOutput, cliLoggerLayer } from "./shared/output";
8
+ import { TracingLayer } from "./shared/tracing";
9
+
10
+ const Live = Layer.mergeAll(
11
+ NodeServices.layer,
12
+ NodeHttpClient.layerFetch,
13
+ cliLoggerLayer,
14
+ CliOutput.layer,
15
+ Layer.succeed(Logger.LogToStderr, true),
16
+ ).pipe(Layer.provide([TracingLayer, ConfigProvider.layer(ConfigProvider.fromEnv())]));
17
+
18
+ NodeRuntime.runMain(
19
+ Command.run(commandRoot, { version: PackageJson.version }).pipe(Effect.provide(Live)),
20
+ { disableErrorReporting: true },
21
+ );
@@ -0,0 +1,98 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { UltimateSearchConfig } from "../config/settings";
4
+ import { FirecrawlProviderClient } from "../providers/firecrawl/client";
5
+ import {
6
+ FetchContentFormatSchema,
7
+ TavilyExtractDepthSchema,
8
+ } from "../providers/tavily/schema";
9
+ import { TavilyProviderClient } from "../providers/tavily/client";
10
+ import { FirecrawlFetch } from "../services/firecrawl-fetch";
11
+ import { TavilyExtract } from "../services/tavily-extract";
12
+ import { WebFetch } from "../services/web-fetch";
13
+ import { WebFetchInput, type WebFetchResult } from "../services/web-fetch-schema";
14
+ import { runCommandWithOutput } from "../shared/command-output";
15
+ import { outputFlag } from "../shared/output";
16
+ import { absoluteUrlStringSchema } from "../shared/schema";
17
+
18
+ const fetchCommandLayer = WebFetch.layer.pipe(
19
+ Layer.provideMerge(FirecrawlFetch.layer),
20
+ Layer.provideMerge(TavilyExtract.layer),
21
+ Layer.provideMerge(FirecrawlProviderClient.layer),
22
+ Layer.provideMerge(TavilyProviderClient.layer),
23
+ Layer.provideMerge(UltimateSearchConfig.layer),
24
+ );
25
+
26
+ const renderHumanFetchResult = (result: WebFetchResult) => {
27
+ const lines = [`Backend: ${result.backend}`];
28
+
29
+ if (result.fallback != null) {
30
+ lines.push(
31
+ `Fallback: ${result.fallback.from} -> ${result.fallback.to} (${result.fallback.reason.message})`,
32
+ );
33
+ }
34
+
35
+ for (const [index, page] of result.results.entries()) {
36
+ lines.push("");
37
+ lines.push(`URL: ${page.url}`);
38
+
39
+ if (page.title != null && page.title.trim().length > 0) {
40
+ lines.push(`Title: ${page.title.trim()}`);
41
+ }
42
+
43
+ lines.push("");
44
+ lines.push(page.raw_content);
45
+
46
+ if (index < result.results.length - 1) {
47
+ lines.push("", "---");
48
+ }
49
+ }
50
+
51
+ return lines.join("\n");
52
+ };
53
+
54
+ export const commandFetch = Command.make(
55
+ "fetch",
56
+ {
57
+ url: Flag.string("url").pipe(
58
+ Flag.withSchema(absoluteUrlStringSchema("url must be an absolute URL")),
59
+ Flag.withDescription("Target page URL."),
60
+ ),
61
+ depth: Flag.choice("depth", TavilyExtractDepthSchema.literals).pipe(
62
+ Flag.withDefault("basic"),
63
+ Flag.withDescription("Optional Tavily extract depth."),
64
+ ),
65
+ format: Flag.choice("format", FetchContentFormatSchema.literals).pipe(
66
+ Flag.withDefault("markdown"),
67
+ Flag.withDescription("Normalized content format to return."),
68
+ ),
69
+ output: outputFlag,
70
+ },
71
+ Effect.fn(function* (input) {
72
+ yield* runCommandWithOutput(input.output, (mode) =>
73
+ Effect.gen(function* () {
74
+ const request = yield* WebFetchInput.decodeEffect({
75
+ urls: [input.url],
76
+ depth: input.depth,
77
+ format: input.format,
78
+ });
79
+ const webFetch = yield* WebFetch;
80
+ const result = yield* webFetch.fetch(request);
81
+
82
+ return {
83
+ human: mode === "human" ? renderHumanFetchResult(result) : "",
84
+ llm: result,
85
+ };
86
+ }),
87
+ );
88
+ }),
89
+ ).pipe(
90
+ Command.withDescription("Fetch and normalize page content from a URL."),
91
+ Command.withExamples([
92
+ {
93
+ command: 'ultimate-search fetch --url "https://example.com"',
94
+ description: "Fetch a page with Tavily first and FireCrawl as fallback.",
95
+ },
96
+ ]),
97
+ Command.provide(fetchCommandLayer),
98
+ );
@@ -0,0 +1,103 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { UltimateSearchConfig } from "../config/settings";
4
+ import {
5
+ TavilyMapBreadthSchema,
6
+ TavilyMapDepthSchema,
7
+ TavilyMapInput,
8
+ TavilyMapLimitSchema,
9
+ type TavilyMapResponse,
10
+ } from "../providers/tavily/schema";
11
+ import { TavilyProviderClient } from "../providers/tavily/client";
12
+ import { TavilyMap } from "../services/tavily-map";
13
+ import {
14
+ optionalIntegerFlagWithSchema,
15
+ optionalTrimmedTextFlag,
16
+ } from "../shared/cli-flags";
17
+ import { runCommandWithOutput } from "../shared/command-output";
18
+ import { outputFlag } from "../shared/output";
19
+ import { absoluteUrlStringSchema } from "../shared/schema";
20
+
21
+ const mapCommandLayer = TavilyMap.layer.pipe(
22
+ Layer.provideMerge(TavilyProviderClient.layer),
23
+ Layer.provideMerge(UltimateSearchConfig.layer),
24
+ );
25
+
26
+ const renderHumanMapResult = (result: TavilyMapResponse) => {
27
+ const lines = [
28
+ `Base URL: ${result.base_url}`,
29
+ `Discovered URLs: ${result.results.length}`,
30
+ ];
31
+
32
+ if (result.response_time !== undefined) {
33
+ lines.push(`Response time: ${result.response_time}s`);
34
+ }
35
+
36
+ if (result.usage?.credits_used !== undefined) {
37
+ lines.push(`Credits used: ${result.usage.credits_used}`);
38
+ }
39
+
40
+ if (result.results.length > 0) {
41
+ lines.push("", ...result.results.map((url) => `- ${url}`));
42
+ }
43
+
44
+ return lines.join("\n");
45
+ };
46
+
47
+ export const commandMap = Command.make(
48
+ "map",
49
+ {
50
+ url: Flag.string("url").pipe(
51
+ Flag.withSchema(absoluteUrlStringSchema("url must be an absolute URL")),
52
+ Flag.withDescription("Root URL to map with Tavily."),
53
+ ),
54
+ depth: optionalIntegerFlagWithSchema(
55
+ "depth",
56
+ TavilyMapDepthSchema,
57
+ "Optional crawl depth between 1 and 5.",
58
+ ),
59
+ breadth: optionalIntegerFlagWithSchema(
60
+ "breadth",
61
+ TavilyMapBreadthSchema,
62
+ "Optional crawl breadth between 1 and 500.",
63
+ ),
64
+ limit: optionalIntegerFlagWithSchema(
65
+ "limit",
66
+ TavilyMapLimitSchema,
67
+ "Optional maximum number of discovered URLs to return.",
68
+ ),
69
+ instructions: optionalTrimmedTextFlag(
70
+ "instructions",
71
+ "Optional guidance for how Tavily should explore the site.",
72
+ ),
73
+ output: outputFlag,
74
+ },
75
+ Effect.fn(function* (input) {
76
+ yield* runCommandWithOutput(input.output, () =>
77
+ Effect.gen(function* () {
78
+ const request = yield* TavilyMapInput.decodeEffect(input);
79
+ const tavilyMap = yield* TavilyMap;
80
+ const result = yield* tavilyMap.map(request);
81
+
82
+ return {
83
+ human: renderHumanMapResult(result),
84
+ llm: result,
85
+ };
86
+ }),
87
+ );
88
+ }),
89
+ ).pipe(
90
+ Command.withDescription("Map a site's reachable URLs with Tavily."),
91
+ Command.withExamples([
92
+ {
93
+ command:
94
+ 'ultimate-search map --url "https://fastapi.tiangolo.com" --depth 2 --breadth 20 --limit 50',
95
+ description: "Discover reachable URLs for a site with Tavily map.",
96
+ },
97
+ {
98
+ command: 'ultimate-search map --url "https://fastapi.tiangolo.com" --output llm',
99
+ description: "Emit structured JSON for agent-driven workflows.",
100
+ },
101
+ ]),
102
+ Command.provide(mapCommandLayer),
103
+ );
@@ -0,0 +1,27 @@
1
+ import { Cause, Effect, Layer } from "effect";
2
+ import { McpServer } from "effect/unstable/ai";
3
+ import { Command } from "effect/unstable/cli";
4
+ import PackageJson from "../../../package.json" with { type: "json" };
5
+ import {
6
+ readOnlyMcpRegistrationLayer,
7
+ readOnlyMcpServicesLayer,
8
+ } from "../../services/read-only-mcp";
9
+
10
+ const mcpStdioServerLayer = readOnlyMcpRegistrationLayer.pipe(
11
+ Layer.provideMerge(
12
+ McpServer.layerStdio({
13
+ name: "ultimate-search",
14
+ version: PackageJson.version,
15
+ }),
16
+ ),
17
+ Layer.provideMerge(readOnlyMcpServicesLayer),
18
+ );
19
+
20
+ export const commandMcpStdio = Command.make("stdio").pipe(
21
+ Command.withDescription("Serve the MCP protocol over stdio."),
22
+ Command.withHandler(() =>
23
+ Layer.launch(mcpStdioServerLayer).pipe(
24
+ Effect.catchCauseIf(Cause.hasInterruptsOnly, () => Effect.void),
25
+ ),
26
+ ),
27
+ );
@@ -0,0 +1,7 @@
1
+ import { Command } from "effect/unstable/cli";
2
+ import { commandMcpStdio } from "./mcp/stdio";
3
+
4
+ export const commandMcp = Command.make("mcp").pipe(
5
+ Command.withDescription("Expose ultimate-search over MCP transports."),
6
+ Command.withSubcommands([commandMcpStdio]),
7
+ );
@@ -0,0 +1,10 @@
1
+ import { Command } from "effect/unstable/cli";
2
+ import { commandFetch } from "./fetch";
3
+ import { commandMap } from "./map";
4
+ import { commandMcp } from "./mcp";
5
+ import { commandSearch } from "./search";
6
+
7
+ export const commandRoot = Command.make("ultimate-search").pipe(
8
+ Command.withDescription("CLI entrypoint for search, fetch, map, and MCP workflows."),
9
+ Command.withSubcommands([commandSearch, commandFetch, commandMap, commandMcp]),
10
+ );
@@ -0,0 +1,91 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { UltimateSearchConfig } from "../../config/settings";
4
+ import { GrokProviderClient } from "../../providers/grok/client";
5
+ import {
6
+ TavilySearchDepthSchema,
7
+ TavilySearchTopicSchema,
8
+ TavilyTimeRangeSchema,
9
+ } from "../../providers/tavily/schema";
10
+ import { TavilyProviderClient } from "../../providers/tavily/client";
11
+ import { DualSearch, DualSearchInput } from "../../services/dual-search";
12
+ import { GrokSearch } from "../../services/grok-search";
13
+ import { TavilySearch } from "../../services/tavily-search";
14
+ import {
15
+ optionalChoiceFlag,
16
+ optionalIntegerFlag,
17
+ optionalTrimmedTextFlag,
18
+ } from "../../shared/cli-flags";
19
+ import { runCommandWithOutput } from "../../shared/command-output";
20
+ import { outputFlag, renderJsonText } from "../../shared/output";
21
+ import { trimmedNonEmptyStringSchema } from "../../shared/schema";
22
+
23
+ const dualCommandLayer = DualSearch.layer.pipe(
24
+ Layer.provideMerge(GrokSearch.layer),
25
+ Layer.provideMerge(TavilySearch.layer),
26
+ Layer.provideMerge(GrokProviderClient.layer),
27
+ Layer.provideMerge(TavilyProviderClient.layer),
28
+ Layer.provideMerge(UltimateSearchConfig.layer),
29
+ );
30
+
31
+ export const commandSearchDual = Command.make(
32
+ "dual",
33
+ {
34
+ query: Flag.string("query").pipe(
35
+ Flag.withSchema(trimmedNonEmptyStringSchema("query must be a non-empty string")),
36
+ Flag.withDescription("Search query to send to both Grok and Tavily."),
37
+ ),
38
+ platform: optionalTrimmedTextFlag(
39
+ "platform",
40
+ "Optional platform focus for the Grok branch such as GitHub or Reddit.",
41
+ ),
42
+ model: optionalTrimmedTextFlag("model", "Override the configured Grok model."),
43
+ searchDepth: optionalChoiceFlag(
44
+ "depth",
45
+ TavilySearchDepthSchema.literals,
46
+ "Optional Tavily search depth.",
47
+ ),
48
+ maxResults: optionalIntegerFlag(
49
+ "max-results",
50
+ "Optional number of Tavily results to return.",
51
+ ),
52
+ topic: optionalChoiceFlag(
53
+ "topic",
54
+ TavilySearchTopicSchema.literals,
55
+ "Optional Tavily topic focus.",
56
+ ),
57
+ timeRange: optionalChoiceFlag(
58
+ "time-range",
59
+ TavilyTimeRangeSchema.literals,
60
+ "Optional Tavily recency window.",
61
+ ),
62
+ includeAnswer: Flag.boolean("include-answer").pipe(
63
+ Flag.withDescription("Request a synthesized Tavily answer in the response."),
64
+ ),
65
+ output: outputFlag,
66
+ },
67
+ Effect.fn(function* (input) {
68
+ yield* runCommandWithOutput(input.output, () =>
69
+ Effect.gen(function* () {
70
+ const request = yield* DualSearchInput.decodeEffect(input);
71
+ const dualSearch = yield* DualSearch;
72
+ const result = yield* dualSearch.search(request);
73
+
74
+ return {
75
+ human: renderJsonText(result),
76
+ llm: result,
77
+ };
78
+ }),
79
+ );
80
+ }),
81
+ ).pipe(
82
+ Command.withDescription("Run Grok and Tavily search concurrently."),
83
+ Command.withExamples([
84
+ {
85
+ command:
86
+ 'ultimate-search search dual --query "FastAPI latest features" --depth advanced --include-answer',
87
+ description: "Run both providers and merge their status-tagged results into one JSON object.",
88
+ },
89
+ ]),
90
+ Command.provide(dualCommandLayer),
91
+ );
@@ -0,0 +1,70 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { UltimateSearchConfig } from "../../config/settings";
4
+ import { GrokProviderClient } from "../../providers/grok/client";
5
+ import { GrokSearchInput, type GrokSearchResult } from "../../providers/grok/schema";
6
+ import { GrokSearch } from "../../services/grok-search";
7
+ import { optionalTrimmedTextFlag } from "../../shared/cli-flags";
8
+ import { runCommandWithOutput } from "../../shared/command-output";
9
+ import { outputFlag } from "../../shared/output";
10
+ import { trimmedNonEmptyStringSchema } from "../../shared/schema";
11
+
12
+ const grokCommandLayer = GrokSearch.layer.pipe(
13
+ Layer.provideMerge(GrokProviderClient.layer),
14
+ Layer.provideMerge(UltimateSearchConfig.layer),
15
+ );
16
+
17
+ const renderHumanGrokResult = (result: GrokSearchResult) =>
18
+ [
19
+ result.content.trim().length > 0 ? result.content.trim() : "Grok returned an empty response.",
20
+ "",
21
+ `Model: ${result.model}`,
22
+ `Tokens: ${result.usage.total_tokens} total (${result.usage.prompt_tokens} prompt, ${result.usage.completion_tokens} completion)`,
23
+ ].join("\n");
24
+
25
+ export const commandSearchGrok = Command.make(
26
+ "grok",
27
+ {
28
+ query: Flag.string("query").pipe(
29
+ Flag.withSchema(trimmedNonEmptyStringSchema("query must be a non-empty string")),
30
+ Flag.withDescription("Search query to send to Grok."),
31
+ ),
32
+ platform: optionalTrimmedTextFlag(
33
+ "platform",
34
+ "Optional platform focus such as GitHub or Reddit.",
35
+ ),
36
+ model: optionalTrimmedTextFlag("model", "Override the configured Grok model."),
37
+ output: outputFlag,
38
+ },
39
+ Effect.fn(function* (input) {
40
+ yield* runCommandWithOutput(input.output, () =>
41
+ Effect.gen(function* () {
42
+ const request = yield* GrokSearchInput.decodeEffect({
43
+ query: input.query,
44
+ platform: input.platform,
45
+ model: input.model,
46
+ });
47
+ const grokSearch = yield* GrokSearch;
48
+ const result = yield* grokSearch.search(request);
49
+
50
+ return {
51
+ human: renderHumanGrokResult(result),
52
+ llm: result,
53
+ };
54
+ }),
55
+ );
56
+ }),
57
+ ).pipe(
58
+ Command.withDescription("Run Grok-backed search."),
59
+ Command.withExamples([
60
+ {
61
+ command: 'ultimate-search search grok --query "FastAPI latest features"',
62
+ description: "Run a Grok-backed web search with the configured model.",
63
+ },
64
+ {
65
+ command: 'ultimate-search search grok --query "FastAPI latest features" --output llm',
66
+ description: "Emit structured JSON for agent-driven workflows.",
67
+ },
68
+ ]),
69
+ Command.provide(grokCommandLayer),
70
+ );
@@ -0,0 +1,102 @@
1
+ import { Effect, Layer } from "effect";
2
+ import { Command, Flag } from "effect/unstable/cli";
3
+ import { UltimateSearchConfig } from "../../config/settings";
4
+ import {
5
+ TavilySearchDepthSchema,
6
+ TavilySearchInput,
7
+ type TavilySearchResponse,
8
+ TavilySearchTopicSchema,
9
+ TavilyTimeRangeSchema,
10
+ } from "../../providers/tavily/schema";
11
+ import { TavilyProviderClient } from "../../providers/tavily/client";
12
+ import { TavilySearch } from "../../services/tavily-search";
13
+ import { optionalChoiceFlag, optionalIntegerFlag } from "../../shared/cli-flags";
14
+ import { runCommandWithOutput } from "../../shared/command-output";
15
+ import { outputFlag } from "../../shared/output";
16
+ import { trimmedNonEmptyStringSchema } from "../../shared/schema";
17
+
18
+ const tavilyCommandLayer = TavilySearch.layer.pipe(
19
+ Layer.provideMerge(TavilyProviderClient.layer),
20
+ Layer.provideMerge(UltimateSearchConfig.layer),
21
+ );
22
+
23
+ const renderHumanTavilyResult = (result: TavilySearchResponse) => {
24
+ const lines: Array<string> = [];
25
+
26
+ if (result.answer != null && result.answer.trim().length > 0) {
27
+ lines.push(result.answer.trim(), "");
28
+ }
29
+
30
+ if (result.response_time != null) {
31
+ lines.push(`Response time: ${result.response_time}s`, "");
32
+ }
33
+
34
+ for (const [index, item] of result.results.entries()) {
35
+ lines.push(`${index + 1}. ${item.title}`);
36
+ lines.push(item.url);
37
+ lines.push(item.content.trim());
38
+
39
+ if (index < result.results.length - 1) {
40
+ lines.push("");
41
+ }
42
+ }
43
+
44
+ return lines.join("\n").trim();
45
+ };
46
+
47
+ export const commandSearchTavily = Command.make(
48
+ "tavily",
49
+ {
50
+ query: Flag.string("query").pipe(
51
+ Flag.withSchema(trimmedNonEmptyStringSchema("query must be a non-empty string")),
52
+ Flag.withDescription("Search query to send to Tavily."),
53
+ ),
54
+ searchDepth: optionalChoiceFlag(
55
+ "depth",
56
+ TavilySearchDepthSchema.literals,
57
+ "Optional Tavily search depth.",
58
+ ),
59
+ maxResults: optionalIntegerFlag(
60
+ "max-results",
61
+ "Optional number of Tavily results to return.",
62
+ ),
63
+ topic: optionalChoiceFlag(
64
+ "topic",
65
+ TavilySearchTopicSchema.literals,
66
+ "Optional Tavily topic focus.",
67
+ ),
68
+ timeRange: optionalChoiceFlag(
69
+ "time-range",
70
+ TavilyTimeRangeSchema.literals,
71
+ "Optional Tavily recency window.",
72
+ ),
73
+ includeAnswer: Flag.boolean("include-answer").pipe(
74
+ Flag.withDescription("Request a synthesized Tavily answer in the response."),
75
+ ),
76
+ output: outputFlag,
77
+ },
78
+ Effect.fn(function* (input) {
79
+ yield* runCommandWithOutput(input.output, (mode) =>
80
+ Effect.gen(function* () {
81
+ const request = yield* TavilySearchInput.decodeEffect(input);
82
+ const tavilySearch = yield* TavilySearch;
83
+ const result = yield* tavilySearch.search(request);
84
+
85
+ return {
86
+ human: mode === "human" ? renderHumanTavilyResult(result) : "",
87
+ llm: result,
88
+ };
89
+ }),
90
+ );
91
+ }),
92
+ ).pipe(
93
+ Command.withDescription("Run Tavily-backed search."),
94
+ Command.withExamples([
95
+ {
96
+ command:
97
+ 'ultimate-search search tavily --query "FastAPI latest features" --depth advanced --max-results 5',
98
+ description: "Run a Tavily-backed web search with optional search tuning.",
99
+ },
100
+ ]),
101
+ Command.provide(tavilyCommandLayer),
102
+ );
@@ -0,0 +1,9 @@
1
+ import { Command } from "effect/unstable/cli";
2
+ import { commandSearchDual } from "./search/dual";
3
+ import { commandSearchGrok } from "./search/grok";
4
+ import { commandSearchTavily } from "./search/tavily";
5
+
6
+ export const commandSearch = Command.make("search").pipe(
7
+ Command.withDescription("Search the web with one or more providers."),
8
+ Command.withSubcommands([commandSearchGrok, commandSearchTavily, commandSearchDual]),
9
+ );