@oh-my-tool/cli 0.2.0 → 0.3.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 (49) hide show
  1. package/README.md +7 -2
  2. package/assets/skills/oh-my-tool/SKILL.md +11 -3
  3. package/bin/ohmytool.cjs +0 -0
  4. package/package.json +20 -11
  5. package/src/cli/commands/connections.ts +98 -0
  6. package/src/cli/commands/describe.ts +23 -22
  7. package/src/cli/commands/extension.ts +24 -24
  8. package/src/cli/commands/index.ts +8 -6
  9. package/src/cli/commands/integrate.ts +64 -64
  10. package/src/cli/commands/mcp.ts +86 -0
  11. package/src/cli/commands/run.ts +14 -8
  12. package/src/cli/commands/search.ts +14 -13
  13. package/src/cli/commands/secret.ts +68 -68
  14. package/src/cli/context.ts +37 -4
  15. package/src/cli/index.ts +338 -273
  16. package/src/cli/output.ts +165 -0
  17. package/src/cli/parseArgs.ts +64 -44
  18. package/src/config/config.ts +207 -63
  19. package/src/core/executor.ts +89 -89
  20. package/src/core/registry.ts +31 -31
  21. package/src/core/result.ts +14 -14
  22. package/src/extension/discovery.ts +61 -61
  23. package/src/extension/install.ts +23 -23
  24. package/src/extension/loader.ts +32 -32
  25. package/src/extension/manifest.ts +114 -114
  26. package/src/integration/adapters.ts +98 -98
  27. package/src/integration/index.ts +4 -4
  28. package/src/integration/manager.ts +375 -375
  29. package/src/integration/skill.ts +84 -84
  30. package/src/integration/types.ts +55 -55
  31. package/src/policy/policy.ts +136 -136
  32. package/src/runtime/errors.ts +7 -2
  33. package/src/runtime/executor.ts +6 -1
  34. package/src/runtime/provider.ts +2 -1
  35. package/src/runtime/providers/mcp/normalize.ts +36 -0
  36. package/src/runtime/providers/mcp/oauth-callback.ts +91 -0
  37. package/src/runtime/providers/mcp/oauth-provider.ts +348 -0
  38. package/src/runtime/providers/mcp/oauth-store.ts +106 -0
  39. package/src/runtime/providers/mcp/provider.ts +99 -0
  40. package/src/runtime/providers/mcp/safe-errors.ts +63 -0
  41. package/src/runtime/providers/mcp/session.ts +117 -0
  42. package/src/runtime/providers/mcp/transport.ts +140 -0
  43. package/src/runtime/providers/native/provider.ts +1 -1
  44. package/src/runtime/result.ts +1 -1
  45. package/src/runtime/runtime.ts +38 -12
  46. package/src/runtime/schema.ts +14 -4
  47. package/src/search/search.ts +78 -78
  48. package/src/secrets/secrets.ts +45 -45
  49. package/src/version.ts +1 -1
