-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Expand file tree
/
Copy pathsync-tool-catalog.ts
More file actions
234 lines (205 loc) · 7.57 KB
/
sync-tool-catalog.ts
File metadata and controls
234 lines (205 loc) · 7.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { formatGeneratedSource } from './format-generated-source'
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SCRIPT_DIR, '..')
const DEFAULT_CATALOG_PATH = resolve(ROOT, '../copilot/copilot/contracts/tool-catalog-v1.json')
const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/tool-catalog-v1.ts')
const RUNTIME_SCHEMA_OUTPUT_PATH = resolve(
ROOT,
'apps/sim/lib/copilot/generated/tool-schemas-v1.ts'
)
function snakeToPascal(s: string): string {
return s
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join('')
}
function toCamelIdentifier(value: string): string {
const parts = value.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length === 0) return 'value'
const camel = parts
.map((part, index) => {
const lower = part.toLowerCase()
if (index === 0) return lower
return lower.charAt(0).toUpperCase() + lower.slice(1)
})
.join('')
return /^[0-9]/.test(camel) ? `v${camel}` : camel
}
function getTopLevelOperationEnum(tool: Record<string, unknown>): string[] | undefined {
const parameters =
typeof tool.parameters === 'object' && tool.parameters !== null
? (tool.parameters as Record<string, unknown>)
: null
const properties =
parameters && typeof parameters.properties === 'object' && parameters.properties !== null
? (parameters.properties as Record<string, unknown>)
: null
const operation =
properties && typeof properties.operation === 'object' && properties.operation !== null
? (properties.operation as Record<string, unknown>)
: null
const values = operation?.enum
if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) {
return undefined
}
return values as string[]
}
function inferTSType(values: unknown[]): string {
const unique = [...new Set(values.filter((v) => v !== undefined && v !== null))]
if (unique.length === 0) return 'string'
if (unique.every((v) => typeof v === 'string')) {
return unique
.map((v) => JSON.stringify(v))
.sort()
.join(' | ')
}
if (unique.every((v) => typeof v === 'boolean')) return 'boolean'
if (unique.every((v) => typeof v === 'number')) return 'number'
return 'unknown'
}
function renderRuntimeSchemaModule(catalog: { tools: Record<string, unknown>[] }): string {
const lines: string[] = [
'// AUTO-GENERATED FILE. DO NOT EDIT.',
'// Generated from copilot/contracts/tool-catalog-v1.json',
'//',
'',
'export type JsonSchema = unknown',
'',
'export interface ToolRuntimeSchemaEntry {',
' parameters?: JsonSchema;',
' resultSchema?: JsonSchema;',
'}',
'',
'export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {',
]
for (const tool of catalog.tools) {
const id = JSON.stringify(tool.id)
const parameters =
'parameters' in tool ? JSON.stringify(tool.parameters ?? null, null, 2) : 'undefined'
const resultSchema =
'resultSchema' in tool ? JSON.stringify(tool.resultSchema ?? null, null, 2) : 'undefined'
lines.push(` [${id}]: {`)
lines.push(
` parameters: ${parameters === 'null' ? 'undefined' : parameters.replace(/\n/g, '\n ')},`
)
lines.push(
` resultSchema: ${resultSchema === 'null' ? 'undefined' : resultSchema.replace(/\n/g, '\n ')},`
)
lines.push(' },')
}
lines.push('}')
lines.push('')
return lines.join('\n')
}
function generateInterface(tools: Record<string, unknown>[]): string {
if (tools.length === 0) return 'export interface ToolCatalogEntry {}\n'
const allKeys = new Set<string>()
for (const tool of tools) {
for (const key of Object.keys(tool)) {
allKeys.add(key)
}
}
const requiredKeys = new Set<string>()
for (const key of allKeys) {
if (tools.every((t) => key in t)) {
requiredKeys.add(key)
}
}
const lines: string[] = ['export interface ToolCatalogEntry {']
for (const key of [...allKeys].sort()) {
const values = tools.map((t) => t[key])
const tsType = inferTSType(values)
const optional = requiredKeys.has(key) ? '' : '?'
lines.push(` ${key}${optional}: ${tsType};`)
}
lines.push('}')
return lines.join('\n')
}
async function main() {
const checkOnly = process.argv.includes('--check')
const inputPathArg = process.argv.find((arg) => arg.startsWith('--input='))
const inputPath = inputPathArg
? resolve(ROOT, inputPathArg.slice('--input='.length))
: DEFAULT_CATALOG_PATH
const raw = await readFile(inputPath, 'utf8')
const catalog = JSON.parse(raw) as { version: string; tools: Record<string, unknown>[] }
const iface = generateInterface(catalog.tools)
const lines: string[] = [
'// AUTO-GENERATED FILE. DO NOT EDIT.',
'// Generated from copilot/contracts/tool-catalog-v1.json',
'//',
'',
iface,
'',
]
const constNames: string[] = []
for (const tool of catalog.tools) {
const constName = snakeToPascal(tool.id as string)
constNames.push(constName)
const fields: string[] = []
for (const [key, value] of Object.entries(tool)) {
fields.push(` ${key}: ${JSON.stringify(value)}`)
}
lines.push(`export const ${constName}: ToolCatalogEntry = {`)
lines.push(`${fields.join(',\n')},`)
lines.push('};')
lines.push('')
}
for (const tool of catalog.tools) {
const constName = snakeToPascal(tool.id as string)
const operationEnum = getTopLevelOperationEnum(tool)
if (!operationEnum || operationEnum.length === 0) continue
const operationConstName = `${constName}Operation`
const seenKeys = new Set<string>()
const members = operationEnum.map((value, index) => {
let key = toCamelIdentifier(value)
if (seenKeys.has(key)) key = `${key}${index + 1}`
seenKeys.add(key)
return { key, value }
})
lines.push(`export const ${operationConstName} = {`)
for (const member of members) {
lines.push(` ${member.key}: ${JSON.stringify(member.value)},`)
}
lines.push('} as const;')
lines.push('')
lines.push(
`export type ${operationConstName} = (typeof ${operationConstName})[keyof typeof ${operationConstName}];`
)
lines.push('')
lines.push(`export const ${operationConstName}Values = [`)
for (const member of members) {
lines.push(` ${operationConstName}.${member.key},`)
}
lines.push(`] as const;`)
lines.push('')
}
lines.push(`export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {`)
for (let i = 0; i < catalog.tools.length; i++) {
lines.push(` [${constNames[i]}.id]: ${constNames[i]},`)
}
lines.push('};')
lines.push('')
const rendered = formatGeneratedSource(lines.join('\n'), OUTPUT_PATH, ROOT)
const runtimeSchemaRendered = formatGeneratedSource(
renderRuntimeSchemaModule(catalog),
RUNTIME_SCHEMA_OUTPUT_PATH,
ROOT
)
if (checkOnly) {
const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null)
const existingRuntime = await readFile(RUNTIME_SCHEMA_OUTPUT_PATH, 'utf8').catch(() => null)
if (existing !== rendered || existingRuntime !== runtimeSchemaRendered) {
throw new Error(`Generated tool catalog is stale. Run: bun run mship-tools:generate`)
}
return
}
await mkdir(dirname(OUTPUT_PATH), { recursive: true })
await writeFile(OUTPUT_PATH, rendered, 'utf8')
await mkdir(dirname(RUNTIME_SCHEMA_OUTPUT_PATH), { recursive: true })
await writeFile(RUNTIME_SCHEMA_OUTPUT_PATH, runtimeSchemaRendered, 'utf8')
}
await main()