@oh-my-tool/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +7 -2
  2. package/assets/skills/oh-my-tool/SKILL.md +11 -3
  3. package/bin/ohmytool.cjs +0 -0
  4. package/package.json +20 -11
  5. package/src/cli/commands/connections.ts +98 -0
  6. package/src/cli/commands/describe.ts +23 -22
  7. package/src/cli/commands/extension.ts +24 -24
  8. package/src/cli/commands/index.ts +8 -6
  9. package/src/cli/commands/integrate.ts +64 -64
  10. package/src/cli/commands/mcp.ts +86 -0
  11. package/src/cli/commands/run.ts +14 -8
  12. package/src/cli/commands/search.ts +14 -13
  13. package/src/cli/commands/secret.ts +68 -68
  14. package/src/cli/context.ts +37 -4
  15. package/src/cli/index.ts +338 -273
  16. package/src/cli/output.ts +165 -0
  17. package/src/cli/parseArgs.ts +64 -44
  18. package/src/config/config.ts +207 -63
  19. package/src/core/executor.ts +89 -89
  20. package/src/core/registry.ts +31 -31
  21. package/src/core/result.ts +14 -14
  22. package/src/extension/discovery.ts +61 -61
  23. package/src/extension/install.ts +23 -23
  24. package/src/extension/loader.ts +32 -32
  25. package/src/extension/manifest.ts +114 -114
  26. package/src/integration/adapters.ts +98 -98
  27. package/src/integration/index.ts +4 -4
  28. package/src/integration/manager.ts +375 -375
  29. package/src/integration/skill.ts +84 -84
  30. package/src/integration/types.ts +55 -55
  31. package/src/policy/policy.ts +136 -136
  32. package/src/runtime/errors.ts +7 -2
  33. package/src/runtime/executor.ts +6 -1
  34. package/src/runtime/provider.ts +2 -1
  35. package/src/runtime/providers/mcp/normalize.ts +36 -0
  36. package/src/runtime/providers/mcp/oauth-callback.ts +91 -0
  37. package/src/runtime/providers/mcp/oauth-provider.ts +348 -0
  38. package/src/runtime/providers/mcp/oauth-store.ts +106 -0
  39. package/src/runtime/providers/mcp/provider.ts +99 -0
  40. package/src/runtime/providers/mcp/safe-errors.ts +63 -0
  41. package/src/runtime/providers/mcp/session.ts +117 -0
  42. package/src/runtime/providers/mcp/transport.ts +140 -0
  43. package/src/runtime/providers/native/provider.ts +1 -1
  44. package/src/runtime/result.ts +1 -1
  45. package/src/runtime/runtime.ts +38 -12
  46. package/src/runtime/schema.ts +14 -4
  47. package/src/search/search.ts +78 -78
  48. package/src/secrets/secrets.ts +45 -45
  49. package/src/version.ts +1 -1