@@ -0,0 +1,63 @@
1
+ import type { McpServerConfig } from "../../../config/config";
2
+ import { RuntimeError } from "../../errors";
3
+
4
+ export function redactMcpText(message: string, secretValues: readonly string[]): string {
5
+ return secretValues.reduce(
6
+ (result, value) => value.length === 0 ? result : result.split(value).join("[REDACTED]"),
7
+ message,
8
+ );
9
+ }
10
+
11
+ function sdkDetails(cause: unknown, secretValues: readonly string[]): string {
12
+ if (!(cause instanceof Error)) return "unknown MCP error";
13
+ const error = cause as Error & { code?: unknown };
14
+ const code = typeof error.code === "string" ? ` (${error.code})` : "";
15
+ return `${code} ${redactMcpText(cause.message, secretValues)}`.trim();
16
+ }
17
+
18
+ export function configuredMcpValues(config: McpServerConfig): string[] {
19
+ const runtimeConfig = config as unknown as { env?: unknown; headers?: unknown };
20
+ return [runtimeConfig.env, runtimeConfig.headers].flatMap((values) => {
21
+ if (values === null || typeof values !== "object" || Array.isArray(values)) return [];
22
+ return Object.values(values).filter((value): value is string => typeof value === "string");
23
+ });
24
+ }
25
+
26
+ export function mcpConnectionError(
27
+ serverId: string,
28
+ cause: unknown,
29
+ secretValues: readonly string[] = [],
30
+ ): RuntimeError {
31
+ return new RuntimeError(
32
+ "MCP_CONNECTION_FAILED",
33
+ `MCP server '${serverId}' connection failed: ${sdkDetails(cause, secretValues)}`,
34
+ undefined,
35
+ { cause },
36
+ );
37
+ }
38
+
39
+ export function mcpRequestError(
40
+ serverId: string,
41
+ operation: "tools/list" | "tools/call",
42
+ cause: unknown,
43
+ secretValues: readonly string[] = [],
44
+ ): RuntimeError {
45
+ const code = operation === "tools/list" ? "MCP_LIST_TOOLS_FAILED" : "MCP_CALL_FAILED";
46
+ return new RuntimeError(
47
+ code,
48
+ `MCP server '${serverId}' ${operation} failed: ${sdkDetails(cause, secretValues)}`,
49
+ undefined,
50
+ { cause },
51
+ );
52
+ }
53
+
54
+ export function normalizeMcpError(
55
+ serverId: string,
56
+ cause: unknown,
57
+ secretValues: readonly string[] = [],
58
+ ): RuntimeError {
59
+ if (!(cause instanceof RuntimeError)) return mcpConnectionError(serverId, cause, secretValues);
60
+ const message = redactMcpText(cause.message, secretValues);
61
+ if (message === cause.message) return cause;
62
+ return new RuntimeError(cause.code, message, cause.details, { cause: cause.cause ?? cause });
63
+ }
@@ -0,0 +1,117 @@
1
+ import { Client, type CallToolResult, type Tool } from "@modelcontextprotocol/client";
2
+ import type { McpEnabledServerConfig } from "../../../config/config";
3
+ import { VERSION } from "../../../version";
4
+ import type { SecretStore } from "@oh-my-tool/sdk";
5
+ import { RuntimeError } from "../../errors";
6
+ import {
7
+ createMcpTransport,
8
+ type McpTransport,
9
+ type OAuthAuthProviderFactory,
10
+ McpTransportSetupError,
11
+ } from "./transport";
12
+ import { createMcpOAuthProvider } from "./oauth-provider";
13
+ import {
14
+ configuredMcpValues,
15
+ mcpConnectionError,
16
+ mcpRequestError,
17
+ } from "./safe-errors";
18
+
19
+ export interface McpSession {
20
+ listTools(cursor?: string): Promise<{ tools: readonly Tool[]; nextCursor?: string }>;
21
+ callTool(name: string, args: Record<string, unknown>): Promise<CallToolResult>;
22
+ close(): Promise<void>;
23
+ }
24
+
25
+ export type McpSessionFactory = (serverId: string, config: McpEnabledServerConfig, secrets: SecretStore) => Promise<McpSession>;
26
+
27
+ export interface McpClient {
28
+ connect(transport: McpTransport): Promise<void>;
29
+ listTools(params: { cursor?: string }): Promise<{ tools: Tool[]; nextCursor?: string }>;
30
+ callTool(params: { name: string; arguments: Record<string, unknown> }): Promise<CallToolResult>;
31
+ close(): Promise<void>;
32
+ }
33
+
34
+ export interface McpSessionDependencies {
35
+ clientVersion: string;
36
+ createClient(info: { name: string; version: string }): McpClient;
37
+ createTransport: typeof createMcpTransport;
38
+ oauthAuthProviderFactory?: OAuthAuthProviderFactory;
39
+ }
40
+
41
+ const defaults: McpSessionDependencies = {
42
+ clientVersion: VERSION,
43
+ createClient: (info) => new Client(info),
44
+ createTransport: createMcpTransport,
45
+ oauthAuthProviderFactory: (serverId, config, secrets) => createMcpOAuthProvider(serverId, config, secrets),
46
+ };
47
+
48
+ function isMissingSecretError(cause: unknown): cause is RuntimeError {
49
+ return cause instanceof RuntimeError && cause.code === "MCP_SECRET_NOT_FOUND";
50
+ }
51
+
52
+ function isPreservedOAuthError(cause: unknown): cause is RuntimeError {
53
+ return cause instanceof RuntimeError && (
54
+ cause.code === "MCP_AUTH_REQUIRED" || cause.code === "MCP_OAUTH_CREDENTIALS_INVALID"
55
+ );
56
+ }
57
+
58
+ async function closeQuietly(transport: McpTransport): Promise<void> {
59
+ try {
60
+ await transport.close();
61
+ } catch {
62
+ // The original MCP setup or connection error is more useful than cleanup failure.
63
+ }
64
+ }
65
+
66
+ export async function createMcpSession(
67
+ serverId: string,
68
+ config: McpEnabledServerConfig,
69
+ secrets: SecretStore,
70
+ dependencies: McpSessionDependencies = defaults,
71
+ ): Promise<McpSession> {
72
+ let client: McpClient;
73
+ try {
74
+ client = dependencies.createClient({ name: "oh-my-tool", version: dependencies.clientVersion });
75
+ } catch (cause) {
76
+ throw mcpConnectionError(serverId, cause, configuredMcpValues(config));
77
+ }
78
+ let connection;
79
+ try {
80
+ connection = await dependencies.createTransport(serverId, config, secrets, dependencies.oauthAuthProviderFactory);
81
+ } catch (cause) {
82
+ if (isMissingSecretError(cause) || isPreservedOAuthError(cause)) throw cause;
83
+ const setupCause = cause instanceof McpTransportSetupError ? cause.cause : cause;
84
+ const secretValues = cause instanceof McpTransportSetupError ? cause.secretValues : [];
85
+ throw mcpConnectionError(serverId, setupCause, [...configuredMcpValues(config), ...secretValues]);
86
+ }
87
+ const secretValues = [...configuredMcpValues(config), ...connection.secretValues];
88
+ try {
89
+ await client.connect(connection.transport);
90
+ } catch (cause) {
91
+ await closeQuietly(connection.transport);
92
+ if (isPreservedOAuthError(cause)) throw cause;
93
+ throw mcpConnectionError(serverId, cause, secretValues);
94
+ }
95
+ let closed = false;
96
+ return {
97
+ async listTools(cursor) {
98
+ try {
99
+ return await client.listTools({ ...(cursor === undefined ? {} : { cursor }) });
100
+ } catch (cause) {
101
+ throw mcpRequestError(serverId, "tools/list", cause, secretValues);
102
+ }
103
+ },
104
+ async callTool(name, args) {
105
+ try {
106
+ return await client.callTool({ name, arguments: args });
107
+ } catch (cause) {
108
+ throw mcpRequestError(serverId, "tools/call", cause, secretValues);
109
+ }
110
+ },
111
+ async close() {
112
+ if (closed) return;
113
+ closed = true;
114
+ await client.close();
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ StreamableHTTPClientTransport,
3
+ type AuthProvider,
4
+ type OAuthClientProvider,
5
+ type Transport,
6
+ } from "@modelcontextprotocol/client";
7
+ import {
8
+ StdioClientTransport,
9
+ getDefaultEnvironment,
10
+ type StdioServerParameters,
11
+ } from "@modelcontextprotocol/client/stdio";
12
+ import type { McpEnabledServerConfig, McpHttpServerConfig } from "../../../config/config";
13
+ import { RuntimeError } from "../../errors";
14
+ import type { SecretStore } from "@oh-my-tool/sdk";
15
+
16
+ export type McpTransport = Transport & { readonly kind?: "stdio" | "streamable-http"; readonly options?: Record<string, unknown> };
17
+
18
+ export type OAuthMcpServerConfig = McpHttpServerConfig & { readonly auth: Extract<McpHttpServerConfig["auth"], { type: "oauth" }> };
19
+
20
+ export type OAuthAuthProviderFactory = (
21
+ serverId: string,
22
+ config: OAuthMcpServerConfig,
23
+ secrets: SecretStore,
24
+ ) => Promise<AuthProvider | OAuthClientProvider>;
25
+
26
+ export interface McpTransportDependencies {
27
+ getDefaultEnvironment(): Record<string, string>;
28
+ createStdioTransport(options: StdioServerParameters): McpTransport;
29
+ createHttpTransport(url: URL, options: { authProvider?: AuthProvider | OAuthClientProvider; requestInit: { headers: Record<string, string> } }): McpTransport;
30
+ }
31
+
32
+ export interface McpTransportConnection {
33
+ transport: McpTransport;
34
+ secretValues: readonly string[];
35
+ }
36
+
37
+ export class McpTransportSetupError extends Error {
38
+ constructor(public readonly cause: unknown, public readonly secretValues: readonly string[]) {
39
+ super(cause instanceof Error ? cause.message : "MCP transport setup failed", { cause });
40
+ this.name = "McpTransportSetupError";
41
+ }
42
+ }
43
+
44
+ const defaults: McpTransportDependencies = {
45
+ getDefaultEnvironment,
46
+ createStdioTransport: (options) => new StdioClientTransport(options),
47
+ createHttpTransport: (url, options) => new StreamableHTTPClientTransport(url, options),
48
+ };
49
+
50
+ function hasAuthorizationHeader(headers: Readonly<Record<string, string>>): boolean {
51
+ return Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
52
+ }
53
+
54
+ function authProviderSecretValues(provider: AuthProvider | OAuthClientProvider): string[] {
55
+ const values = (provider as { readonly secretValues?: unknown }).secretValues;
56
+ return Array.isArray(values) && values.every((value) => typeof value === "string") ? [...values] : [];
57
+ }
58
+
59
+ async function requiredSecret(serverId: string, name: string, secrets: SecretStore): Promise<string> {
60
+ const value = await secrets.get(name);
61
+ if (value === undefined) {
62
+ throw new RuntimeError("MCP_SECRET_NOT_FOUND", `MCP server '${serverId}' requires missing secret '${name}'`);
63
+ }
64
+ return value;
65
+ }
66
+
67
+ function isPreservedOAuthSetupError(cause: unknown): cause is RuntimeError {
68
+ return cause instanceof RuntimeError && (
69
+ cause.code === "MCP_SECRET_NOT_FOUND" ||
70
+ cause.code === "MCP_AUTH_REQUIRED" ||
71
+ cause.code === "MCP_OAUTH_CREDENTIALS_INVALID"
72
+ );
73
+ }
74
+
75
+ export async function createMcpTransport(
76
+ serverId: string,
77
+ config: McpEnabledServerConfig,
78
+ secrets: SecretStore,
79
+ oauthAuthProviderFactory?: OAuthAuthProviderFactory,
80
+ dependencies: McpTransportDependencies = defaults,
81
+ ): Promise<McpTransportConnection> {
82
+ const transportKind = (config as { transport: string }).transport;
83
+ if (transportKind !== "stdio" && transportKind !== "streamable-http") {
84
+ throw new RuntimeError("MCP_UNSUPPORTED_TRANSPORT", `MCP server '${serverId}' has unsupported transport '${transportKind}'`);
85
+ }
86
+ if (config.transport === "stdio") {
87
+ const resolvedSecretEnv = Object.fromEntries(await Promise.all(
88
+ Object.entries(config.secretEnv).map(async ([name, secret]) => [name, await requiredSecret(serverId, secret, secrets)]),
89
+ )) as Record<string, string>;
90
+ const secretValues = Object.values(resolvedSecretEnv);
91
+ try {
92
+ return {
93
+ transport: dependencies.createStdioTransport({
94
+ command: config.command,
95
+ args: [...config.args],
96
+ cwd: config.cwd,
97
+ env: { ...dependencies.getDefaultEnvironment(), ...config.env, ...resolvedSecretEnv },
98
+ stderr: "pipe",
99
+ }),
100
+ secretValues,
101
+ };
102
+ } catch (cause) {
103
+ throw new McpTransportSetupError(cause, secretValues);
104
+ }
105
+ }
106
+
107
+ if (config.auth.type !== "none" && (hasAuthorizationHeader(config.headers) || hasAuthorizationHeader(config.secretHeaders))) {
108
+ throw new RuntimeError("MCP_INVALID_CONFIG", `MCP server '${serverId}' must not configure Authorization when ${config.auth.type} auth is enabled`);
109
+ }
110
+
111
+ const resolvedSecretHeaders = Object.fromEntries(await Promise.all(
112
+ Object.entries(config.secretHeaders).map(async ([name, secret]) => [name, await requiredSecret(serverId, secret, secrets)]),
113
+ )) as Record<string, string>;
114
+ const secretValues = [...Object.values(resolvedSecretHeaders)];
115
+ try {
116
+ let authProvider: AuthProvider | OAuthClientProvider | undefined;
117
+ if (config.auth.type === "bearer") {
118
+ const auth = config.auth;
119
+ const token = await requiredSecret(serverId, auth.tokenSecret, secrets);
120
+ secretValues.push(token);
121
+ authProvider = { token: () => secrets.get(auth.tokenSecret) };
122
+ } else if (config.auth.type === "oauth") {
123
+ if (!oauthAuthProviderFactory) {
124
+ throw new RuntimeError("MCP_OAUTH_PROVIDER_UNAVAILABLE", `MCP server '${serverId}' requires an OAuth auth provider`);
125
+ }
126
+ authProvider = await oauthAuthProviderFactory(serverId, config as OAuthMcpServerConfig, secrets);
127
+ secretValues.push(...authProviderSecretValues(authProvider));
128
+ }
129
+ return {
130
+ transport: dependencies.createHttpTransport(new URL(config.url), {
131
+ ...(authProvider === undefined ? {} : { authProvider }),
132
+ requestInit: { headers: { ...config.headers, ...resolvedSecretHeaders } },
133
+ }),
134
+ secretValues: [...Object.values(config.headers), ...secretValues],
135
+ };
136
+ } catch (cause) {
137
+ if (isPreservedOAuthSetupError(cause)) throw cause;
138
+ throw new McpTransportSetupError(cause, secretValues);
139
+ }
140
+ }
@@ -32,7 +32,7 @@ export class NativeExtensionProvider implements ToolProvider {
32
32
  risk: tool.risk ?? "read",
33
33
  inputSchema: tool.inputSchema,
34
34
  provider: { id: this.id, kind: this.kind },
35
- source: { id: extension.manifest.id, kind: "extension" },
35
+ source: { id: extension.manifest.id, kind: "extension", version: extension.manifest.version },
36
36
  });
37
37
  }
