@oh-my-tool/cli 0.3.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.
- package/package.json +10 -3
- package/src/cli/commands/connections.ts +98 -0
- package/src/cli/commands/describe.ts +1 -1
- package/src/cli/commands/index.ts +1 -0
- package/src/cli/commands/run.ts +7 -2
- package/src/cli/context.ts +17 -7
- package/src/cli/index.ts +46 -5
- package/src/cli/output.ts +165 -0
- package/src/cli/parseArgs.ts +3 -1
- package/src/config/config.ts +55 -3
- package/src/core/executor.ts +2 -2
- package/src/policy/policy.ts +1 -1
- package/src/runtime/provider.ts +1 -1
- package/src/runtime/providers/native/provider.ts +1 -1
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-tool/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Oh My Tool CLI - local and enterprise tools for agents",
|
|
6
|
-
"keywords": [
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai",
|
|
8
|
+
"agent",
|
|
9
|
+
"cli",
|
|
10
|
+
"tools",
|
|
11
|
+
"extensions",
|
|
12
|
+
"bun"
|
|
13
|
+
],
|
|
7
14
|
"homepage": "https://github.com/oh-my-tool/oh-my-tool#readme",
|
|
8
15
|
"repository": {
|
|
9
16
|
"type": "git",
|
|
@@ -22,7 +29,7 @@
|
|
|
22
29
|
"dependencies": {
|
|
23
30
|
"@clack/prompts": "^1.7.0",
|
|
24
31
|
"@modelcontextprotocol/client": "2.0.0",
|
|
25
|
-
"@oh-my-tool/sdk": "0.3.
|
|
32
|
+
"@oh-my-tool/sdk": "0.3.1",
|
|
26
33
|
"open": "11.0.0"
|
|
27
34
|
},
|
|
28
35
|
"engines": {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { loadConfig } from "../../config/config";
|
|
2
|
+
import { createPaths } from "../../paths";
|
|
3
|
+
import { prepareHome } from "../../migration";
|
|
4
|
+
import { withRuntime } from "../context";
|
|
5
|
+
|
|
6
|
+
export interface ConnectionSummary {
|
|
7
|
+
extension: string;
|
|
8
|
+
name: string;
|
|
9
|
+
environment: string;
|
|
10
|
+
host: string;
|
|
11
|
+
port: number;
|
|
12
|
+
database: string;
|
|
13
|
+
username: string;
|
|
14
|
+
tls: boolean;
|
|
15
|
+
secretConfigured: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ConnectionListResult {
|
|
19
|
+
connections: ConnectionSummary[];
|
|
20
|
+
count: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ConnectionCheck {
|
|
24
|
+
extension: string;
|
|
25
|
+
name: string;
|
|
26
|
+
status: "ok" | "error" | "unsupported";
|
|
27
|
+
code?: string;
|
|
28
|
+
durationMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConnectionCheckResult {
|
|
32
|
+
checks: ConnectionCheck[];
|
|
33
|
+
count: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ConfigCheckResult {
|
|
37
|
+
valid: true;
|
|
38
|
+
connectionCount: number;
|
|
39
|
+
extensionCount: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function configuredConnections(): Promise<ConnectionListResult> {
|
|
43
|
+
const paths = createPaths();
|
|
44
|
+
await prepareHome(paths);
|
|
45
|
+
const config = loadConfig(paths.home);
|
|
46
|
+
const connections = Object.entries(config.extensions)
|
|
47
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
48
|
+
.flatMap(([extension, value]) => Object.entries(value.connections)
|
|
49
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
50
|
+
.map(([name, connection]) => ({
|
|
51
|
+
extension,
|
|
52
|
+
name,
|
|
53
|
+
environment: connection.environment,
|
|
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,
|
|
60
|
+
})));
|
|
61
|
+
return { connections, count: connections.length };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runConnectionList(): Promise<ConnectionListResult> {
|
|
65
|
+
return configuredConnections();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runConfigCheck(): Promise<ConfigCheckResult> {
|
|
69
|
+
const paths = createPaths();
|
|
70
|
+
await prepareHome(paths);
|
|
71
|
+
const config = loadConfig(paths.home);
|
|
72
|
+
return {
|
|
73
|
+
valid: true,
|
|
74
|
+
connectionCount: Object.values(config.extensions).reduce((count, extension) => count + Object.keys(extension.connections).length, 0),
|
|
75
|
+
extensionCount: Object.keys(config.extensions).length,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function runConnectionCheck(): Promise<ConnectionCheckResult> {
|
|
80
|
+
const list = await configuredConnections();
|
|
81
|
+
return withRuntime(async (runtime) => {
|
|
82
|
+
const checks: ConnectionCheck[] = [];
|
|
83
|
+
for (const connection of list.connections) {
|
|
84
|
+
if (connection.extension !== "redis" && connection.extension !== "mysql") {
|
|
85
|
+
checks.push({ extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
const result = await runtime.run(`${connection.extension}.ping`, { connection: connection.name });
|
|
90
|
+
checks.push(result.ok
|
|
91
|
+
? { extension: connection.extension, name: connection.name, status: "ok", durationMs: Date.now() - started }
|
|
92
|
+
: result.error?.code === "TOOL_NOT_FOUND"
|
|
93
|
+
? { extension: connection.extension, name: connection.name, status: "unsupported", code: "CHECK_UNSUPPORTED", durationMs: Date.now() - started }
|
|
94
|
+
: { extension: connection.extension, name: connection.name, status: "error", code: result.error?.code ?? "CHECK_FAILED", durationMs: Date.now() - started });
|
|
95
|
+
}
|
|
96
|
+
return { checks, count: checks.length };
|
|
97
|
+
}, { includeMcp: false });
|
|
98
|
+
}
|
|
@@ -18,7 +18,7 @@ export async function runDescribe(toolName: string): Promise<DescribedTool> {
|
|
|
18
18
|
risk: descriptor.risk,
|
|
19
19
|
inputSchema: descriptor.inputSchema,
|
|
20
20
|
extension: descriptor.source.id,
|
|
21
|
-
extensionVersion: "unknown",
|
|
21
|
+
extensionVersion: descriptor.source.version ?? "unknown",
|
|
22
22
|
};
|
|
23
23
|
});
|
|
24
24
|
}
|
package/src/cli/commands/run.ts
CHANGED
|
@@ -19,7 +19,7 @@ export async function runTool(
|
|
|
19
19
|
return { ok: true, tool: toolName, data: result.output, meta: result.meta ?? {} };
|
|
20
20
|
}
|
|
21
21
|
return { ok: false, tool: toolName, error: result.error ?? { code: "EXECUTION_FAILED", message: "execution failed" } };
|
|
22
|
-
});
|
|
22
|
+
}, { targetTool: toolName });
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function readStdinJson(): Promise<Record<string, unknown>> {
|
|
@@ -30,7 +30,12 @@ function readStdinJson(): Promise<Record<string, unknown>> {
|
|
|
30
30
|
stdin.on("data", (chunk: string) => (data += chunk));
|
|
31
31
|
stdin.on("end", () => {
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
const parsed: unknown = JSON.parse(data);
|
|
34
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
35
|
+
reject(new Error("stdin JSON must be an object"));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve(parsed as Record<string, unknown>);
|
|
34
39
|
} catch (e) {
|
|
35
40
|
reject(e);
|
|
36
41
|
}
|
package/src/cli/context.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createPaths } from "../paths";
|
|
2
2
|
import { prepareHome } from "../migration";
|
|
3
|
-
import { loadConfig, getConnectionConfig, type McpEnabledServerConfig } from "../config/config";
|
|
3
|
+
import { loadConfig, getConnectionConfig, sanitizeExtensionConnections, 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";
|
|
8
9
|
import { createToolRuntime } from "../runtime/runtime";
|
|
9
10
|
import type { ToolDescriptor } from "../runtime/provider";
|
|
10
11
|
|
|
@@ -12,15 +13,24 @@ export function homeDir(): string {
|
|
|
12
13
|
return createPaths().home;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
export
|
|
16
|
+
export interface RuntimeOptions {
|
|
17
|
+
readonly includeMcp?: boolean;
|
|
18
|
+
readonly targetTool?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function createRuntime(options: RuntimeOptions = {}) {
|
|
16
22
|
const paths = createPaths();
|
|
17
23
|
await prepareHome(paths);
|
|
18
24
|
const config = loadConfig(paths.home);
|
|
19
25
|
const secrets = new SecretsManager();
|
|
20
|
-
const
|
|
26
|
+
const extensionConnections = sanitizeExtensionConnections(config);
|
|
27
|
+
const nativeTarget = options.targetTool !== undefined && discoverExtensions(paths.home)
|
|
28
|
+
.some((extension) => extension.manifest.tools.some((tool) => tool.name === options.targetTool));
|
|
29
|
+
const mcpProviders = options.includeMcp === false || nativeTarget ? [] : Object.entries(config.mcp.servers)
|
|
21
30
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
22
31
|
.filter((entry): entry is [string, McpEnabledServerConfig] => entry[1].enabled)
|
|
23
|
-
.map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }))
|
|
32
|
+
.map(([serverId, server]) => new McpProvider({ serverId, config: server, secrets }));
|
|
33
|
+
const providers = [new NativeExtensionProvider(paths), ...mcpProviders];
|
|
24
34
|
return createToolRuntime({
|
|
25
35
|
providers,
|
|
26
36
|
policy: {
|
|
@@ -42,15 +52,15 @@ export async function createRuntime() {
|
|
|
42
52
|
: undefined;
|
|
43
53
|
return {
|
|
44
54
|
logger: { debug() {}, info() {}, warn() {}, error() {} },
|
|
45
|
-
config: (connection ?? {}) as Record<string, unknown>,
|
|
55
|
+
config: (connection ?? { connections: extensionConnections[extensionId] ?? {} }) as Record<string, unknown>,
|
|
46
56
|
secrets,
|
|
47
57
|
};
|
|
48
58
|
},
|
|
49
59
|
});
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
export async function withRuntime<T>(operation: (runtime: Awaited<ReturnType<typeof createRuntime>>) => Promise<T
|
|
53
|
-
const runtime = await createRuntime();
|
|
62
|
+
export async function withRuntime<T>(operation: (runtime: Awaited<ReturnType<typeof createRuntime>>) => Promise<T>, options: RuntimeOptions = {}): Promise<T> {
|
|
63
|
+
const runtime = await createRuntime(options);
|
|
54
64
|
let operationFailed = false;
|
|
55
65
|
try {
|
|
56
66
|
return await operation(runtime);
|
package/src/cli/index.ts
CHANGED
|
@@ -14,15 +14,21 @@ import { AGENT_IDS } from "../integration";
|
|
|
14
14
|
import { multiselect, isCancel } from "@clack/prompts";
|
|
15
15
|
import { VERSION } from "../version";
|
|
16
16
|
import { runMcpAuth, runMcpList, runMcpLogout } from "./commands/mcp";
|
|
17
|
+
import { runConnectionList, runConnectionCheck, runConfigCheck } from "./commands/connections";
|
|
17
18
|
import { RuntimeError } from "../runtime/errors";
|
|
19
|
+
import { formatAiResult, formatJson, formatOutput, type OutputFormat } from "./output";
|
|
18
20
|
|
|
19
21
|
const HELP = `Oh My Tool - local and enterprise tools for agents
|
|
20
22
|
|
|
21
23
|
Usage:
|
|
22
24
|
ohmytool search "<task>" search tools by intent
|
|
23
25
|
ohmytool describe <tool> inspect a tool and its input schema
|
|
24
|
-
ohmytool run <tool> [key=value ...] execute a tool
|
|
26
|
+
ohmytool run <tool> [key=value ...] execute a tool (AI-friendly text by default)
|
|
25
27
|
ohmytool run <tool> --stdin execute with JSON from stdin
|
|
28
|
+
ohmytool run <tool> ... --json output the machine-readable JSON result
|
|
29
|
+
ohmytool connection list list configured connections
|
|
30
|
+
ohmytool connection check check MySQL/Redis connectivity
|
|
31
|
+
ohmytool config check validate configuration
|
|
26
32
|
ohmytool extension list list installed extensions
|
|
27
33
|
ohmytool extension install <path> install an extension from a local dir
|
|
28
34
|
ohmytool secret set <name> set a secret (interactive hidden prompt or stdin pipe)
|
|
@@ -95,7 +101,15 @@ function readSecretHidden(prompt: string): Promise<string> {
|
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
function print(v: unknown): void {
|
|
98
|
-
console.log(
|
|
104
|
+
console.log(formatJson(v));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function outputFormat(parsed: ReturnType<typeof parseArgs>): OutputFormat {
|
|
108
|
+
const format = parsed.options.format ?? (parsed.options.json === "true" ? "json" : undefined);
|
|
109
|
+
if (format !== undefined && !["json", "text", "table", "csv"].includes(format)) {
|
|
110
|
+
throw new RuntimeError("INVALID_FORMAT", `unsupported output format '${format}'`);
|
|
111
|
+
}
|
|
112
|
+
return parsed.flags.includes("json") ? "json" : (format as OutputFormat | undefined) ?? "text";
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
function parseAgentIds(raw?: string): AgentId[] | undefined {
|
|
@@ -189,9 +203,15 @@ export interface CliDependencies {
|
|
|
189
203
|
const defaultCliDependencies: CliDependencies = { runMcpAuth, runMcpLogout, runMcpList };
|
|
190
204
|
|
|
191
205
|
export async function main(argv: string[], dependencies: CliDependencies = defaultCliDependencies): Promise<number> {
|
|
192
|
-
const parsed = parseArgs(argv);
|
|
193
|
-
const cmd = parsed.positional[0];
|
|
194
206
|
try {
|
|
207
|
+
const parsed = parseArgs(argv);
|
|
208
|
+
const cmd = parsed.positional[0];
|
|
209
|
+
const allowedFlags = new Set(["help", "version", "json", "stdin", "yes", "dry-run", "force"]);
|
|
210
|
+
const allowedOptions = new Set(["format", "json", "agents"]);
|
|
211
|
+
const unknownFlag = parsed.flags.find((flag) => !allowedFlags.has(flag));
|
|
212
|
+
const unknownOption = Object.keys(parsed.options).find((option) => !allowedOptions.has(option));
|
|
213
|
+
if (unknownFlag) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownFlag}'`);
|
|
214
|
+
if (unknownOption) throw new RuntimeError("INVALID_ARGUMENT", `unknown option '--${unknownOption}'`);
|
|
195
215
|
switch (cmd) {
|
|
196
216
|
case "search": {
|
|
197
217
|
const q = parsed.positional.slice(1).join(" ");
|
|
@@ -205,9 +225,30 @@ export async function main(argv: string[], dependencies: CliDependencies = defau
|
|
|
205
225
|
case "run": {
|
|
206
226
|
const tool = parsed.positional[1];
|
|
207
227
|
const res = await runTool(tool, parsed.keyValues, parsed.flags.includes("stdin"));
|
|
208
|
-
|
|
228
|
+
console.log(formatOutput(res, outputFormat(parsed)));
|
|
209
229
|
return res.ok ? 0 : 1;
|
|
210
230
|
}
|
|
231
|
+
case "connection": {
|
|
232
|
+
const action = parsed.positional[1];
|
|
233
|
+
if (action !== "list" && action !== "check") {
|
|
234
|
+
console.error("usage: ohmytool connection list | connection check");
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
const result = action === "list" ? await runConnectionList() : await runConnectionCheck();
|
|
238
|
+
const wrapped = { ok: true as const, tool: `connection.${action}`, data: result, meta: {} };
|
|
239
|
+
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
case "config": {
|
|
243
|
+
if (parsed.positional[1] !== "check") {
|
|
244
|
+
console.error("usage: ohmytool config check");
|
|
245
|
+
return 1;
|
|
246
|
+
}
|
|
247
|
+
const result = await runConfigCheck();
|
|
248
|
+
const wrapped = { ok: true as const, tool: "config.check", data: result, meta: {} };
|
|
249
|
+
console.log(formatOutput(wrapped, outputFormat(parsed)));
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
211
252
|
case "secret": {
|
|
212
253
|
const sub = parsed.positional[1];
|
|
213
254
|
if (sub === "set") {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { OmtResult } from "../core/result";
|
|
2
|
+
|
|
3
|
+
export type OutputFormat = "text" | "json" | "table" | "csv";
|
|
4
|
+
|
|
5
|
+
export function formatAiResult(result: OmtResult): string {
|
|
6
|
+
const lines = [`status: ${result.ok ? "ok" : "error"}`, `tool: ${result.tool}`];
|
|
7
|
+
if (!result.ok) {
|
|
8
|
+
lines.push("error:");
|
|
9
|
+
appendObject(lines, result.error, 2);
|
|
10
|
+
return lines.join("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (isRowsResult(result.data)) {
|
|
14
|
+
lines.push("rows:");
|
|
15
|
+
for (const row of result.data.rows) lines.push(formatRow(row));
|
|
16
|
+
lines.push(`row_count: ${result.data.rows.length}`);
|
|
17
|
+
if (result.data.truncated === true) lines.push("truncated: true");
|
|
18
|
+
appendMeta(lines, result.meta, new Set(["returnedRows"]), false);
|
|
19
|
+
} else {
|
|
20
|
+
lines.push("data:");
|
|
21
|
+
appendValue(lines, result.data, 2);
|
|
22
|
+
appendMeta(lines, result.meta);
|
|
23
|
+
}
|
|
24
|
+
return lines.join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatJson(value: unknown): string {
|
|
28
|
+
const seen = new WeakSet<object>();
|
|
29
|
+
return JSON.stringify(value, (_key, entry) => {
|
|
30
|
+
if (typeof entry === "bigint") return String(entry);
|
|
31
|
+
if (entry !== null && typeof entry === "object") {
|
|
32
|
+
if (seen.has(entry)) return "[Circular]";
|
|
33
|
+
seen.add(entry);
|
|
34
|
+
}
|
|
35
|
+
return entry;
|
|
36
|
+
}, 2) ?? "null";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatOutput(result: OmtResult, format: OutputFormat): string {
|
|
40
|
+
if (format === "json") return formatJson(result);
|
|
41
|
+
if (format === "csv" || format === "table") return formatDelimited(result, format === "csv" ? "," : "\t");
|
|
42
|
+
return formatAiResult(result);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatDelimited(result: OmtResult, delimiter: string): string {
|
|
46
|
+
if (!result.ok) return formatAiResult(result);
|
|
47
|
+
const rows = isRowsResult(result.data)
|
|
48
|
+
? { columns: result.data.columns, rows: result.data.rows }
|
|
49
|
+
: findObjectArray(result.data);
|
|
50
|
+
if (!rows) return formatAiResult(result);
|
|
51
|
+
return [rows.columns, ...rows.rows].map((row) => row.map((value) => quoteDelimited(value, delimiter)).join(delimiter)).join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findObjectArray(value: unknown): { columns: unknown[]; rows: unknown[][] } | undefined {
|
|
55
|
+
if (!isRecord(value)) return undefined;
|
|
56
|
+
for (const entry of Object.values(value)) {
|
|
57
|
+
if (!Array.isArray(entry) || !entry.every(isRecord)) continue;
|
|
58
|
+
const columns = [...new Set(entry.flatMap((item) => Object.keys(item)))];
|
|
59
|
+
return { columns, rows: entry.map((item) => columns.map((column) => item[column] ?? null)) };
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteDelimited(value: unknown, delimiter: string): string {
|
|
65
|
+
const text = value === null || value === undefined ? "" : typeof value === "string" ? value : formatInline(value);
|
|
66
|
+
return delimiter === "," && /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RowsResult {
|
|
70
|
+
columns: unknown[];
|
|
71
|
+
rows: unknown[][];
|
|
72
|
+
truncated?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isRowsResult(value: unknown): value is RowsResult {
|
|
76
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
77
|
+
const candidate = value as { columns?: unknown; rows?: unknown };
|
|
78
|
+
return Array.isArray(candidate.columns) && Array.isArray(candidate.rows) && candidate.rows.every(Array.isArray);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function appendMeta(
|
|
82
|
+
lines: string[],
|
|
83
|
+
meta: Record<string, unknown> | undefined,
|
|
84
|
+
skip = new Set<string>(),
|
|
85
|
+
grouped = true,
|
|
86
|
+
): void {
|
|
87
|
+
const entries = Object.entries(meta ?? {}).filter(([key]) => !skip.has(key));
|
|
88
|
+
if (entries.length === 0) return;
|
|
89
|
+
if (grouped) lines.push("meta:");
|
|
90
|
+
for (const [key, value] of entries) appendField(lines, key, value, grouped ? 2 : 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendValue(lines: string[], value: unknown, indent: number): void {
|
|
94
|
+
if (isRecord(value)) {
|
|
95
|
+
appendObject(lines, value, indent);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
lines.push(`${spaces(indent)}${formatInline(value)}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function appendObject(lines: string[], value: Record<string, unknown>, indent: number): void {
|
|
102
|
+
const entries = Object.entries(value);
|
|
103
|
+
if (entries.length === 0) {
|
|
104
|
+
lines.push(`${spaces(indent)}{}`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [key, entry] of entries) appendField(lines, key, entry, indent);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendField(lines: string[], key: string, value: unknown, indent: number): void {
|
|
111
|
+
const prefix = `${spaces(indent)}${key}:`;
|
|
112
|
+
if (isRecord(value)) {
|
|
113
|
+
lines.push(prefix);
|
|
114
|
+
appendObject(lines, value, indent + 2);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(value) && value.every(isRecord)) {
|
|
118
|
+
lines.push(prefix);
|
|
119
|
+
if (value.length === 0) {
|
|
120
|
+
lines.push(`${spaces(indent + 2)}[]`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
for (const item of value) {
|
|
124
|
+
const entries = Object.entries(item);
|
|
125
|
+
if (entries.length === 0) {
|
|
126
|
+
lines.push(`${spaces(indent + 2)}- {}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const [firstKey, firstValue] = entries[0];
|
|
130
|
+
if (isRecord(firstValue)) {
|
|
131
|
+
lines.push(`${spaces(indent + 2)}- ${firstKey}:`);
|
|
132
|
+
appendObject(lines, firstValue, indent + 4);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push(`${spaces(indent + 2)}- ${firstKey}: ${formatInline(firstValue)}`);
|
|
135
|
+
}
|
|
136
|
+
for (const [itemKey, itemValue] of entries.slice(1)) appendField(lines, itemKey, itemValue, indent + 4);
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
lines.push(`${prefix} ${formatInline(value)}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatRow(row: unknown[], header = false): string {
|
|
144
|
+
return `[${row.map((value) => header && typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_$]*$/.test(value)
|
|
145
|
+
? value
|
|
146
|
+
: formatInline(value)).join(", ")}]`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatInline(value: unknown): string {
|
|
150
|
+
if (value === undefined) return "null";
|
|
151
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
152
|
+
if (typeof value === "bigint") return String(value);
|
|
153
|
+
if (value !== null && typeof value === "object") {
|
|
154
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
155
|
+
}
|
|
156
|
+
return String(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function spaces(count: number): string {
|
|
164
|
+
return " ".repeat(count);
|
|
165
|
+
}
|
package/src/cli/parseArgs.ts
CHANGED
|
@@ -26,7 +26,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
26
26
|
} else {
|
|
27
27
|
const eq = arg.indexOf("=");
|
|
28
28
|
if (eq > 0) {
|
|
29
|
-
|
|
29
|
+
const key = arg.slice(0, eq);
|
|
30
|
+
if (key in keyValues) throw new Error(`duplicate input key '${key}'`);
|
|
31
|
+
keyValues[key] = arg.slice(eq + 1);
|
|
30
32
|
} else {
|
|
31
33
|
positional.push(arg);
|
|
32
34
|
}
|
package/src/config/config.ts
CHANGED
|
@@ -38,6 +38,37 @@ export interface Config {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function invalid(path: string, reason: string): never { throw new RuntimeError("MCP_INVALID_CONFIG", `${path}: ${reason}`); }
|
|
41
|
+
function invalidConnection(path: string, reason: string): never { throw new RuntimeError("CONFIG_INVALID", `${path}: ${reason}`); }
|
|
42
|
+
|
|
43
|
+
function parseConnection(extensionId: string, name: string, value: unknown): ConnectionConfig {
|
|
44
|
+
const path = `extensions.${extensionId}.connections.${name}`;
|
|
45
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalidConnection(path, "must be a table");
|
|
46
|
+
const raw = value as Record<string, unknown>;
|
|
47
|
+
const stringField = (key: string, fallback = ""): string => {
|
|
48
|
+
const entry = raw[key];
|
|
49
|
+
if (entry === undefined) return fallback;
|
|
50
|
+
if (typeof entry !== "string") invalidConnection(`${path}.${key}`, "must be a string");
|
|
51
|
+
return entry;
|
|
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");
|
|
62
|
+
return {
|
|
63
|
+
environment: stringField("environment"),
|
|
64
|
+
host,
|
|
65
|
+
port,
|
|
66
|
+
database: stringField("database"),
|
|
67
|
+
username: stringField("username"),
|
|
68
|
+
secret: stringField("secret"),
|
|
69
|
+
tls,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
41
72
|
|
|
42
73
|
function parseStringMap(value: unknown, path: string): Record<string, string> {
|
|
43
74
|
if (value === undefined) return {};
|
|
@@ -129,12 +160,18 @@ export function loadConfig(homeDir: string): Config {
|
|
|
129
160
|
const parsed = Bun.TOML.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
130
161
|
const extensions: Config["extensions"] = {};
|
|
131
162
|
const extSection = parsed.extensions;
|
|
163
|
+
if (extSection !== undefined && (extSection === null || typeof extSection !== "object" || Array.isArray(extSection))) {
|
|
164
|
+
invalidConnection("extensions", "must be a table");
|
|
165
|
+
}
|
|
132
166
|
if (extSection && typeof extSection === "object") for (const [extId, extVal] of Object.entries(extSection)) {
|
|
167
|
+
if (extVal === null || typeof extVal !== "object" || Array.isArray(extVal)) invalidConnection(`extensions.${extId}`, "must be a table");
|
|
133
168
|
const connections: Record<string, ConnectionConfig> = {};
|
|
134
|
-
const connSection = (extVal as Record<string,
|
|
169
|
+
const connSection = (extVal as Record<string, unknown>).connections;
|
|
170
|
+
if (connSection !== undefined && (connSection === null || typeof connSection !== "object" || Array.isArray(connSection))) {
|
|
171
|
+
invalidConnection(`extensions.${extId}.connections`, "must be a table");
|
|
172
|
+
}
|
|
135
173
|
if (connSection && typeof connSection === "object") for (const [name, rawConn] of Object.entries(connSection)) {
|
|
136
|
-
|
|
137
|
-
connections[name] = { environment: String(rc.environment ?? ""), host: String(rc.host ?? ""), port: Number(rc.port ?? 3306), database: String(rc.database ?? ""), username: String(rc.username ?? ""), secret: String(rc.secret ?? ""), tls: Boolean(rc.tls ?? false) };
|
|
174
|
+
connections[name] = parseConnection(extId, name, rawConn);
|
|
138
175
|
}
|
|
139
176
|
extensions[extId] = { connections };
|
|
140
177
|
}
|
|
@@ -153,3 +190,18 @@ export function loadConfig(homeDir: string): Config {
|
|
|
153
190
|
|
|
154
191
|
export function getConnectionConfig(cfg: Config, extensionId: string, connection: string): ConnectionConfig | undefined { return cfg.extensions[extensionId]?.connections[connection]; }
|
|
155
192
|
export function listConnections(cfg: Config, extensionId: string): string[] { return Object.keys(cfg.extensions[extensionId]?.connections ?? {}); }
|
|
193
|
+
|
|
194
|
+
export function sanitizeExtensionConnections(cfg: Config): Record<string, Record<string, Omit<ConnectionConfig, "secret"> & { secretConfigured: boolean }> > {
|
|
195
|
+
return Object.fromEntries(Object.entries(cfg.extensions).map(([extensionId, extension]) => [
|
|
196
|
+
extensionId,
|
|
197
|
+
Object.fromEntries(Object.entries(extension.connections).map(([name, connection]) => [name, {
|
|
198
|
+
environment: connection.environment,
|
|
199
|
+
host: connection.host,
|
|
200
|
+
port: connection.port,
|
|
201
|
+
database: connection.database,
|
|
202
|
+
username: connection.username,
|
|
203
|
+
tls: connection.tls,
|
|
204
|
+
secretConfigured: connection.secret.length > 0,
|
|
205
|
+
}])),
|
|
206
|
+
]));
|
|
207
|
+
}
|
package/src/core/executor.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Logger, SecretStore, ToolContext, ToolResult } from "@oh-my-tool/sdk";
|
|
2
2
|
import { ToolError } from "@oh-my-tool/sdk";
|
|
3
3
|
import type { Config } from "../config/config";
|
|
4
|
-
import { getConnectionConfig } from "../config/config";
|
|
4
|
+
import { getConnectionConfig, sanitizeExtensionConnections } from "../config/config";
|
|
5
5
|
import { resolveTool, OmtError, type Registry } from "./registry";
|
|
6
6
|
import { validateInput, type Schema } from "./schema";
|
|
7
7
|
import { validateConnectionInput, applyLimits, PolicyError } from "../policy/policy";
|
|
@@ -53,7 +53,7 @@ export async function executeTool(
|
|
|
53
53
|
const ctx: ToolContext = {
|
|
54
54
|
toolName,
|
|
55
55
|
logger: deps.logger ?? noopLogger,
|
|
56
|
-
config: (connectionCfg ?? {}) as Record<string, unknown>,
|
|
56
|
+
config: (connectionCfg ?? { connections: sanitizeExtensionConnections(deps.config)[extension.id] ?? {} }) as Record<string, unknown>,
|
|
57
57
|
secrets: deps.secrets,
|
|
58
58
|
};
|
|
59
59
|
|
package/src/policy/policy.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function assertReadOnly(sql: string): void {
|
|
|
75
75
|
throw new PolicyError("only a single read-only statement is allowed");
|
|
76
76
|
}
|
|
77
77
|
const cleaned = stripLiteralsAndComments(sql);
|
|
78
|
-
if (FORBIDDEN.test(cleaned)) {
|
|
78
|
+
if (!/^(select|with|show|explain|describe|desc)\b/i.test(cleaned.trim()) || FORBIDDEN.test(cleaned) || /\bfor\s+update\b|\block\s+in\s+share\s+mode\b|\binto\s+(out|dump)file\b|\bload_file\s*\(/i.test(cleaned)) {
|
|
79
79
|
throw new PolicyError("sql contains a non read-only statement");
|
|
80
80
|
}
|
|
81
81
|
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ToolDescriptor {
|
|
|
8
8
|
risk: "read" | "write" | "admin";
|
|
9
9
|
inputSchema?: Record<string, unknown>;
|
|
10
10
|
provider: { id: string; kind: string };
|
|
11
|
-
source: { id: string; kind: string };
|
|
11
|
+
source: { id: string; kind: string; version?: string };
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type ToolSearchResult = Omit<ToolDescriptor, "inputSchema">;
|
|
@@ -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
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.3.
|
|
1
|
+
export const VERSION = "0.3.1";
|