@@ -0,0 +1,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
+ }
@@ -1,44 +1,64 @@
1
- export interface ParsedArgs {
2
- positional: string[];
3
- keyValues: Record<string, string>;
4
- flags: string[];
5
- options: Record<string, string>;
6
- }
7
-
8
- export function parseArgs(argv: string[]): ParsedArgs {
9
- const positional: string[] = [];
10
- const keyValues: Record<string, string> = {};
11
- const flags: string[] = [];
12
- const options: Record<string, string> = {};
13
- for (const arg of argv) {
14
- if (arg.startsWith("--")) {
15
- const option = arg.slice(2);
16
- const eq = option.indexOf("=");
17
- if (eq > 0) {
18
- options[option.slice(0, eq)] = option.slice(eq + 1);
19
- } else {
20
- flags.push(option);
21
- }
22
- } else {
23
- const eq = arg.indexOf("=");
24
- if (eq > 0) {
25
- keyValues[arg.slice(0, eq)] = arg.slice(eq + 1);
26
- } else {
27
- positional.push(arg);
28
- }
29
- }
30
- }
31
- return { positional, keyValues, flags, options };
32
- }
33
-
34
- export function coerceInput(input: Record<string, unknown>): Record<string, unknown> {
35
- const out: Record<string, unknown> = {};
36
- for (const [k, v] of Object.entries(input)) {
37
- if (typeof v === "string" && /^-?\d+$/.test(v)) {
38
- out[k] = Number(v);
39
- } else {
40
- out[k] = v;
41
- }
42
- }
43
- return out;
44
- }
1
+ export interface ParsedArgs {
2
+ positional: string[];
3
+ keyValues: Record<string, string>;
4
+ flags: string[];
5
+ options: Record<string, string>;
6
+ }
7
+
8
+ export type ParsedMcpCommand =
9
+ | { readonly action: "list" }
10
+ | { readonly action: "auth" | "logout"; readonly serverId: string };
11
+
12
+ export function parseArgs(argv: string[]): ParsedArgs {
13
+ const positional: string[] = [];
14
+ const keyValues: Record<string, string> = {};
15
+ const flags: string[] = [];
16
+ const options: Record<string, string> = {};
17
+ for (const arg of argv) {
18
+ if (arg.startsWith("--")) {
19
+ const option = arg.slice(2);
20
+ const eq = option.indexOf("=");
21
+ if (eq > 0) {
22
+ options[option.slice(0, eq)] = option.slice(eq + 1);
23
+ } else {
24
+ flags.push(option);
25
+ }
26
+ } else {
27
+ const eq = arg.indexOf("=");
28
+ if (eq > 0) {
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);
32
+ } else {
33
+ positional.push(arg);
34
+ }
35
+ }
36
+ }
37
+ return { positional, keyValues, flags, options };
38
+ }
39
+
40
+ export function parseMcpCommand(args: ParsedArgs): ParsedMcpCommand | undefined {
41
+ if (args.positional[0] !== "mcp") return undefined;
42
+ const action = args.positional[1];
43
+ const serverId = args.positional[2];
44
+ if (action === "list") return args.positional.length === 2 ? { action } : undefined;
45
+ if (
46
+ (action !== "auth" && action !== "logout") ||
47
+ serverId === undefined ||
48
+ serverId.length === 0 ||
49
+ args.positional.length !== 3
50
+ ) return undefined;
51
+ return { action, serverId };
52
+ }
53
+
54
+ export function coerceInput(input: Record<string, unknown>): Record<string, unknown> {
55
+ const out: Record<string, unknown> = {};
56
+ for (const [k, v] of Object.entries(input)) {
57
+ if (typeof v === "string" && /^-?\d+$/.test(v)) {
58
+ out[k] = Number(v);
59
+ } else {
60
+ out[k] = v;
61
+ }
62
+ }
63
+ return out;
64
+ }
@@ -1,63 +1,207 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
3
-
4
- export interface ConnectionConfig {
5
- environment: string;
6
- host: string;
7
- port: number;
8
- database: string;
9
- username: string;
10
- secret: string;
11
- tls: boolean;
12
- }
13
-
14
- export interface Config {
15
- extensions: Record<string, { connections: Record<string, ConnectionConfig> }>;
16
- }
17
-
18
- export function loadConfig(homeDir: string): Config {
19
- const path = join(homeDir, "config.toml");
20
- if (!existsSync(path)) {
21
- return { extensions: {} };
22
- }
23
- const raw = readFileSync(path, "utf8");
24
- const parsed = Bun.TOML.parse(raw) as Record<string, unknown>;
25
-
26
- const extensions: Config["extensions"] = {};
27
- const extSection = (parsed as Record<string, any>)["extensions"];
28
- if (extSection && typeof extSection === "object") {
29
- for (const [extId, extVal] of Object.entries(extSection)) {
30
- const connections: Record<string, ConnectionConfig> = {};
31
- const connSection = (extVal as any)?.["connections"];
32
- if (connSection && typeof connSection === "object") {
33
- for (const [name, rawConn] of Object.entries(connSection)) {
34
- const rc = rawConn as Record<string, any>;
35
- connections[name] = {
36
- environment: String(rc.environment ?? ""),
37
- host: String(rc.host ?? ""),
38
- port: Number(rc.port ?? 3306),
39
- database: String(rc.database ?? ""),
40
- username: String(rc.username ?? ""),
41
- secret: String(rc.secret ?? ""),
42
- tls: Boolean(rc.tls ?? false),
43
- };
44
- }
45
- }
46
- extensions[extId] = { connections };
47
- }
48
- }
49
- return { extensions };
50
- }
51
-
52
- export function getConnectionConfig(
53
- cfg: Config,
54
- extensionId: string,
55
- connection: string,
56
- ): ConnectionConfig | undefined {
57
- return cfg.extensions[extensionId]?.connections[connection];
58
- }
59
-
60
- export function listConnections(cfg: Config, extensionId: string): string[] {
61
- const conns = cfg.extensions[extensionId]?.connections ?? {};
62
- return Object.keys(conns);
63
- }
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { RuntimeError } from "../runtime/errors";
4
+
5
+ export interface ConnectionConfig {
6
+ environment: string;
7
+ host: string;
8
+ port: number;
9
+ database: string;
10
+ username: string;
11
+ secret: string;
12
+ tls: boolean;
13
+ }
14
+
15
+ export interface McpCommonServerConfig { readonly enabled: true; readonly namespace: string; }
16
+ export interface McpStdioServerConfig extends McpCommonServerConfig {
17
+ readonly transport: "stdio"; readonly command: string; readonly args: readonly string[]; readonly cwd?: string;
18
+ readonly env: Readonly<Record<string, string>>; readonly secretEnv: Readonly<Record<string, string>>;
19
+ }
20
+ export interface McpHttpServerConfig extends McpCommonServerConfig {
21
+ readonly transport: "streamable-http"; readonly url: string; readonly headers: Readonly<Record<string, string>>;
22
+ readonly secretHeaders: Readonly<Record<string, string>>; readonly auth: McpHttpAuthConfig;
23
+ }
24
+ export type McpHttpAuthConfig =
25
+ | { readonly type: "none" }
26
+ | { readonly type: "bearer"; readonly tokenSecret: string }
27
+ | { readonly type: "oauth"; readonly scopes: readonly string[]; readonly callbackPort: number; readonly clientId?: string; readonly clientSecretSecret?: string; readonly tokenEndpointAuthMethod: "none" | "client_secret_basic" | "client_secret_post" };
28
+ export type McpEnabledServerConfig = McpStdioServerConfig | McpHttpServerConfig;
29
+ export interface McpDisabledServerConfig {
30
+ readonly enabled: false;
31
+ readonly namespace: string;
32
+ readonly transport: "disabled";
33
+ }
34
+ export type McpServerConfig = McpEnabledServerConfig | McpDisabledServerConfig;
35
+ export interface Config {
36
+ extensions: Record<string, { connections: Record<string, ConnectionConfig> }>;
37
+ mcp: { servers: Record<string, McpServerConfig> };
38
+ }
39
+
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
+ }
72
+
73
+ function parseStringMap(value: unknown, path: string): Record<string, string> {
74
+ if (value === undefined) return {};
75
+ if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(path, "must be a table");
76
+ const result: Record<string, string> = {};
77
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
78
+ if (typeof entry !== "string") invalid(`${path}.${key}`, "must be a string");
79
+ result[key] = entry;
80
+ }
81
+ return result;
82
+ }
83
+
84
+ function parseStringArray(value: unknown, path: string): string[] {
85
+ if (value === undefined) return [];
86
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) invalid(path, "must be an array of strings");
87
+ return [...value];
88
+ }
89
+
90
+ function assertMcpName(value: string, path: string): void {
91
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(value)) {
92
+ invalid(path, "must contain only lowercase letters, numbers, underscores, and hyphens");
93
+ }
94
+ }
95
+
96
+ function requiredString(value: unknown, path: string): string {
97
+ if (typeof value !== "string" || value.length === 0) invalid(path, "must be a non-empty string");
98
+ return value;
99
+ }
100
+
101
+ function parseMcpServer(id: string, value: unknown): McpServerConfig {
102
+ const path = `mcp.servers.${id}`;
103
+ assertMcpName(id, path);
104
+ if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(path, "must be a table");
105
+ const raw = value as Record<string, unknown>;
106
+ const enabled = raw.enabled === undefined ? true : raw.enabled;
107
+ if (typeof enabled !== "boolean") invalid(`${path}.enabled`, "must be a boolean");
108
+ if (!enabled) return { enabled: false, namespace: id, transport: "disabled" };
109
+ const namespace = raw.namespace === undefined ? id : requiredString(raw.namespace, `${path}.namespace`);
110
+ assertMcpName(namespace, `${path}.namespace`);
111
+ if (namespace === "native" || namespace === "mcp") invalid(`${path}.namespace`, "is reserved");
112
+
113
+ if (raw.transport === "stdio") {
114
+ if (raw.auth !== undefined) invalid(`${path}.auth`, "is only supported for HTTP servers");
115
+ const command = requiredString(raw.command, `${path}.command`);
116
+ const args = parseStringArray(raw.args, `${path}.args`);
117
+ const cwd = raw.cwd === undefined ? undefined : requiredString(raw.cwd, `${path}.cwd`);
118
+ return { enabled, transport: "stdio", command, args, ...(cwd === undefined ? {} : { cwd }), namespace, env: parseStringMap(raw.env, `${path}.env`), secretEnv: parseStringMap(raw.secretEnv, `${path}.secretEnv`) };
119
+ }
120
+ if (raw.transport !== "streamable-http") invalid(`${path}.transport`, "must be stdio or streamable-http");
121
+ const url = requiredString(raw.url, `${path}.url`);
122
+ try {
123
+ const parsedUrl = new URL(url);
124
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") invalid(`${path}.url`, "must use http or https");
125
+ } catch { invalid(`${path}.url`, "must be a valid HTTP(S) URL"); }
126
+ const headers = parseStringMap(raw.headers, `${path}.headers`);
127
+ const secretHeaders = parseStringMap(raw.secretHeaders, `${path}.secretHeaders`);
128
+ const secretHeaderNames = new Set(Object.keys(secretHeaders).map((key) => key.toLowerCase()));
129
+ for (const key of Object.keys(headers)) {
130
+ if (secretHeaderNames.has(key.toLowerCase())) invalid(`${path}.headers`, "must not overlap secretHeaders");
131
+ }
132
+ const hasAuthorizationHeader = [...Object.keys(headers), ...Object.keys(secretHeaders)]
133
+ .some((key) => key.toLowerCase() === "authorization");
134
+ if (hasAuthorizationHeader && raw.bearerTokenSecret !== undefined) {
135
+ invalid(`${path}.bearerTokenSecret`, "must not be combined with Authorization");
136
+ }
137
+ const authMode = raw.auth === undefined ? "none" : raw.auth;
138
+ if (hasAuthorizationHeader && (authMode === "bearer" || authMode === "oauth")) {
139
+ invalid(`${path}.headers`, "must not configure Authorization when bearer or oauth auth is enabled");
140
+ }
141
+ if (authMode === "none") return { enabled, transport: "streamable-http", url, namespace, headers, secretHeaders, auth: { type: "none" } };
142
+ if (authMode === "bearer") return { enabled, transport: "streamable-http", url, namespace, headers, secretHeaders, auth: { type: "bearer", tokenSecret: requiredString(raw.bearerTokenSecret, `${path}.bearerTokenSecret`) } };
143
+ if (authMode !== "oauth") invalid(`${path}.auth`, "must be none, bearer, or oauth");
144
+ const scopes = parseStringArray(raw.oauthScopes, `${path}.oauthScopes`);
145
+ const callbackPort = raw.oauthCallbackPort === undefined ? 0 : raw.oauthCallbackPort;
146
+ if (typeof callbackPort !== "number" || !Number.isInteger(callbackPort) || (callbackPort !== 0 && (callbackPort < 1024 || callbackPort > 65535))) invalid(`${path}.oauthCallbackPort`, "must be 0 or an integer from 1024 through 65535");
147
+ const clientId = raw.oauthClientId === undefined ? undefined : requiredString(raw.oauthClientId, `${path}.oauthClientId`);
148
+ const clientSecretSecret = raw.oauthClientSecretSecret === undefined ? undefined : requiredString(raw.oauthClientSecretSecret, `${path}.oauthClientSecretSecret`);
149
+ if (clientSecretSecret !== undefined && clientId === undefined) invalid(`${path}.oauthClientSecretSecret`, "requires oauthClientId");
150
+ const tokenEndpointAuthMethod = raw.oauthTokenEndpointAuthMethod === undefined ? "none" : raw.oauthTokenEndpointAuthMethod;
151
+ if (tokenEndpointAuthMethod !== "none" && tokenEndpointAuthMethod !== "client_secret_basic" && tokenEndpointAuthMethod !== "client_secret_post") invalid(`${path}.oauthTokenEndpointAuthMethod`, "must be none, client_secret_basic, or client_secret_post");
152
+ if (tokenEndpointAuthMethod !== "none" && clientSecretSecret === undefined) invalid(`${path}.oauthTokenEndpointAuthMethod`, "requires oauthClientSecretSecret");
153
+ if (tokenEndpointAuthMethod === "none" && clientSecretSecret !== undefined) invalid(`${path}.oauthTokenEndpointAuthMethod`, "cannot be none with a client secret");
154
+ return { enabled, transport: "streamable-http", url, namespace, headers, secretHeaders, auth: { type: "oauth", scopes, callbackPort, ...(clientId === undefined ? {} : { clientId }), ...(clientSecretSecret === undefined ? {} : { clientSecretSecret }), tokenEndpointAuthMethod } };
155
+ }
156
+
157
+ export function loadConfig(homeDir: string): Config {
158
+ const path = join(homeDir, "config.toml");
159
+ if (!existsSync(path)) return { extensions: {}, mcp: { servers: {} } };
160
+ const parsed = Bun.TOML.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
161
+ const extensions: Config["extensions"] = {};
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
+ }
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");
168
+ const connections: Record<string, ConnectionConfig> = {};
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
+ }
173
+ if (connSection && typeof connSection === "object") for (const [name, rawConn] of Object.entries(connSection)) {
174
+ connections[name] = parseConnection(extId, name, rawConn);
175
+ }
176
+ extensions[extId] = { connections };
177
+ }
178
+ const servers: Record<string, McpServerConfig> = {};
179
+ const serverSection = (parsed.mcp as Record<string, unknown> | undefined)?.servers;
180
+ if (serverSection !== undefined) {
181
+ if (serverSection === null || typeof serverSection !== "object" || Array.isArray(serverSection)) {
182
+ invalid("mcp.servers", "must be a table");
183
+ }
184
+ for (const [id, value] of Object.entries(serverSection)) {
185
+ servers[id] = parseMcpServer(id, value);
186
+ }
187
+ }
188
+ return { extensions, mcp: { servers } };
189
+ }
190
+
191
+ export function getConnectionConfig(cfg: Config, extensionId: string, connection: string): ConnectionConfig | undefined { return cfg.extensions[extensionId]?.connections[connection]; }
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
+ }