38
38
  }
@@ -8,5 +8,5 @@ export interface ExecutionResult {
8
8
  toolId: string;
9
9
  output?: unknown;
10
10
  meta?: Record<string, unknown>;
11
- error?: { code: string; message: string };
11
+ error?: { code: string; message: string; details?: unknown };
12
12
  }
@@ -19,7 +19,9 @@ interface RuntimeState {
19
19
  }
20
20
 
21
21
  export class ToolRuntime {
22
- constructor(private readonly state: RuntimeState) {}
22
+ private closePromise?: Promise<void>;
23
+
24
+ constructor(private readonly state: RuntimeState, private readonly registeredProviders: readonly ToolProvider[] = []) {}
23
25
 
24
26
  search(query: string): Promise<ToolSearchResult[]> {
25
27
  return Promise.resolve(this.state.tools.search(query));
@@ -44,28 +46,52 @@ export class ToolRuntime {
44
46
  createExecutionContext: this.state.createExecutionContext,
45
47
  }, (input ?? {}) as Record<string, unknown>);
46
48
  }
49
+
50
+ close(): Promise<void> {
51
+ if (this.closePromise) return this.closePromise;
52
+ this.closePromise = (async () => {
53
+ const errors: unknown[] = [];
54
+ for (const provider of [...this.registeredProviders].reverse()) {
55
+ if (!provider.close) continue;
56
+ try { await provider.close(); } catch (error) { errors.push(error); }
57
+ }
58
+ if (errors.length > 0) throw errors[0];
59
+ })();
60
+ return this.closePromise;
61
+ }
62
+ }
63
+
64
+ async function closeProviders(providers: readonly ToolProvider[]): Promise<void> {
65
+ await Promise.allSettled([...providers].reverse().map(async (provider) => {
66
+ if (provider.close) await provider.close();
67
+ }));
47
68
  }
