toolnexus 0.1.0

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/src/mcp.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Dynamic MCP source. Mirrors opencode's mcp/index.ts + mcp/catalog.ts, but
3
+ * standalone (no Effect): connect to every configured server, list tools, and
4
+ * convert each into a uniform Tool.
5
+ */
6
+ import { readFileSync } from "node:fs"
7
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js"
8
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
9
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
10
+ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
11
+ import type { Tool as McpToolDef } from "@modelcontextprotocol/sdk/types.js"
12
+ import { type JSONSchema, type Tool, type ToolContext, type ToolResult, type McpStatus, sanitize } from "./types.js"
13
+
14
+ const DEFAULT_TIMEOUT = 30_000
15
+ const MAX_LIST_PAGES = 1_000
16
+
17
+ export interface LocalServer {
18
+ type?: "local"
19
+ command: string[]
20
+ environment?: Record<string, string>
21
+ env?: Record<string, string>
22
+ cwd?: string
23
+ enabled?: boolean
24
+ disabled?: boolean
25
+ timeout?: number
26
+ }
27
+
28
+ export interface RemoteServer {
29
+ type?: "remote"
30
+ url: string
31
+ headers?: Record<string, string>
32
+ enabled?: boolean
33
+ disabled?: boolean
34
+ timeout?: number
35
+ }
36
+
37
+ export type ServerConfig = LocalServer | RemoteServer
38
+ export type McpConfig = Record<string, ServerConfig>
39
+
40
+ function isRemote(cfg: ServerConfig): cfg is RemoteServer {
41
+ if (cfg.type === "remote") return true
42
+ if (cfg.type === "local") return false
43
+ return "url" in cfg && typeof (cfg as RemoteServer).url === "string"
44
+ }
45
+
46
+ function isEnabled(cfg: ServerConfig): boolean {
47
+ if (cfg.disabled === true) return false
48
+ if (cfg.enabled === false) return false
49
+ return true
50
+ }
51
+
52
+ /** Accept a path, a raw object, or an object wrapped under mcpServers/servers/mcp. */
53
+ export function parseMcpConfig(input: string | object): McpConfig {
54
+ const raw: any = typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input
55
+ const servers = raw.mcpServers ?? raw.servers ?? raw.mcp ?? raw
56
+ return servers as McpConfig
57
+ }
58
+
59
+ /**
60
+ * Expand ${ENV_VAR} in header values from process.env so tokens live in the
61
+ * environment, not the committed config. Values are never logged.
62
+ */
63
+ export function expandEnvHeaders(headers?: Record<string, string>): Record<string, string> | undefined {
64
+ if (!headers) return undefined
65
+ const out: Record<string, string> = {}
66
+ for (const [k, v] of Object.entries(headers)) {
67
+ out[k] = v.replace(/\$\{([A-Za-z0-9_]+)\}/g, (_, name) => process.env[name] ?? "")
68
+ }
69
+ return out
70
+ }
71
+
72
+ function formatToolErrorContent(content: unknown): string {
73
+ if (!Array.isArray(content)) return "MCP tool returned an error"
74
+ return (
75
+ content
76
+ .flatMap((item) => (isTextContent(item) ? [item.text] : []))
77
+ .filter((text) => text.trim())
78
+ .join("\n\n") || "MCP tool returned an error"
79
+ )
80
+ }
81
+
82
+ function isTextContent(value: unknown): value is { type: "text"; text: string } {
83
+ return (
84
+ typeof value === "object" &&
85
+ value !== null &&
86
+ (value as any).type === "text" &&
87
+ typeof (value as any).text === "string"
88
+ )
89
+ }
90
+
91
+ function joinTextContent(content: unknown): string {
92
+ if (!Array.isArray(content)) return ""
93
+ return content
94
+ .flatMap((item) => (isTextContent(item) ? [item.text] : []))
95
+ .join("\n")
96
+ }
97
+
98
+ async function paginateTools(client: Client, timeout: number): Promise<McpToolDef[]> {
99
+ const result: McpToolDef[] = []
100
+ const cursors = new Set<string>()
101
+ let cursor: string | undefined
102
+ for (let page = 0; page < MAX_LIST_PAGES; page++) {
103
+ const res = await client.listTools(cursor === undefined ? undefined : { cursor }, { timeout })
104
+ result.push(...res.tools)
105
+ if (res.nextCursor === undefined) return result
106
+ if (cursors.has(res.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${res.nextCursor}`)
107
+ cursors.add(res.nextCursor)
108
+ cursor = res.nextCursor
109
+ }
110
+ throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`)
111
+ }
112
+
113
+ function convertTool(server: string, def: McpToolDef, client: Client, timeout: number): Tool {
114
+ const inputSchema: JSONSchema = {
115
+ ...(def.inputSchema as object),
116
+ type: "object",
117
+ properties: (def.inputSchema?.properties ?? {}) as Record<string, unknown>,
118
+ additionalProperties: false,
119
+ }
120
+ return {
121
+ name: sanitize(server) + "_" + sanitize(def.name),
122
+ description: def.description ?? "",
123
+ inputSchema,
124
+ source: "mcp",
125
+ async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
126
+ try {
127
+ const result: any = await client.callTool(
128
+ { name: def.name, arguments: args ?? {} },
129
+ undefined,
130
+ { resetTimeoutOnProgress: true, signal: ctx?.signal, timeout: ctx?.timeout ?? timeout },
131
+ )
132
+ if (result.isError) {
133
+ return { output: formatToolErrorContent(result.content), isError: true, metadata: { server } }
134
+ }
135
+ if (result.structuredContent !== undefined && result.structuredContent !== null) {
136
+ return { output: JSON.stringify(result.structuredContent), isError: false, metadata: { server } }
137
+ }
138
+ return { output: joinTextContent(result.content), isError: false, metadata: { server } }
139
+ } catch (e) {
140
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
141
+ }
142
+ },
143
+ }
144
+ }
145
+
146
+ export interface McpSource {
147
+ tools: Tool[]
148
+ status: Record<string, McpStatus>
149
+ close(): Promise<void>
150
+ }
151
+
152
+ /** Connect to every enabled server, list + convert tools. Failures are isolated. */
153
+ export async function loadMcp(input: string | object): Promise<McpSource> {
154
+ const config = parseMcpConfig(input)
155
+ const tools: Tool[] = []
156
+ const status: Record<string, McpStatus> = {}
157
+ const clients: Client[] = []
158
+
159
+ await Promise.all(
160
+ Object.entries(config).map(async ([name, cfg]) => {
161
+ if (!isEnabled(cfg)) {
162
+ status[name] = "disabled"
163
+ return
164
+ }
165
+ const timeout = cfg.timeout ?? DEFAULT_TIMEOUT
166
+ const client = new Client({ name: "toolnexus", version: "0.1.0" }, { capabilities: {} })
167
+ try {
168
+ if (isRemote(cfg)) {
169
+ const url = new URL(cfg.url)
170
+ const expanded = expandEnvHeaders(cfg.headers)
171
+ const requestInit = expanded ? { headers: expanded } : undefined
172
+ try {
173
+ await client.connect(new StreamableHTTPClientTransport(url, { requestInit }))
174
+ } catch {
175
+ await client.connect(new SSEClientTransport(url, { requestInit }))
176
+ }
177
+ } else {
178
+ const local = cfg as LocalServer
179
+ const [cmd, ...args] = local.command
180
+ await client.connect(
181
+ new StdioClientTransport({
182
+ command: cmd,
183
+ args,
184
+ cwd: local.cwd,
185
+ env: { ...(process.env as Record<string, string>), ...(local.environment ?? local.env ?? {}) },
186
+ stderr: "pipe",
187
+ }),
188
+ )
189
+ }
190
+ const defs = await paginateTools(client, timeout)
191
+ for (const def of defs) tools.push(convertTool(name, def, client, timeout))
192
+ clients.push(client)
193
+ status[name] = "connected"
194
+ } catch (e) {
195
+ status[name] = "failed"
196
+ await client.close().catch(() => {})
197
+ // one bad server never breaks the toolkit
198
+ console.warn(`[toolnexus] MCP server "${name}" failed: ${e instanceof Error ? e.message : String(e)}`)
199
+ }
200
+ }),
201
+ )
202
+
203
+ return {
204
+ tools,
205
+ status,
206
+ async close() {
207
+ await Promise.all(clients.map((c) => c.close().catch(() => {})))
208
+ },
209
+ }
210
+ }
package/src/native.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Native / annotation tools — turn a plain function into a uniform Tool.
3
+ * See ../../SPEC.md §6.
4
+ */
5
+ import type { JSONSchema, Tool, ToolContext, ToolResult } from "./types.js"
6
+
7
+ export interface DefineToolOptions {
8
+ name: string
9
+ description: string
10
+ inputSchema?: JSONSchema
11
+ source?: Tool["source"]
12
+ /** Return a string (wrapped as output) or a full ToolResult. */
13
+ run: (args: Record<string, unknown>, ctx?: ToolContext) => unknown | Promise<unknown>
14
+ }
15
+
16
+ const EMPTY_SCHEMA: JSONSchema = { type: "object", properties: {}, additionalProperties: false }
17
+
18
+ /** Wrap a function as a Tool. The run() return value becomes the tool output. */
19
+ export function defineTool(opts: DefineToolOptions): Tool {
20
+ return {
21
+ name: opts.name,
22
+ description: opts.description,
23
+ inputSchema: opts.inputSchema ?? EMPTY_SCHEMA,
24
+ source: opts.source ?? "native",
25
+ async execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
26
+ try {
27
+ const out = await opts.run(args ?? {}, ctx)
28
+ if (out && typeof out === "object" && "output" in (out as object) && "isError" in (out as object)) {
29
+ return out as ToolResult
30
+ }
31
+ return { output: typeof out === "string" ? out : JSON.stringify(out), isError: false }
32
+ } catch (e) {
33
+ return { output: e instanceof Error ? e.message : String(e), isError: true }
34
+ }
35
+ },
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Optional decorator sugar: `@tool("description", { name, inputSchema })`.
41
+ * Decorated methods/functions are collected; read them with `collectTools(obj)`.
42
+ */
43
+ const REGISTRY = new WeakMap<object, Tool[]>()
44
+
45
+ export function tool(description: string, opts?: { name?: string; inputSchema?: JSONSchema }) {
46
+ return function (value: any, context: any) {
47
+ const fn = typeof value === "function" ? value : undefined
48
+ if (!fn) return value
49
+ const name = opts?.name ?? (context?.name ? String(context.name) : fn.name)
50
+ const t = defineTool({ name, description, inputSchema: opts?.inputSchema, run: (args, ctx) => fn(args, ctx) })
51
+ if (context?.addInitializer) {
52
+ context.addInitializer(function (this: object) {
53
+ const list = REGISTRY.get(this) ?? []
54
+ list.push(t)
55
+ REGISTRY.set(this, list)
56
+ })
57
+ }
58
+ ;(fn as any).__tool = t
59
+ return value
60
+ }
61
+ }
62
+
63
+ /** Gather tools defined with the @tool decorator on an instance or a plain object of fns. */
64
+ export function collectTools(obj: object): Tool[] {
65
+ const fromInstance = REGISTRY.get(obj) ?? []
66
+ const fromProps: Tool[] = []
67
+ for (const key of Object.keys(obj)) {
68
+ const v = (obj as any)[key]
69
+ if (typeof v === "function" && v.__tool) fromProps.push(v.__tool)
70
+ }
71
+ return [...fromInstance, ...fromProps]
72
+ }
package/src/skill.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Dynamic agent-skill source. Mirrors opencode's skill/index.ts + tool/skill.ts:
3
+ * discover **\/SKILL.md, parse frontmatter, and expose ONE `skill` tool that
4
+ * loads a skill's instructions + sampled resources on demand (progressive
5
+ * disclosure).
6
+ */
7
+ import { readFileSync, readdirSync, statSync } from "node:fs"
8
+ import path from "node:path"
9
+ import { pathToFileURL } from "node:url"
10
+ import type { Tool, ToolResult } from "./types.js"
11
+
12
+ export const SKILL_TOOL_DESCRIPTION = `Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
13
+
14
+ Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
15
+
16
+ The skill name must match one of the skills listed in your system prompt.`
17
+
18
+ export interface SkillInfo {
19
+ name: string
20
+ description?: string
21
+ location: string // absolute path to SKILL.md
22
+ content: string // body after frontmatter
23
+ }
24
+
25
+ /** Minimal YAML frontmatter parser — only flat `key: value` pairs are needed. */
26
+ function parseFrontmatter(text: string): { data: Record<string, string>; content: string } {
27
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
28
+ if (!match) return { data: {}, content: text }
29
+ const data: Record<string, string> = {}
30
+ for (const line of match[1].split(/\r?\n/)) {
31
+ const idx = line.indexOf(":")
32
+ if (idx === -1) continue
33
+ const key = line.slice(0, idx).trim()
34
+ let value = line.slice(idx + 1).trim()
35
+ if (
36
+ (value.startsWith('"') && value.endsWith('"')) ||
37
+ (value.startsWith("'") && value.endsWith("'"))
38
+ ) {
39
+ value = value.slice(1, -1)
40
+ }
41
+ if (key) data[key] = value
42
+ }
43
+ return { data, content: match[2] }
44
+ }
45
+
46
+ function walkSkillFiles(root: string): string[] {
47
+ const out: string[] = []
48
+ const stack = [root]
49
+ while (stack.length) {
50
+ const dir = stack.pop()!
51
+ let entries
52
+ try {
53
+ entries = readdirSync(dir, { withFileTypes: true })
54
+ } catch {
55
+ continue
56
+ }
57
+ for (const entry of entries) {
58
+ const full = path.join(dir, entry.name)
59
+ if (entry.isDirectory()) {
60
+ if (entry.name === "node_modules" || entry.name === ".git") continue
61
+ stack.push(full)
62
+ } else if (entry.isFile() && entry.name === "SKILL.md") {
63
+ out.push(full)
64
+ }
65
+ }
66
+ }
67
+ return out
68
+ }
69
+
70
+ function sampleSiblingFiles(dir: string, limit = 10): string[] {
71
+ const out: string[] = []
72
+ const stack = [dir]
73
+ while (stack.length && out.length < limit) {
74
+ const cur = stack.pop()!
75
+ let entries
76
+ try {
77
+ entries = readdirSync(cur, { withFileTypes: true })
78
+ } catch {
79
+ continue
80
+ }
81
+ for (const entry of entries) {
82
+ if (out.length >= limit) break
83
+ const full = path.join(cur, entry.name)
84
+ if (entry.isDirectory()) {
85
+ if (entry.name === "node_modules" || entry.name === ".git") continue
86
+ stack.push(full)
87
+ } else if (entry.isFile() && entry.name !== "SKILL.md") {
88
+ out.push(full)
89
+ }
90
+ }
91
+ }
92
+ return out
93
+ }
94
+
95
+ export interface SkillSource {
96
+ skills: Record<string, SkillInfo>
97
+ tool: Tool
98
+ /** Markdown catalog for the system prompt (mirrors opencode Skill.fmt). */
99
+ prompt(): string
100
+ }
101
+
102
+ /** Discover skills under one or more roots and build the `skill` loader tool. */
103
+ export function loadSkills(dirs: string | string[]): SkillSource {
104
+ const roots = Array.isArray(dirs) ? dirs : [dirs]
105
+ const skills: Record<string, SkillInfo> = {}
106
+
107
+ for (const root of roots) {
108
+ let isDir = false
109
+ try {
110
+ isDir = statSync(root).isDirectory()
111
+ } catch {
112
+ isDir = false
113
+ }
114
+ if (!isDir) {
115
+ console.warn(`[toolnexus] skills dir not found: ${root}`)
116
+ continue
117
+ }
118
+ for (const file of walkSkillFiles(root)) {
119
+ let text: string
120
+ try {
121
+ text = readFileSync(file, "utf8")
122
+ } catch {
123
+ continue
124
+ }
125
+ const { data, content } = parseFrontmatter(text)
126
+ if (!data.name) continue
127
+ if (skills[data.name]) {
128
+ console.warn(`[toolnexus] duplicate skill name "${data.name}" (${file}) — keeping first`)
129
+ continue
130
+ }
131
+ skills[data.name] = {
132
+ name: data.name,
133
+ description: data.description,
134
+ location: file,
135
+ content,
136
+ }
137
+ }
138
+ }
139
+
140
+ const tool: Tool = {
141
+ name: "skill",
142
+ description: SKILL_TOOL_DESCRIPTION,
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: { name: { type: "string", description: "The name of the skill to load" } },
146
+ required: ["name"],
147
+ additionalProperties: false,
148
+ },
149
+ source: "skill",
150
+ async execute(args: Record<string, unknown>): Promise<ToolResult> {
151
+ const name = String(args?.name ?? "")
152
+ const info = skills[name]
153
+ if (!info) {
154
+ return {
155
+ output: `Skill "${name}" not found. Available skills: ${Object.keys(skills).sort().join(", ") || "none"}`,
156
+ isError: true,
157
+ }
158
+ }
159
+ const dir = path.dirname(info.location)
160
+ const base = pathToFileURL(dir).href
161
+ const files = sampleSiblingFiles(dir)
162
+ const output = [
163
+ `<skill_content name="${info.name}">`,
164
+ `# Skill: ${info.name}`,
165
+ "",
166
+ info.content.trim(),
167
+ "",
168
+ `Base directory for this skill: ${base}`,
169
+ "Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
170
+ "Note: file list is sampled.",
171
+ "",
172
+ "<skill_files>",
173
+ files.map((f) => `<file>${f}</file>`).join("\n"),
174
+ "</skill_files>",
175
+ "</skill_content>",
176
+ ].join("\n")
177
+ return { output, isError: false, metadata: { name: info.name, dir } }
178
+ },
179
+ }
180
+
181
+ return {
182
+ skills,
183
+ tool,
184
+ prompt() {
185
+ const described = Object.values(skills)
186
+ .filter((s) => s.description !== undefined)
187
+ .sort((a, b) => a.name.localeCompare(b.name))
188
+ if (described.length === 0) return "No skills are currently available."
189
+ return [
190
+ "## Available Skills",
191
+ ...described.map((s) => `- **${s.name}**: ${s.description}`),
192
+ ].join("\n")
193
+ },
194
+ }
195
+ }
package/src/toolkit.ts ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Toolkit — aggregates dynamic MCP tools + the skill tool + custom tools into a
3
+ * single uniform list, with provider adapters and a single execute() router.
4
+ */
5
+ import { type Tool, type ToolContext, type ToolResult, type McpStatus } from "./types.js"
6
+ import { loadMcp, type McpSource } from "./mcp.js"
7
+ import { loadSkills, type SkillSource } from "./skill.js"
8
+ import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
9
+
10
+ export interface ToolkitOptions {
11
+ mcpConfig?: string | object
12
+ skillsDir?: string | string[]
13
+ extraTools?: Tool[]
14
+ }
15
+
16
+ export class Toolkit {
17
+ private byName = new Map<string, Tool>()
18
+ private constructor(
19
+ private readonly mcp: McpSource | undefined,
20
+ private readonly skill: SkillSource | undefined,
21
+ extraTools: Tool[],
22
+ ) {
23
+ const all = [...(mcp?.tools ?? []), ...(skill ? [skill.tool] : []), ...extraTools]
24
+ for (const t of all) {
25
+ if (this.byName.has(t.name)) {
26
+ console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
27
+ continue
28
+ }
29
+ this.byName.set(t.name, t)
30
+ }
31
+ }
32
+
33
+ static async create(opts: ToolkitOptions): Promise<Toolkit> {
34
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig) : undefined
35
+ const skill = opts.skillsDir !== undefined ? loadSkills(opts.skillsDir) : undefined
36
+ return new Toolkit(mcp, skill, opts.extraTools ?? [])
37
+ }
38
+
39
+ tools(): Tool[] {
40
+ return [...this.byName.values()]
41
+ }
42
+
43
+ get(name: string): Tool | undefined {
44
+ return this.byName.get(name)
45
+ }
46
+
47
+ /** Add a tool at runtime (native, http, or any custom Tool). First name wins. */
48
+ register(...tools: Tool[]): this {
49
+ for (const t of tools) {
50
+ if (this.byName.has(t.name)) {
51
+ console.warn(`[toolnexus] duplicate tool name "${t.name}" — keeping first`)
52
+ continue
53
+ }
54
+ this.byName.set(t.name, t)
55
+ }
56
+ return this
57
+ }
58
+
59
+ async execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
60
+ const tool = this.byName.get(name)
61
+ if (!tool) {
62
+ return { output: `Unknown tool: ${name}`, isError: true }
63
+ }
64
+ return tool.execute(args ?? {}, ctx)
65
+ }
66
+
67
+ /** Markdown skill catalog for the system prompt. */
68
+ skillsPrompt(): string {
69
+ return this.skill ? this.skill.prompt() : ""
70
+ }
71
+
72
+ mcpStatus(): Record<string, McpStatus> {
73
+ return this.mcp?.status ?? {}
74
+ }
75
+
76
+ toOpenAI() {
77
+ return toOpenAI(this.tools())
78
+ }
79
+ toAnthropic() {
80
+ return toAnthropic(this.tools())
81
+ }
82
+ toGemini() {
83
+ return toGemini(this.tools())
84
+ }
85
+
86
+ async close(): Promise<void> {
87
+ await this.mcp?.close()
88
+ }
89
+ }
90
+
91
+ export function createToolkit(opts: ToolkitOptions): Promise<Toolkit> {
92
+ return Toolkit.create(opts)
93
+ }
package/src/types.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Shared contract types. See ../../SPEC.md.
3
+ */
4
+
5
+ export type JSONSchema = {
6
+ type: "object"
7
+ properties?: Record<string, unknown>
8
+ required?: string[]
9
+ additionalProperties?: boolean
10
+ [k: string]: unknown
11
+ }
12
+
13
+ export type ToolSource = "mcp" | "skill" | "native" | "http" | "custom"
14
+
15
+ export interface ToolResult {
16
+ output: string
17
+ isError: boolean
18
+ metadata?: Record<string, unknown>
19
+ }
20
+
21
+ export interface ToolContext {
22
+ signal?: AbortSignal
23
+ timeout?: number
24
+ }
25
+
26
+ export interface Tool {
27
+ name: string
28
+ description: string
29
+ inputSchema: JSONSchema
30
+ source: ToolSource
31
+ execute(args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult>
32
+ }
33
+
34
+ export type McpStatus = "connected" | "disabled" | "failed"
35
+
36
+ /** Replace anything outside [a-zA-Z0-9_-] with "_" (same as opencode). */
37
+ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_")