@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 CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@oh-my-tool/cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Oh My Tool CLI - local and enterprise tools for agents",
6
- "keywords": ["ai", "agent", "cli", "tools", "extensions", "bun"],
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.0",
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
  }
@@ -5,4 +5,5 @@ export * from "./extension";
5
5
  export * from "./secret";
6
6
  export * from "./integrate";
7
7
  export * from "./mcp";
8
+ export * from "./connections";
8
9
 
@@ -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
- resolve(JSON.parse(data));
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
  }
@@ -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 async function createRuntime() {
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 providers = [new NativeExtensionProvider(paths), ...Object.entries(config.mcp.servers)
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>): 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(JSON.stringify(v, null, 2));
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
- print(res);
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
+ }
@@ -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
- keyValues[arg.slice(0, eq)] = arg.slice(eq + 1);
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
  }
@@ -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, any>)?.connections;
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
- const rc = rawConn as Record<string, any>;
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
+ }
@@ -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
 
@@ -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
  }
@@ -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.0";
1
+ export const VERSION = "0.3.1";