48
69
 
49
70
  export async function createToolRuntime(options: ToolRuntimeOptions): Promise<ToolRuntime> {
50
71
  const providers = new ProviderRegistry();
51
72
  const tools = new ToolRegistry();
52
- for (const provider of options.providers) {
53
- providers.register(provider);
54
- const descriptors = await provider.listTools();
55
- for (const descriptor of descriptors) {
56
- if (descriptor.provider.id !== provider.id || descriptor.provider.kind !== provider.kind) {
57
- throw new RuntimeError(
58
- "PROVIDER_DESCRIPTOR_MISMATCH",
59
- `tool '${descriptor.id}' does not identify provider '${provider.id}/${provider.kind}'`,
60
- );
73
+ try {
74
+ for (const provider of options.providers) {
75
+ providers.register(provider);
76
+ const descriptors = await provider.listTools();
77
+ for (const descriptor of descriptors) {
78
+ if (descriptor.provider.id !== provider.id || descriptor.provider.kind !== provider.kind) {
79
+ throw new RuntimeError(
80
+ "PROVIDER_DESCRIPTOR_MISMATCH",
81
+ `tool '${descriptor.id}' does not identify provider '${provider.id}/${provider.kind}'`,
82
+ );
83
+ }
61
84
  }
85
+ tools.register(descriptors);
62
86
  }
63
- tools.register(descriptors);
87
+ } catch (error) {
88
+ await closeProviders(options.providers);
89
+ throw error;
64
90
  }
65
91
  return new ToolRuntime({
66
92
  providers,
67
93
  tools,
68
94
  policy: options.policy,
69
95
  createExecutionContext: options.createExecutionContext,
70
- });
96
+ }, options.providers);
71
97
  }
@@ -33,13 +33,23 @@ function checkType(value: unknown, schema: Schema, path: string): void {
33
33
  }
34
34
  }
