@oh-my-tool/cli 0.3.2 → 0.3.4
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/package.json +2 -2
- package/src/cli/commands/connections.ts +33 -25
- package/src/cli/commands/run.ts +3 -9
- package/src/cli/commands/search.ts +8 -2
- package/src/cli/context.ts +10 -5
- package/src/cli/index.ts +23 -6
- package/src/cli/output.ts +13 -13
- package/src/config/config.ts +62 -37
- package/src/extension/loader.ts +1 -1
- package/src/extension/manifest.ts +18 -0
- package/src/integration/manager.ts +4 -4
- package/src/policy/policy.ts +2 -0
- package/src/runtime/provider.ts +17 -0
- package/src/runtime/providers/mcp/provider.ts +2 -0
- package/src/runtime/providers/native/provider.ts +33 -9
- package/src/runtime/result.ts +12 -5
- package/src/runtime/runtime.ts +79 -25
- package/src/runtime/tool-registry.ts +23 -8
- package/src/version.ts +1 -1
- package/src/core/executor.ts +0 -99
- package/src/core/registry.ts +0 -33
- package/src/core/result.ts +0 -14
- package/src/core/schema.ts +0 -2
- package/src/search/search.ts +0 -78
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-tool/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Oh My Tool CLI - local and enterprise tools for agents",
|
|
6
6
|
"keywords": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@clack/prompts": "^1.7.0",
|
|
31
31
|
"@modelcontextprotocol/client": "2.0.0",
|
|
32
|
-
"@oh-my-tool/sdk": "0.3.
|
|
32
|
+
"@oh-my-tool/sdk": "0.3.4",
|
|
33
33
|
"open": "11.0.0"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
@@ -1,18 +1,15 @@
|
|
|
1
|
-
import { loadConfig } from "../../config/config";
|
|
1
|
+
import { loadConfig, sanitizeExtensionConnections, validateConfiguredConnections } from "../../config/config";
|
|
2
2
|
import { createPaths } from "../../paths";
|
|
3
3
|
import { prepareHome } from "../../migration";
|
|
4
4
|
import { withRuntime } from "../context";
|
|
5
|
+
import { discoverExtensions } from "../../extension/discovery";
|
|
5
6
|
|
|
6
7
|
export interface ConnectionSummary {
|
|
7
8
|
extension: string;
|
|
8
9
|
name: string;
|
|
9
|
-
environment
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
database: string;
|
|
13
|
-
username: string;
|
|
14
|
-
tls: boolean;
|
|
15
|
-
secretConfigured: boolean;
|
|
10
|
+
environment?: string;
|
|
11
|
+
settings: Record<string, unknown>;
|
|
12
|
+
secretsConfigured: Record<string, boolean>;
|
|
16
13
|
}
|
|
17
14
|
|
|
18
15
|
export interface ConnectionListResult {
|
|
@@ -43,20 +40,15 @@ async function configuredConnections(): Promise<ConnectionListResult> {
|
|
|
43
40
|
const paths = createPaths();
|
|
44
41
|
await prepareHome(paths);
|
|
45
42
|
const config = loadConfig(paths.home);
|
|
46
|
-
const
|
|
43
|
+
const sanitized = sanitizeExtensionConnections(config);
|
|
44
|
+
const connections = Object.entries(sanitized)
|
|
47
45
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
48
|
-
.flatMap(([extension, value]) => Object.entries(value
|
|
46
|
+
.flatMap(([extension, value]) => Object.entries(value)
|
|
49
47
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
50
48
|
.map(([name, connection]) => ({
|
|
51
49
|
extension,
|
|
52
50
|
name,
|
|
53
|
-
|
|
54
|
-
host: connection.host,
|
|
55
|
-
port: connection.port,
|
|
56
|
-
database: connection.database,
|
|
57
|
-
username: connection.username,
|
|
58
|
-
tls: connection.tls,
|
|
59
|
-
secretConfigured: connection.secret.length > 0,
|
|
51
|
+
...connection,
|
|
60
52
|
})));
|
|
61
53
|
return { connections, count: connections.length };
|
|
62
54
|
}
|
|
@@ -69,6 +61,7 @@ export async function runConfigCheck(): Promise<ConfigCheckResult> {
|
|
|
69
61
|
const paths = createPaths();
|
|
70
62
|
await prepareHome(paths);
|
|
71
63
|
const config = loadConfig(paths.home);
|
|
64
|
+
validateConfiguredConnections(config, discoverExtensions(paths.home));
|
|
72
65
|
return {
|
|
73
66
|
valid: true,
|
|
74
67
|
connectionCount: Object.values(config.extensions).reduce((count, extension) => count + Object.keys(extension.connections).length, 0),
|
|
@@ -78,17 +71,32 @@ export async function runConfigCheck(): Promise<ConfigCheckResult> {
|
|
|
78
71
|
|
|
79
72
|
export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
|
|
80
73
|
const list = await configuredConnections();
|
|
74
|
+
const paths = createPaths();
|
|
75
|
+
const checkTools = new Map(discoverExtensions(paths.home).map((extension) => [extension.id, extension.manifest.connectionCheckTool]));
|
|
81
76
|
return withRuntime(async (runtime) => {
|
|
82
|
-
const checks: ConnectionCheck[] =
|
|
83
|
-
|
|
77
|
+
const checks: ConnectionCheck[] = await boundedMap(list.connections, 4, async (connection) => {
|
|
78
|
+
const toolId = checkTools.get(connection.extension);
|
|
79
|
+
if (!toolId) return { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" };
|
|
84
80
|
const started = Date.now();
|
|
85
|
-
const result = await runtime.run(
|
|
86
|
-
|
|
81
|
+
const result = await runtime.run(toolId, { connection: connection.name });
|
|
82
|
+
return result.ok
|
|
87
83
|
? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
|
|
88
|
-
: result.error
|
|
89
|
-
|
|
90
|
-
: { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
|
|
91
|
-
}
|
|
84
|
+
: { extension: connection.extension, name: connection.name, status: "error", code: result.error.code ?? "CHECK_FAILED", durationMs: Date.now() - started };
|
|
85
|
+
});
|
|
92
86
|
return { checks, count: checks.length };
|
|
93
87
|
}, { includeMcp: false });
|
|
94
88
|
}
|
|
89
|
+
|
|
90
|
+
async function boundedMap<T, R>(values: readonly T[], concurrency: number, worker: (value: T) => Promise<R>): Promise<R[]> {
|
|
91
|
+
const results = new Array<R>(values.length);
|
|
92
|
+
let next = 0;
|
|
93
|
+
async function consume(): Promise<void> {
|
|
94
|
+
while (true) {
|
|
95
|
+
const index = next++;
|
|
96
|
+
if (index >= values.length) return;
|
|
97
|
+
results[index] = await worker(values[index]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, () => consume()));
|
|
101
|
+
return results;
|
|
102
|
+
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
import { coerceInput } from "../parseArgs";
|
|
2
2
|
import { withRuntime } from "../context";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ExecutionResult } from "../../runtime/result";
|
|
4
4
|
|
|
5
5
|
export async function runTool(
|
|
6
6
|
toolName: string,
|
|
7
7
|
keyValues: Record<string, string>,
|
|
8
8
|
useStdin: boolean,
|
|
9
|
-
): Promise<
|
|
9
|
+
): Promise<ExecutionResult> {
|
|
10
10
|
let input: Record<string, unknown>;
|
|
11
11
|
if (useStdin) {
|
|
12
12
|
input = await readStdinJson();
|
|
13
13
|
} else {
|
|
14
14
|
input = coerceInput(keyValues);
|
|
15
15
|
}
|
|
16
|
-
return withRuntime(
|
|
17
|
-
const result = await runtime.run(toolName, input);
|
|
18
|
-
if (result.ok) {
|
|
19
|
-
return { ok: true, tool: toolName, data: result.output, meta: result.meta ?? {} };
|
|
20
|
-
}
|
|
21
|
-
return { ok: false, tool: toolName, error: result.error ?? { code: "EXECUTION_FAILED", message: "execution failed" } };
|
|
22
|
-
}, { targetTool: toolName });
|
|
16
|
+
return withRuntime((runtime) => runtime.run(toolName, input), { targetTool: toolName });
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
function readStdinJson(): Promise<Record<string, unknown>> {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { withRuntime } from "../context";
|
|
2
|
+
import type { ToolSearchOptions } from "../../runtime/provider";
|
|
2
3
|
|
|
3
|
-
export async function runSearch(query: string): Promise<{ tools: Array<Record<string, unknown>> }> {
|
|
4
|
+
export async function runSearch(query: string, options: ToolSearchOptions = {}): Promise<{ tools: Array<Record<string, unknown>>; meta: { unavailableProviders: Array<Record<string, unknown>> } }> {
|
|
4
5
|
return withRuntime(async (runtime) => {
|
|
5
|
-
const descriptors = await runtime.search(query);
|
|
6
|
+
const descriptors = await runtime.search(query, options);
|
|
6
7
|
return {
|
|
7
8
|
tools: descriptors.map((descriptor) => ({
|
|
8
9
|
name: descriptor.id,
|
|
@@ -11,6 +12,11 @@ export async function runSearch(query: string): Promise<{ tools: Array<Record<st
|
|
|
11
12
|
risk: descriptor.risk,
|
|
12
13
|
provider: descriptor.provider,
|
|
13
14
|
})),
|
|
15
|
+
meta: {
|
|
16
|
+
unavailableProviders: runtime.providerStatuses()
|
|
17
|
+
.filter((status) => status.status === "unavailable")
|
|
18
|
+
.map(({ id, kind, status, code, message }) => ({ id, kind, status, ...(code === undefined ? {} : { code }), ...(message === undefined ? {} : { message }) })),
|
|
19
|
+
},
|
|
14
20
|
};
|
|
15
21
|
});
|
|
16
22
|
}
|
package/src/cli/context.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { createPaths } from "../paths";
|
|
2
2
|
import { prepareHome } from "../migration";
|
|
3
|
-
import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, type McpEnabledServerConfig } from "../config/config";
|
|
3
|
+
import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, validateConfiguredConnections, type McpEnabledServerConfig } from "../config/config";
|
|
4
4
|
import { SecretsManager } from "../secrets/secrets";
|
|
5
5
|
import { applyLimits, validateConnectionInput } from "../policy/policy";
|
|
6
6
|
import { NativeExtensionProvider } from "../runtime/providers/native/provider";
|
|
7
7
|
import { McpProvider } from "../runtime/providers/mcp/provider";
|
|
8
|
-
import { discoverExtensions } from "../extension/discovery";
|
|
9
8
|
import { createToolRuntime } from "../runtime/runtime";
|
|
10
9
|
import type { ToolDescriptor } from "../runtime/provider";
|
|
11
10
|
|
|
@@ -24,13 +23,19 @@ export async function createRuntime(options: RuntimeOptions = {}) {
|
|
|
24
23
|
const config = loadConfig(paths.home);
|
|
25
24
|
const secrets = new SecretsManager();
|
|
26
25
|
const extensionConnections = sanitizeExtensionConnections(config);
|
|
27
|
-
const
|
|
28
|
-
|
|
26
|
+
const nativeProvider = new NativeExtensionProvider(paths);
|
|
27
|
+
const nativeTargetExtension = options.targetTool === undefined
|
|
28
|
+
? undefined
|
|
29
|
+
: nativeProvider.extensionForTool(options.targetTool);
|
|
30
|
+
if (nativeTargetExtension !== undefined) {
|
|
31
|
+
validateConfiguredConnections(config, nativeProvider.installedExtensions(), nativeTargetExtension);
|
|
32
|
+
}
|
|
33
|
+
const nativeTarget = nativeTargetExtension !== undefined;
|
|
29
34
|
const mcpProviders = options.includeMcp === false || nativeTarget ? [] : Object.entries(config.mcp.servers)
|
|
30
35
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
31
36
|
.filter((entry): entry is [string, McpEnabledServerConfig] => entry[1].enabled)
|
|
32
37
|
.map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }));
|
|
33
|
-
const providers = [
|
|
38
|
+
const providers = [nativeProvider, ...mcpProviders];
|
|
34
39
|
return createToolRuntime({
|
|
35
40
|
providers,
|
|
36
41
|
policy: {
|
package/src/cli/index.ts
CHANGED
|
@@ -121,6 +121,23 @@ function parseAgentIds(raw?: string): AgentId[] | undefined {
|
|
|
121
121
|
return [...new Set(values as AgentId[])];
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
function parseSearchOptions(options: Record<string, string>): { limit?: number; provider?: string; source?: string; risk?: "read" | "write" | "admin" } {
|
|
125
|
+
const limit = options.limit === undefined ? undefined : Number(options.limit);
|
|
126
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) {
|
|
127
|
+
throw new RuntimeError("INVALID_ARGUMENT", "search --limit must be a positive integer");
|
|
128
|
+
}
|
|
129
|
+
const risk = options.risk;
|
|
130
|
+
if (risk !== undefined && risk !== "read" && risk !== "write" && risk !== "admin") {
|
|
131
|
+
throw new RuntimeError("INVALID_ARGUMENT", `unsupported search risk '${risk}'`);
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
...(limit === undefined ? {} : { limit }),
|
|
135
|
+
...(options.provider === undefined ? {} : { provider: options.provider }),
|
|
136
|
+
...(options.source === undefined ? {} : { source: options.source }),
|
|
137
|
+
...(risk === undefined ? {} : { risk }),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
function printDetected(agents: AgentDetection[]): void {
|
|
125
142
|
console.log("Detected agents:\n");
|
|
126
143
|
for (const agent of agents) console.log(`✓ ${agent.variant ?? agent.displayName}`);
|
|
@@ -169,10 +186,10 @@ function printStatus(results: IntegrationResult[]): void {
|
|
|
169
186
|
const summary = Object.entries(counts).map(([status, n]) => `${n} ${status}`).join(" · ");
|
|
170
187
|
console.log(`\nSummary: ${summary}`);
|
|
171
188
|
if (results.some((item) => item.status === "broken")) {
|
|
172
|
-
console.log("Tip: run `
|
|
189
|
+
console.log("Tip: run `ohmytool integrate repair` to recreate broken links");
|
|
173
190
|
}
|
|
174
191
|
if (results.some((item) => item.status === "conflict")) {
|
|
175
|
-
console.log("Tip: conflict means OMT refuses to touch an unmanaged path; run `
|
|
192
|
+
console.log("Tip: conflict means OMT refuses to touch an unmanaged path; run `ohmytool integrate --force` only if you accept replacing it");
|
|
176
193
|
}
|
|
177
194
|
}
|
|
178
195
|
|
|
@@ -207,7 +224,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
207
224
|
const parsed = parseArgs(argv);
|
|
208
225
|
const cmd = parsed.positional[0];
|
|
209
226
|
const allowedFlags = new Set(["help", "version", "json", "stdin", "yes", "dry-run", "force"]);
|
|
210
|
-
const allowedOptions = new Set(["format", "json", "agents"]);
|
|
227
|
+
const allowedOptions = new Set(["format", "json", "agents", "limit", "provider", "source", "risk"]);
|
|
211
228
|
const unknownFlag = parsed.flags.find((flag) => !allowedFlags.has(flag));
|
|
212
229
|
const unknownOption = Object.keys(parsed.options).find((option) => !allowedOptions.has(option));
|
|
213
230
|
if (unknownFlag) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownFlag}'`);
|
|
@@ -215,7 +232,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
215
232
|
switch (cmd) {
|
|
216
233
|
case "search": {
|
|
217
234
|
const q = parsed.positional.slice(1).join(" ");
|
|
218
|
-
print(await runSearch(q));
|
|
235
|
+
print(await runSearch(q, parseSearchOptions(parsed.options)));
|
|
219
236
|
return 0;
|
|
220
237
|
}
|
|
221
238
|
case "describe": {
|
|
@@ -235,7 +252,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
235
252
|
return 1;
|
|
236
253
|
}
|
|
237
254
|
const result = action === "list" ? await runConnectionList() : await runConnectionCheck();
|
|
238
|
-
const wrapped = { ok: true as const,
|
|
255
|
+
const wrapped = { ok: true as const, toolId: `connection.${action}`, output: result, meta: {} };
|
|
239
256
|
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
240
257
|
return 0;
|
|
241
258
|
}
|
|
@@ -245,7 +262,7 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
245
262
|
return 1;
|
|
246
263
|
}
|
|
247
264
|
const result = await runConfigCheck();
|
|
248
|
-
const wrapped = { ok: true as const,
|
|
265
|
+
const wrapped = { ok: true as const, toolId: "config.check", output: result, meta: {} };
|
|
249
266
|
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
250
267
|
return 0;
|
|
251
268
|
}
|
package/src/cli/output.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ExecutionResult } from "../runtime/result";
|
|
2
2
|
|
|
3
3
|
export type OutputFormat = "text" | "json" | "table" | "csv";
|
|
4
4
|
|
|
5
|
-
export function formatAiResult(result:
|
|
6
|
-
const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.
|
|
5
|
+
export function formatAiResult(result: ExecutionResult): string {
|
|
6
|
+
const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.toolId}`];
|
|
7
7
|
if (!result.ok) {
|
|
8
8
|
lines.push("error:");
|
|
9
9
|
appendObject(lines, result.error, 2);
|
|
10
10
|
return lines.join("\n");
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
if (isRowsResult(result.
|
|
13
|
+
if (isRowsResult(result.output)) {
|
|
14
14
|
lines.push("rows:");
|
|
15
|
-
for (const row of result.
|
|
16
|
-
lines.push(`row_count: ${result.
|
|
17
|
-
if (result.
|
|
15
|
+
for (const row of result.output.rows) lines.push(formatRow(row));
|
|
16
|
+
lines.push(`row_count: ${result.output.rows.length}`);
|
|
17
|
+
if (result.output.truncated === true) lines.push("truncated: true");
|
|
18
18
|
appendMeta(lines, result.meta, new Set(["returnedRows"]), false);
|
|
19
19
|
} else {
|
|
20
20
|
lines.push("data:");
|
|
21
|
-
appendValue(lines, result.
|
|
21
|
+
appendValue(lines, result.output, 2);
|
|
22
22
|
appendMeta(lines, result.meta);
|
|
23
23
|
}
|
|
24
24
|
return lines.join("\n");
|
|
@@ -36,17 +36,17 @@ export function formatJson(value: unknown): string {
|
|
|
36
36
|
}, 2) ?? "null";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function formatOutput(result:
|
|
39
|
+
export function formatOutput(result: ExecutionResult, format: OutputFormat): string {
|
|
40
40
|
if (format === "json") return formatJson(result);
|
|
41
41
|
if (format === "csv" || format === "table") return formatDelimited(result, format === "csv" ? "," : "\t");
|
|
42
42
|
return formatAiResult(result);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function formatDelimited(result:
|
|
45
|
+
function formatDelimited(result: ExecutionResult, delimiter: string): string {
|
|
46
46
|
if (!result.ok) return formatAiResult(result);
|
|
47
|
-
const rows = isRowsResult(result.
|
|
48
|
-
? { columns: result.
|
|
49
|
-
: findObjectArray(result.
|
|
47
|
+
const rows = isRowsResult(result.output)
|
|
48
|
+
? { columns: result.output.columns, rows: result.output.rows }
|
|
49
|
+
: findObjectArray(result.output);
|
|
50
50
|
if (!rows) return formatAiResult(result);
|
|
51
51
|
return [rows.columns, ...rows.rows].map((row) => row.map((value) => quoteDelimited(value, delimiter)).join(delimiter)).join("\n");
|
|
52
52
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { RuntimeError } from "../runtime/errors";
|
|
4
|
+
import { validateInput, type Schema } from "../runtime/schema";
|
|
5
|
+
import type { InstalledExtension } from "../extension/discovery";
|
|
4
6
|
|
|
5
7
|
export interface ConnectionConfig {
|
|
6
|
-
environment
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
environment?: string;
|
|
9
|
+
settings: Record<string, unknown>;
|
|
10
|
+
secrets: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SanitizedConnectionConfig {
|
|
14
|
+
environment?: string;
|
|
15
|
+
settings: Record<string, unknown>;
|
|
16
|
+
secretsConfigured: Record<string, boolean>;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export interface McpCommonServerConfig { readonly enabled: true; readonly namespace: string; }
|
|
@@ -44,32 +48,36 @@ function parseConnection(extensionId: string, name: string, value: unknown): Con
|
|
|
44
48
|
const path = `extensions.${extensionId}.connections.${name}`;
|
|
45
49
|
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
46
50
|
const raw = value as Record<string, unknown>;
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
const host = stringField("host");
|
|
54
|
-
if (host.trim().length === 0) invalidConnection(`${path}.host`, "must be a non-empty string");
|
|
55
|
-
const defaultPort = extensionId === "redis" ? 6379 : 3306;
|
|
56
|
-
const port = raw.port === undefined ? defaultPort : raw.port;
|
|
57
|
-
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
58
|
-
invalidConnection(`${path}.port`, "must be an integer from 1 through 65535");
|
|
59
|
-
}
|
|
60
|
-
const tls = raw.tls === undefined ? false : raw.tls;
|
|
61
|
-
if (typeof tls !== "boolean") invalidConnection(`${path}.tls`, "must be a boolean");
|
|
51
|
+
const allowed = new Set(["environment", "settings", "secrets"]);
|
|
52
|
+
for (const key of Object.keys(raw)) if (!allowed.has(key)) invalidConnection(`${path}.${key}`, "unknown connection field");
|
|
53
|
+
const environment = raw.environment;
|
|
54
|
+
if (environment !== undefined && typeof environment !== "string") invalidConnection(`${path}.environment`, "must be a string");
|
|
55
|
+
const settings = parseSettingsMap(raw.settings, `${path}.settings`);
|
|
56
|
+
const secrets = parseSecretMap(raw.secrets, `${path}.secrets`);
|
|
62
57
|
return {
|
|
63
|
-
environment:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
database: stringField("database"),
|
|
67
|
-
username: stringField("username"),
|
|
68
|
-
secret: stringField("secret"),
|
|
69
|
-
tls,
|
|
58
|
+
...(environment === undefined ? {} : { environment }),
|
|
59
|
+
settings,
|
|
60
|
+
secrets,
|
|
70
61
|
};
|
|
71
62
|
}
|
|
72
63
|
|
|
64
|
+
function parseSettingsMap(value: unknown, path: string): Record<string, unknown> {
|
|
65
|
+
if (value === undefined) return {};
|
|
66
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
67
|
+
return { ...(value as Record<string, unknown>) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSecretMap(value: unknown, path: string): Record<string, string> {
|
|
71
|
+
if (value === undefined) return {};
|
|
72
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
73
|
+
const result: Record<string, string> = {};
|
|
74
|
+
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
75
|
+
if (typeof entry !== "string" || entry.length === 0) invalidConnection(`${path}.${key}`, "must be a non-empty string");
|
|
76
|
+
result[key] = entry;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function parseStringMap(value: unknown, path: string): Record<string, string> {
|
|
74
82
|
if (value === undefined) return {};
|
|
75
83
|
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(path, "must be a table");
|
|
@@ -191,17 +199,34 @@ export function loadConfig(homeDir: string): Config {
|
|
|
191
199
|
export function getConnectionConfig(cfg: Config, extensionId: string, connection: string): ConnectionConfig | undefined { return cfg.extensions[extensionId]?.connections[connection]; }
|
|
192
200
|
export function listConnections(cfg: Config, extensionId: string): string[] { return Object.keys(cfg.extensions[extensionId]?.connections ?? {}); }
|
|
193
201
|
|
|
194
|
-
export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string,
|
|
202
|
+
export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string, SanitizedConnectionConfig>> {
|
|
195
203
|
return Object.fromEntries(Object.entries(cfg.extensions).map(([extensionId, extension]) => [
|
|
196
204
|
extensionId,
|
|
197
205
|
Object.fromEntries(Object.entries(extension.connections).map(([name, connection]) => [name, {
|
|
198
|
-
environment: connection.environment,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
database: connection.database,
|
|
202
|
-
username: connection.username,
|
|
203
|
-
tls: connection.tls,
|
|
204
|
-
secretConfigured: connection.secret.length > 0,
|
|
206
|
+
...(connection.environment === undefined ? {} : { environment: connection.environment }),
|
|
207
|
+
settings: { ...connection.settings },
|
|
208
|
+
secretsConfigured: Object.fromEntries(Object.keys(connection.secrets).map((key) => [key, true])),
|
|
205
209
|
}])),
|
|
206
210
|
]));
|
|
207
211
|
}
|
|
212
|
+
|
|
213
|
+
export function validateConfiguredConnections(
|
|
214
|
+
cfg: Config,
|
|
215
|
+
installedExtensions: readonly InstalledExtension[],
|
|
216
|
+
targetExtensionId?: string,
|
|
217
|
+
): void {
|
|
218
|
+
const manifests = new Map(installedExtensions.map((extension) => [extension.id, extension.manifest]));
|
|
219
|
+
for (const [extensionId, extension] of Object.entries(cfg.extensions)) {
|
|
220
|
+
if (targetExtensionId !== undefined && targetExtensionId !== extensionId) continue;
|
|
221
|
+
const schema = manifests.get(extensionId)?.connectionSchema;
|
|
222
|
+
if (schema === undefined) continue;
|
|
223
|
+
for (const [name, connection] of Object.entries(extension.connections)) {
|
|
224
|
+
try {
|
|
225
|
+
validateInput(schema as Schema, connection.settings, { applyDefaults: false });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
228
|
+
invalidConnection(`extensions.${extensionId}.connections.${name}.settings`, message);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/extension/loader.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pathToFileURL } from "node:url";
|
|
2
2
|
import type { ExtensionDefinition } from "@oh-my-tool/sdk";
|
|
3
|
-
import { OmtError } from "../
|
|
3
|
+
import { RuntimeError as OmtError } from "../runtime/errors";
|
|
4
4
|
import type { InstalledExtension } from "./discovery";
|
|
5
5
|
import { validateHandlers } from "./manifest";
|
|
6
6
|
|
|
@@ -35,6 +35,11 @@ export function validateManifest(manifest: ExtensionManifest): void {
|
|
|
35
35
|
if (!manifest.sdkVersion || typeof manifest.sdkVersion !== "string") {
|
|
36
36
|
throw new ManifestError("manifest must contain a string 'sdkVersion'");
|
|
37
37
|
}
|
|
38
|
+
if (manifest.connectionSchema !== undefined && (
|
|
39
|
+
manifest.connectionSchema === null || typeof manifest.connectionSchema !== "object" || Array.isArray(manifest.connectionSchema)
|
|
40
|
+
)) {
|
|
41
|
+
throw new ManifestError("manifest 'connectionSchema' must be an object");
|
|
42
|
+
}
|
|
38
43
|
|
|
39
44
|
const seen = new Set<string>();
|
|
40
45
|
for (const tool of manifest.tools) {
|
|
@@ -53,6 +58,19 @@ export function validateManifest(manifest: ExtensionManifest): void {
|
|
|
53
58
|
);
|
|
54
59
|
}
|
|
55
60
|
}
|
|
61
|
+
|
|
62
|
+
if (manifest.connectionCheckTool !== undefined) {
|
|
63
|
+
if (typeof manifest.connectionCheckTool !== "string" || manifest.connectionCheckTool.length === 0) {
|
|
64
|
+
throw new ManifestError("manifest 'connectionCheckTool' must be a non-empty string");
|
|
65
|
+
}
|
|
66
|
+
const checkTool = manifest.tools.find((tool) => tool.name === manifest.connectionCheckTool);
|
|
67
|
+
if (!checkTool || !manifest.connectionCheckTool.startsWith(`${manifest.id}.`)) {
|
|
68
|
+
throw new ManifestError("manifest 'connectionCheckTool' must name a declared tool prefixed by the extension id");
|
|
69
|
+
}
|
|
70
|
+
if ((checkTool.risk ?? "read") !== "read") {
|
|
71
|
+
throw new ManifestError("manifest 'connectionCheckTool' must be read-only");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
const FULL_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
@@ -274,17 +274,17 @@ export function createIntegrationManager(options: IntegrationManagerOptions) {
|
|
|
274
274
|
const recorded = records[agent.id];
|
|
275
275
|
if (!recorded) {
|
|
276
276
|
if (!pathPresent(agent.target)) {
|
|
277
|
-
return result(agent, "not-installed", "not installed; run `
|
|
277
|
+
return result(agent, "not-installed", "not installed; run `ohmytool integrate` to install");
|
|
278
278
|
}
|
|
279
279
|
return result(agent, "conflict", "target exists but is not managed by OMT");
|
|
280
280
|
}
|
|
281
281
|
try {
|
|
282
282
|
assertSafeManagedState(agent, recorded, options.omtHome, platform);
|
|
283
283
|
} catch {
|
|
284
|
-
return result(agent, "conflict", "recorded state is unsafe; run `
|
|
284
|
+
return result(agent, "conflict", "recorded state is unsafe; run `ohmytool integrate repair`");
|
|
285
285
|
}
|
|
286
286
|
if (!pathPresent(agent.target) || !safeRealpath(agent.target)) {
|
|
287
|
-
return result(agent, "broken", "link is missing; run `
|
|
287
|
+
return result(agent, "broken", "link is missing; run `ohmytool integrate repair`");
|
|
288
288
|
}
|
|
289
289
|
if (!isManagedLink(agent.target, recorded.canonical)) {
|
|
290
290
|
return result(agent, "conflict", "target was replaced by unmanaged content");
|
|
@@ -292,7 +292,7 @@ export function createIntegrationManager(options: IntegrationManagerOptions) {
|
|
|
292
292
|
if (recorded.version === options.skillVersion) {
|
|
293
293
|
return result(agent, "current");
|
|
294
294
|
}
|
|
295
|
-
return result(agent, "update-available", `new version ${options.skillVersion} available; run \`
|
|
295
|
+
return result(agent, "update-available", `new version ${options.skillVersion} available; run \`ohmytool integrate\``);
|
|
296
296
|
});
|
|
297
297
|
}
|
|
298
298
|
|
package/src/policy/policy.ts
CHANGED
package/src/runtime/provider.ts
CHANGED
|
@@ -11,6 +11,22 @@ export interface ToolDescriptor {
|
|
|
11
11
|
source: { id: string; kind: string; version?: string };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface ToolSearchOptions {
|
|
15
|
+
readonly limit?: number;
|
|
16
|
+
readonly provider?: string;
|
|
17
|
+
readonly source?: string;
|
|
18
|
+
readonly risk?: ToolDescriptor["risk"];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProviderStatus {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly kind: string;
|
|
24
|
+
readonly status: "available" | "unavailable";
|
|
25
|
+
readonly code?: string;
|
|
26
|
+
readonly message?: string;
|
|
27
|
+
readonly namespace?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
|
|
15
31
|
|
|
16
32
|
export interface ExecutionContext {
|
|
@@ -22,6 +38,7 @@ export interface ExecutionContext {
|
|
|
22
38
|
export interface ToolProvider {
|
|
23
39
|
readonly id: string;
|
|
24
40
|
readonly kind: string;
|
|
41
|
+
readonly namespace?: string;
|
|
25
42
|
listTools(): Promise<readonly ToolDescriptor[]>;
|
|
26
43
|
execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult>;
|
|
27
44
|
close?(): Promise<void>;
|
|
@@ -17,6 +17,7 @@ export interface McpProviderOptions {
|
|
|
17
17
|
export class McpProvider implements ToolProvider {
|
|
18
18
|
readonly id: string;
|
|
19
19
|
readonly kind = "mcp";
|
|
20
|
+
readonly namespace: string;
|
|
20
21
|
private session?: McpSession;
|
|
21
22
|
private descriptors?: readonly ToolDescriptor[];
|
|
22
23
|
private readonly routes = new Map<string, string>();
|
|
@@ -24,6 +25,7 @@ export class McpProvider implements ToolProvider {
|
|
|
24
25
|
|
|
25
26
|
constructor(private readonly options: McpProviderOptions) {
|
|
26
27
|
this.id = `mcp:${options.serverId}`;
|
|
28
|
+
this.namespace = options.config.namespace;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
async listTools(): Promise<readonly ToolDescriptor[]> {
|
|
@@ -8,7 +8,15 @@ export class NativeExtensionProvider implements ToolProvider {
|
|
|
8
8
|
readonly id = "native";
|
|
9
9
|
readonly kind = "native";
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
private readonly discover: (home: string) => InstalledExtension[];
|
|
12
|
+
private snapshot?: { extensions: InstalledExtension[]; routes: Map<string, InstalledExtension> };
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
private readonly homeOrPaths: string | Pick<OhMyToolPaths, "home">,
|
|
16
|
+
discover: (home: string) => InstalledExtension[] = discoverExtensions,
|
|
17
|
+
) {
|
|
18
|
+
this.discover = discover;
|
|
19
|
+
}
|
|
12
20
|
|
|
13
21
|
private home(): string {
|
|
14
22
|
return typeof this.homeOrPaths === "string" ? this.homeOrPaths : this.homeOrPaths.home;
|
|
@@ -16,7 +24,7 @@ export class NativeExtensionProvider implements ToolProvider {
|
|
|
16
24
|
|
|
17
25
|
async listTools(): Promise<readonly ToolDescriptor[]> {
|
|
18
26
|
const descriptors: ToolDescriptor[] = [];
|
|
19
|
-
for (const extension of
|
|
27
|
+
for (const extension of this.getSnapshot().extensions) {
|
|
20
28
|
for (const tool of extension.manifest.tools) {
|
|
21
29
|
descriptors.push({
|
|
22
30
|
id: tool.name,
|
|
@@ -40,18 +48,34 @@ export class NativeExtensionProvider implements ToolProvider {
|
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
async execute(toolId: string, input: unknown, context: ExecutionContext): Promise<ToolResult> {
|
|
43
|
-
const extension = this.
|
|
51
|
+
const extension = this.getSnapshot().routes.get(toolId);
|
|
52
|
+
if (!extension) throw new Error(`unknown native tool '${toolId}'`);
|
|
44
53
|
const definition = await loadExtension(extension);
|
|
45
54
|
const handler = definition.handlers[toolId];
|
|
46
55
|
if (!handler) throw new Error(`no handler for ${toolId}`);
|
|
47
56
|
return handler({ toolName: toolId, logger: context.logger, config: context.config, secrets: context.secrets }, input);
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
59
|
+
async hasTool(toolId: string): Promise<boolean> {
|
|
60
|
+
return this.getSnapshot().routes.has(toolId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
installedExtensions(): readonly InstalledExtension[] {
|
|
64
|
+
return this.getSnapshot().extensions;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
extensionForTool(toolId: string): string | undefined {
|
|
68
|
+
return this.getSnapshot().routes.get(toolId)?.manifest.id;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private getSnapshot(): { extensions: InstalledExtension[]; routes: Map<string, InstalledExtension> } {
|
|
72
|
+
if (this.snapshot) return this.snapshot;
|
|
73
|
+
const extensions = this.discover(this.home());
|
|
74
|
+
const routes = new Map<string, InstalledExtension>();
|
|
75
|
+
for (const extension of extensions) {
|
|
76
|
+
for (const tool of extension.manifest.tools) routes.set(tool.name, extension);
|
|
77
|
+
}
|
|
78
|
+
this.snapshot = { extensions, routes };
|
|
79
|
+
return this.snapshot;
|
|
56
80
|
}
|
|
57
81
|
}
|
package/src/runtime/result.ts
CHANGED
|
@@ -3,10 +3,17 @@ export interface ToolResult {
|
|
|
3
3
|
meta?: Record<string, unknown>;
|
|
4
4
|
}
|
|
5
5
|
|
|
6
|
-
export interface
|
|
7
|
-
ok:
|
|
6
|
+
export interface ExecutionOk {
|
|
7
|
+
ok: true;
|
|
8
8
|
toolId: string;
|
|
9
|
-
output
|
|
10
|
-
meta
|
|
11
|
-
error?: { code: string; message: string; details?: unknown };
|
|
9
|
+
output: unknown;
|
|
10
|
+
meta: Record<string, unknown>;
|
|
12
11
|
}
|
|
12
|
+
|
|
13
|
+
export interface ExecutionError {
|
|
14
|
+
ok: false;
|
|
15
|
+
toolId: string;
|
|
16
|
+
error: { code: string; message: string; details?: unknown };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ExecutionResult = ExecutionOk | ExecutionError;
|
package/src/runtime/runtime.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExecutionContext, ToolDescriptor, ToolProvider, ToolSearchResult } from "./provider";
|
|
1
|
+
import type { ExecutionContext, ProviderStatus, ToolDescriptor, ToolProvider, ToolSearchOptions, ToolSearchResult } from "./provider";
|
|
2
2
|
import type { ExecutionResult } from "./result";
|
|
3
3
|
import { executeRuntimeTool, type CreateExecutionContext, type PolicyPreflight } from "./executor";
|
|
4
4
|
import { RuntimeError } from "./errors";
|
|
@@ -20,23 +20,30 @@ interface RuntimeState {
|
|
|
20
20
|
|
|
21
21
|
export class ToolRuntime {
|
|
22
22
|
private closePromise?: Promise<void>;
|
|
23
|
+
private readonly discovery = new Map<string, Promise<void>>();
|
|
24
|
+
private readonly statuses = new Map<string, ProviderStatus>();
|
|
25
|
+
private readonly closedProviders = new Set<string>();
|
|
23
26
|
|
|
24
27
|
constructor(private readonly state: RuntimeState, private readonly registeredProviders: readonly ToolProvider[] = []) {}
|
|
25
28
|
|
|
26
|
-
search(query: string): Promise<ToolSearchResult[]> {
|
|
27
|
-
|
|
29
|
+
async search(query: string, options?: ToolSearchOptions): Promise<ToolSearchResult[]> {
|
|
30
|
+
await this.discoverAll();
|
|
31
|
+
return this.state.tools.search(query, options);
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
describe(toolId: string): Promise<ToolDescriptor> {
|
|
34
|
+
async describe(toolId: string): Promise<ToolDescriptor> {
|
|
35
|
+
await this.discoverForTarget(toolId);
|
|
31
36
|
const descriptor = this.state.tools.get(toolId);
|
|
32
|
-
if (!descriptor)
|
|
33
|
-
return
|
|
37
|
+
if (!descriptor) throw this.targetError(toolId);
|
|
38
|
+
return descriptor;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
async run(toolId: string, input: unknown): Promise<ExecutionResult> {
|
|
42
|
+
await this.discoverForTarget(toolId);
|
|
37
43
|
const descriptor = this.state.tools.get(toolId);
|
|
38
44
|
if (!descriptor) {
|
|
39
|
-
|
|
45
|
+
const error = this.targetError(toolId);
|
|
46
|
+
return { ok: false, toolId, error: { code: error.code, message: error.message } };
|
|
40
47
|
}
|
|
41
48
|
const provider = this.state.providers.require(descriptor.provider.id);
|
|
42
49
|
return executeRuntimeTool({
|
|
@@ -47,32 +54,45 @@ export class ToolRuntime {
|
|
|
47
54
|
}, (input ?? {}) as Record<string, unknown>);
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
providerStatuses(): readonly ProviderStatus[] {
|
|
58
|
+
return [...this.statuses.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
close(): Promise<void> {
|
|
51
62
|
if (this.closePromise) return this.closePromise;
|
|
52
63
|
this.closePromise = (async () => {
|
|
53
64
|
const errors: unknown[] = [];
|
|
54
65
|
for (const provider of [...this.registeredProviders].reverse()) {
|
|
55
|
-
|
|
56
|
-
try { await provider.close(); } catch (error) { errors.push(error); }
|
|
66
|
+
try { await this.closeProvider(provider); } catch (error) { errors.push(error); }
|
|
57
67
|
}
|
|
58
68
|
if (errors.length > 0) throw errors[0];
|
|
59
69
|
})();
|
|
60
70
|
return this.closePromise;
|
|
61
71
|
}
|
|
62
|
-
}
|
|
63
72
|
|
|
64
|
-
async
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}));
|
|
68
|
-
}
|
|
73
|
+
private async discoverAll(): Promise<void> {
|
|
74
|
+
await Promise.all(this.registeredProviders.map((provider) => this.discoverProvider(provider)));
|
|
75
|
+
}
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
private async discoverForTarget(toolId: string): Promise<void> {
|
|
78
|
+
const nativeProviders = this.registeredProviders.filter((provider) => provider.kind === "native");
|
|
79
|
+
await Promise.all(nativeProviders.map((provider) => this.discoverProvider(provider)));
|
|
80
|
+
if (this.state.tools.get(toolId)) return;
|
|
81
|
+
await Promise.all(this.registeredProviders
|
|
82
|
+
.filter((provider) => provider.kind !== "native")
|
|
83
|
+
.map((provider) => this.discoverProvider(provider)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private discoverProvider(provider: ToolProvider): Promise<void> {
|
|
87
|
+
const current = this.discovery.get(provider.id);
|
|
88
|
+
if (current) return current;
|
|
89
|
+
const promise = this.discoverProviderOnce(provider);
|
|
90
|
+
this.discovery.set(provider.id, promise);
|
|
91
|
+
return promise;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async discoverProviderOnce(provider: ToolProvider): Promise<void> {
|
|
95
|
+
try {
|
|
76
96
|
const descriptors = await provider.listTools();
|
|
77
97
|
for (const descriptor of descriptors) {
|
|
78
98
|
if (descriptor.provider.id !== provider.id || descriptor.provider.kind !== provider.kind) {
|
|
@@ -82,12 +102,46 @@ export async function createToolRuntime(options: ToolRuntimeOptions): Promise<To
|
|
|
82
102
|
);
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
|
-
tools.register(descriptors);
|
|
105
|
+
this.state.tools.register(descriptors);
|
|
106
|
+
this.statuses.set(provider.id, {
|
|
107
|
+
id: provider.id,
|
|
108
|
+
kind: provider.kind,
|
|
109
|
+
status: "available",
|
|
110
|
+
...("namespace" in provider && typeof provider.namespace === "string" ? { namespace: provider.namespace } : {}),
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (provider.kind !== "mcp") throw error;
|
|
114
|
+
const typed = error as { code?: unknown; message?: unknown };
|
|
115
|
+
this.statuses.set(provider.id, {
|
|
116
|
+
id: provider.id,
|
|
117
|
+
kind: provider.kind,
|
|
118
|
+
status: "unavailable",
|
|
119
|
+
...("namespace" in provider && typeof provider.namespace === "string" ? { namespace: provider.namespace } : {}),
|
|
120
|
+
code: typeof typed.code === "string" ? typed.code : "PROVIDER_UNAVAILABLE",
|
|
121
|
+
message: typeof typed.message === "string" ? typed.message : "provider discovery failed",
|
|
122
|
+
});
|
|
123
|
+
try { await this.closeProvider(provider); } catch { /* preserve discovery status */ }
|
|
86
124
|
}
|
|
87
|
-
} catch (error) {
|
|
88
|
-
await closeProviders(options.providers);
|
|
89
|
-
throw error;
|
|
90
125
|
}
|
|
126
|
+
|
|
127
|
+
private targetError(toolId: string): RuntimeError {
|
|
128
|
+
const unavailable = this.providerStatuses().find((status) =>
|
|
129
|
+
status.status === "unavailable" && status.namespace !== undefined && toolId.startsWith(`${status.namespace}.`));
|
|
130
|
+
if (unavailable) return new RuntimeError("PROVIDER_UNAVAILABLE", `provider '${unavailable.id}' is unavailable`);
|
|
131
|
+
return new RuntimeError("TOOL_NOT_FOUND", `unknown tool '${toolId}'`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private async closeProvider(provider: ToolProvider): Promise<void> {
|
|
135
|
+
if (!provider.close || this.closedProviders.has(provider.id)) return;
|
|
136
|
+
this.closedProviders.add(provider.id);
|
|
137
|
+
await provider.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function createToolRuntime(options: ToolRuntimeOptions): Promise<ToolRuntime> {
|
|
142
|
+
const providers = new ProviderRegistry();
|
|
143
|
+
const tools = new ToolRegistry();
|
|
144
|
+
for (const provider of options.providers) providers.register(provider);
|
|
91
145
|
return new ToolRuntime({
|
|
92
146
|
providers,
|
|
93
147
|
tools,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type { ToolDescriptor, ToolSearchResult } from "./provider";
|
|
1
|
+
import type { ToolDescriptor, ToolSearchOptions, ToolSearchResult } from "./provider";
|
|
2
2
|
import { RuntimeError } from "./errors";
|
|
3
3
|
|
|
4
4
|
const NAME_WEIGHT = 3;
|
|
5
5
|
const KEYWORD_WEIGHT = 2;
|
|
6
6
|
const DESCRIPTION_WEIGHT = 1;
|
|
7
|
+
const EXACT_BOOST = 4;
|
|
8
|
+
const PREFIX_BOOST = 2;
|
|
9
|
+
const MAX_SEARCH_RESULTS = 100;
|
|
7
10
|
|
|
8
11
|
export class ToolRegistry {
|
|
9
12
|
private readonly tools = new Map<string, ToolDescriptor>();
|
|
@@ -25,13 +28,17 @@ export class ToolRegistry {
|
|
|
25
28
|
return this.tools.get(toolId);
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
search(query: string): ToolSearchResult[] {
|
|
31
|
+
search(query: string, options: ToolSearchOptions = {}): ToolSearchResult[] {
|
|
29
32
|
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
30
33
|
if (tokens.length === 0) return [];
|
|
31
34
|
return [...this.tools.values()]
|
|
35
|
+
.filter((descriptor) => options.provider === undefined || descriptor.provider.id === options.provider)
|
|
36
|
+
.filter((descriptor) => options.source === undefined || descriptor.source.id === options.source)
|
|
37
|
+
.filter((descriptor) => options.risk === undefined || descriptor.risk === options.risk)
|
|
32
38
|
.map((descriptor) => ({ descriptor, score: this.score(descriptor, tokens) }))
|
|
33
39
|
.filter((hit) => hit.score > 0)
|
|
34
|
-
.sort((a, b) => b.score - a.score)
|
|
40
|
+
.sort((a, b) => b.score - a.score || a.descriptor.id.localeCompare(b.descriptor.id))
|
|
41
|
+
.slice(0, Math.min(Math.max(Math.trunc(options.limit ?? MAX_SEARCH_RESULTS), 1), MAX_SEARCH_RESULTS))
|
|
35
42
|
.map(({ descriptor }) => {
|
|
36
43
|
const { inputSchema: _inputSchema, ...summary } = descriptor;
|
|
37
44
|
return summary;
|
|
@@ -43,12 +50,20 @@ export class ToolRegistry {
|
|
|
43
50
|
const description = descriptor.description.toLowerCase();
|
|
44
51
|
const keywords = (descriptor.keywords ?? []).map((keyword) => keyword.toLowerCase());
|
|
45
52
|
return tokens.reduce((score, token) => {
|
|
46
|
-
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
const idScore = matchWeight(id, token, NAME_WEIGHT);
|
|
54
|
+
if (idScore > 0) return score + idScore;
|
|
55
|
+
const keywordScore = Math.max(0, ...keywords.map((keyword) => matchWeight(keyword, token, KEYWORD_WEIGHT)));
|
|
56
|
+
if (keywordScore > 0) return score + keywordScore;
|
|
57
|
+
const descriptionScore = matchWeight(description, token, DESCRIPTION_WEIGHT);
|
|
58
|
+
if (descriptionScore > 0) return score + descriptionScore;
|
|
51
59
|
return score;
|
|
52
60
|
}, 0);
|
|
53
61
|
}
|
|
54
62
|
}
|
|
63
|
+
|
|
64
|
+
function matchWeight(value: string, token: string, base: number): number {
|
|
65
|
+
if (value === token) return base + EXACT_BOOST;
|
|
66
|
+
if (value.startsWith(token)) return base + PREFIX_BOOST;
|
|
67
|
+
if (value.includes(token) || token.includes(value)) return base;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.3.
|
|
1
|
+
export const VERSION = "0.3.4";
|
package/src/core/executor.ts
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import type { Logger, SecretStore, ToolContext, ToolResult } from "@oh-my-tool/sdk";
|
|
2
|
-
import { ToolError } from "@oh-my-tool/sdk";
|
|
3
|
-
import type { Config } from "../config/config";
|
|
4
|
-
import { getConnectionConfig, sanitizeExtensionConnections } from "../config/config";
|
|
5
|
-
import { resolveTool, OmtError, type Registry } from "./registry";
|
|
6
|
-
import { validateInput, type Schema } from "./schema";
|
|
7
|
-
import { validateConnectionInput, applyLimits, PolicyError } from "../policy/policy";
|
|
8
|
-
import { loadExtension } from "../extension/loader";
|
|
9
|
-
import type { OmtResult } from "./result";
|
|
10
|
-
|
|
11
|
-
const noopLogger: Logger = {
|
|
12
|
-
debug: () => {},
|
|
13
|
-
info: () => {},
|
|
14
|
-
warn: () => {},
|
|
15
|
-
error: () => {},
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export interface ExecutorDeps {
|
|
19
|
-
registry: Registry;
|
|
20
|
-
config: Config;
|
|
21
|
-
secrets: SecretStore;
|
|
22
|
-
logger?: Logger;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function hasConnection(schema: Schema | undefined): boolean {
|
|
26
|
-
return Boolean(schema?.properties && "connection" in schema.properties);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function executeTool(
|
|
30
|
-
deps: ExecutorDeps,
|
|
31
|
-
toolName: string,
|
|
32
|
-
rawInput: Record<string, unknown>,
|
|
33
|
-
): Promise<OmtResult> {
|
|
34
|
-
const started = Date.now();
|
|
35
|
-
try {
|
|
36
|
-
const { extension, tool } = resolveTool(deps.registry, toolName);
|
|
37
|
-
const schema = tool.inputSchema as Schema | undefined;
|
|
38
|
-
|
|
39
|
-
const limits = applyLimits(rawInput);
|
|
40
|
-
const normalized = { ...rawInput, maxRows: limits.maxRows, timeoutMs: limits.timeoutMs };
|
|
41
|
-
|
|
42
|
-
const needsConnection = hasConnection(schema) || "connection" in normalized;
|
|
43
|
-
if (needsConnection) {
|
|
44
|
-
validateConnectionInput(normalized, deps.config, extension.id);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const input = validateInput(schema, normalized);
|
|
48
|
-
|
|
49
|
-
const connectionCfg = needsConnection
|
|
50
|
-
? getConnectionConfig(deps.config, extension.id, String(input.connection))
|
|
51
|
-
: undefined;
|
|
52
|
-
|
|
53
|
-
const ctx: ToolContext = {
|
|
54
|
-
toolName,
|
|
55
|
-
logger: deps.logger ?? noopLogger,
|
|
56
|
-
config: (connectionCfg ?? { connections: sanitizeExtensionConnections(deps.config)[extension.id] ?? {} }) as Record<string, unknown>,
|
|
57
|
-
secrets: deps.secrets,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const def = await loadExtension(extension);
|
|
61
|
-
const handler = def.handlers[toolName];
|
|
62
|
-
if (!handler) {
|
|
63
|
-
throw new OmtError("HANDLER_MISSING", `no handler for ${toolName}`);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const result: ToolResult = await handler(ctx, input);
|
|
67
|
-
const durationMs = Date.now() - started;
|
|
68
|
-
return {
|
|
69
|
-
ok: true,
|
|
70
|
-
tool: toolName,
|
|
71
|
-
data: result.data,
|
|
72
|
-
meta: { durationMs, ...(result.meta ?? {}) },
|
|
73
|
-
};
|
|
74
|
-
} catch (e) {
|
|
75
|
-
const durationMs = Date.now() - started;
|
|
76
|
-
if (e instanceof PolicyError) {
|
|
77
|
-
return { ok: false, tool: toolName, error: { code: "POLICY_VIOLATION", message: e.message } };
|
|
78
|
-
}
|
|
79
|
-
if (e instanceof ToolError) {
|
|
80
|
-
return { ok: false, tool: toolName, error: { code: e.code, message: e.message } };
|
|
81
|
-
}
|
|
82
|
-
if (e instanceof OmtError) {
|
|
83
|
-
return { ok: false, tool: toolName, error: { code: e.code, message: e.message } };
|
|
84
|
-
}
|
|
85
|
-
if (e && typeof e === "object" && "code" in e && typeof (e as { code?: unknown }).code === "string") {
|
|
86
|
-
return {
|
|
87
|
-
ok: false,
|
|
88
|
-
tool: toolName,
|
|
89
|
-
error: { code: (e as { code: string }).code, message: e instanceof Error ? e.message : String(e) },
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
return {
|
|
93
|
-
ok: false,
|
|
94
|
-
tool: toolName,
|
|
95
|
-
error: { code: "EXECUTION_FAILED", message: e instanceof Error ? e.message : String(e) },
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
package/src/core/registry.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { ExtensionManifest } from "@oh-my-tool/sdk";
|
|
2
|
-
import type { InstalledExtension } from "../extension/discovery";
|
|
3
|
-
|
|
4
|
-
import { RuntimeError as OmtError } from "../runtime/errors";
|
|
5
|
-
export { OmtError };
|
|
6
|
-
|
|
7
|
-
export interface Registry {
|
|
8
|
-
byTool: Map<string, { extension: InstalledExtension; tool: ExtensionManifest["tools"][number] }>;
|
|
9
|
-
byId: Map<string, InstalledExtension>;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function createRegistry(installed: InstalledExtension[]): Registry {
|
|
13
|
-
const byTool = new Map();
|
|
14
|
-
const byId = new Map();
|
|
15
|
-
for (const ext of installed) {
|
|
16
|
-
byId.set(ext.id, ext);
|
|
17
|
-
for (const tool of ext.manifest.tools) {
|
|
18
|
-
byTool.set(tool.name, { extension: ext, tool });
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
return { byTool, byId };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function resolveTool(
|
|
25
|
-
reg: Registry,
|
|
26
|
-
toolName: string,
|
|
27
|
-
): { extension: InstalledExtension; tool: ExtensionManifest["tools"][number] } {
|
|
28
|
-
const hit = reg.byTool.get(toolName);
|
|
29
|
-
if (!hit) {
|
|
30
|
-
throw new OmtError("UNKNOWN_TOOL", `unknown tool '${toolName}'`);
|
|
31
|
-
}
|
|
32
|
-
return hit;
|
|
33
|
-
}
|
package/src/core/result.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
export interface OmtOk {
|
|
2
|
-
ok: true;
|
|
3
|
-
tool: string;
|
|
4
|
-
data: unknown;
|
|
5
|
-
meta: Record<string, unknown>;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface OmtErr {
|
|
9
|
-
ok: false;
|
|
10
|
-
tool: string;
|
|
11
|
-
error: { code: string; message: string; details?: unknown };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export type OmtResult = OmtOk | OmtErr;
|
package/src/core/schema.ts
DELETED
package/src/search/search.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|
-
}
|