35
35
 
36
- export function validateInput(schema: Schema | undefined, input: Record<string, unknown>): Record<string, unknown> {
36
+ export interface ValidateInputOptions {
37
+ readonly applyDefaults?: boolean;
38
+ }
39
+
40
+ export function validateInput(
41
+ schema: Schema | undefined,
42
+ input: Record<string, unknown>,
43
+ options: ValidateInputOptions = {},
44
+ ): Record<string, unknown> {
37
45
  if (!schema) return { ...input };
38
46
  const out: Record<string, unknown> = { ...input };
39
47
  const props = schema.properties ?? {};
40
- for (const key of Object.keys(props)) {
41
- const prop = props[key];
42
- if (out[key] === undefined && prop.default !== undefined) out[key] = prop.default;
48
+ if (options.applyDefaults !== false) {
49
+ for (const key of Object.keys(props)) {
50
+ const prop = props[key];
51
+ if (out[key] === undefined && prop.default !== undefined) out[key] = prop.default;
52
+ }
43
53
  }
44
54
  for (const key of schema.required ?? []) {
45
55
  if (out[key] === undefined || out[key] === null) throw new OmtError("INVALID_INPUT", `missing required field '${key}'`);
@@ -1,78 +1,78 @@
1
- import type { ExtensionManifest } from "@oh-my-tool/sdk";
2
-
3
- export interface SearchHit {
4
- name: string;
5
- description: string;
6
- extension: string;
7
- risk: string;
8
- score: number;
9
- }
10
-
11
- interface Searchable {
12
- name: string;
13
- description: string;
14
- keywords: string[];
15
- risk: string;
16
- extension: string;
17
- extName: string;
18
- }
19
-
20
- const NAME_WEIGHT = 3;
21
- const KEYWORD_WEIGHT = 2;
22
- const DESC_WEIGHT = 1;
23
-
24
- function toSearchable(ext: ExtensionManifest) {
25
- const out: Searchable[] = [];
26
- for (const tool of ext.tools) {
27
- out.push({
28
- name: tool.name.toLowerCase(),
29
- description: tool.description.toLowerCase(),
30
- keywords: (tool.keywords ?? []).map((k) => k.toLowerCase()),
31
- risk: tool.risk ?? "read",
32
- extension: ext.id,
33
- extName: ext.name.toLowerCase(),
34
- });
35
- }
36
- return out;
37
- }
38
-
39
- function bestWeight(s: Searchable, token: string): number {
40
- if (s.name.includes(token)) return NAME_WEIGHT;
41
- if (s.extName.includes(token)) return NAME_WEIGHT;
42
- if (s.keywords.some((k) => k.includes(token) || token.includes(k))) {
43
- return KEYWORD_WEIGHT;
44
- }
45
- if (s.description.includes(token)) return DESC_WEIGHT;
46
- return 0;
47
- }
48
-
49
- export function searchTools(query: string, manifests: ExtensionManifest[]): SearchHit[] {
50
- const tokens = query
51
- .toLowerCase()
52
- .split(/\s+/)
53
- .filter((t) => t.length > 0);
54
-
55
- if (tokens.length === 0) return [];
56
-
57
- const hits: SearchHit[] = [];
58
- for (const ext of manifests) {
59
- for (const s of toSearchable(ext)) {
60
- let score = 0;
61
- for (const token of tokens) {
62
- score += bestWeight(s, token);
63
- }
64
- if (score > 0) {
65
- hits.push({
66
- name: s.name,
67
- description: s.description,
68
- extension: s.extension,
69
- risk: s.risk,
70
- score,
71
- });
72
- }
73
- }
74
- }
75
-
76
- hits.sort((a, b) => b.score - a.score);
77
- return hits;
78
- }
1
+ import type { ExtensionManifest } from "@oh-my-tool/sdk";
2
+
3
+ export interface SearchHit {
4
+ name: string;
5
+ description: string;
6
+ extension: string;
7
+ risk: string;
8
+ score: number;
9
+ }
10
+
11
+ interface Searchable {
12
+ name: string;
13
+ description: string;
14
+ keywords: string[];
15
+ risk: string;
16
+ extension: string;
17
+ extName: string;
18
+ }
19
+
20
+ const NAME_WEIGHT = 3;
21
+ const KEYWORD_WEIGHT = 2;
22
+ const DESC_WEIGHT = 1;
23
+
24
+ function toSearchable(ext: ExtensionManifest) {
25
+ const out: Searchable[] = [];
26
+ for (const tool of ext.tools) {
27
+ out.push({
28
+ name: tool.name.toLowerCase(),
29
+ description: tool.description.toLowerCase(),
30
+ keywords: (tool.keywords ?? []).map((k) => k.toLowerCase()),
31
+ risk: tool.risk ?? "read",
32
+ extension: ext.id,
33
+ extName: ext.name.toLowerCase(),
34
+ });
35
+ }
36
+ return out;
37
+ }
38
+
39
+ function bestWeight(s: Searchable, token: string): number {
40
+ if (s.name.includes(token)) return NAME_WEIGHT;
41
+ if (s.extName.includes(token)) return NAME_WEIGHT;
42
+ if (s.keywords.some((k) => k.includes(token) || token.includes(k))) {
43
+ return KEYWORD_WEIGHT;
44
+ }
45
+ if (s.description.includes(token)) return DESC_WEIGHT;
46
+ return 0;
47
+ }
48
+
49
+ export function searchTools(query: string, manifests: ExtensionManifest[]): SearchHit[] {
50
+ const tokens = query
51
+ .toLowerCase()
52
+ .split(/\s+/)
53
+ .filter((t) => t.length > 0);
54
+
55
+ if (tokens.length === 0) return [];
56
+
57
+ const hits: SearchHit[] = [];
58
+ for (const ext of manifests) {
59
+ for (const s of toSearchable(ext)) {
60
+ let score = 0;
61
+ for (const token of tokens) {
62
+ score += bestWeight(s, token);
63
+ }
64
+ if (score > 0) {
65
+ hits.push({
66
+ name: s.name,
67
+ description: s.description,
68
+ extension: s.extension,
69
+ risk: s.risk,
70
+ score,
71
+ });
72
+ }
73
+ }
74
+ }
75
+
76
+ hits.sort((a, b) => b.score - a.score);
77
+ return hits;
78
+ }