| @@ -48,6 +48,34 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 47 unmodified lines |
| 48 | 48 | </div> |
| 49 | 49 | </div> |
| 50 | 50 | |
| 51 | + <!-- URL input bar --> |
| 52 | + <div class="rise-d4 mb-4" id="detect-bar"> |
| 53 | + <div class="relative"> |
| 54 | + <div class="flex items-center gap-3 bg-surface-raised border border-rule rounded-xl px-5 py-3.5 focus-within:border-teal/30 transition-colors"> |
| 55 | + <svg class="w-4 h-4 text-ink-faint shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.414-5.874a4.5 4.5 0 00-6.364 0l-4.5 4.5a4.5 4.5 0 001.242 7.244" /></svg> |
| 56 | + <input |
| 57 | + id="detect-input" |
| 58 | + type="url" |
| 59 | + placeholder="Paste any API URL to detect tools..." |
| 60 | + class="flex-1 bg-transparent text-[14px] text-ink placeholder:text-ink-faint outline-none font-mono" |
| 61 | + autocomplete="off" |
| 62 | + spellcheck="false" |
| 63 | + /> |
| 64 | + <button id="detect-btn" class="text-[12px] font-medium text-surface bg-teal hover:bg-teal-dim px-4 py-1.5 rounded-md transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"> |
| 65 | + Detect |
| 66 | + </button> |
| 67 | + </div> |
| 68 | + <div id="detect-status" class="hidden absolute left-0 right-0 -bottom-7 text-[11px] font-mono text-center"></div> |
| 69 | + <div id="detect-examples" class="mt-3 flex flex-wrap items-center gap-2 justify-center"> |
| 70 | + <span class="text-[11px] text-ink-faint">Try:</span> |
| 71 | + <button class="detect-example text-[11px] font-mono text-teal/60 hover:text-teal border border-rule hover:border-teal/20 rounded px-2 py-1 transition-colors" data-url="https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json">GitHub REST</button> |
| 72 | + <button class="detect-example text-[11px] font-mono text-teal/60 hover:text-teal border border-rule hover:border-teal/20 rounded px-2 py-1 transition-colors" data-url="https://api.linear.app/graphql">Linear GraphQL</button> |
| 73 | + <button class="detect-example text-[11px] font-mono text-teal/60 hover:text-teal border border-rule hover:border-teal/20 rounded px-2 py-1 transition-colors" data-url="https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest">Google Calendar</button> |
| 74 | + <button class="detect-example text-[11px] font-mono text-teal/60 hover:text-teal border border-rule hover:border-teal/20 rounded px-2 py-1 transition-colors" data-url="https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json">Stripe</button> |
| 75 | + </div> |
| 76 | + </div> |
| 77 | + </div> |
| 78 | + |
| 51 | 79 | <!-- Full-width interactive catalog UI --> |
| 52 | 80 | <div class="rise-d4 relative" id="hero-catalog"> |
| 53 | 81 | <div class="absolute -inset-px rounded-xl bg-gradient-to-b from-teal/10 via-transparent to-transparent pointer-events-none"></div> |
| @@ -123,19 +151,22 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 69 unmodified lines |
| 123 | 151 | </button> |
| 124 | 152 | </div> |
| 125 | 153 | |
| 126 | | − <!-- Main: tool list --> |
| 127 | | − <div class="flex-1 min-w-0"> |
| 154 | + <!-- Main: tool tree --> |
| 155 | + <div class="flex-1 min-w-0 flex flex-col"> |
| 128 | 156 | <!-- Search --> |
| 129 | | − <div class="px-4 py-3 border-b border-rule"> |
| 157 | + <div class="px-4 py-3 border-b border-rule shrink-0"> |
| 130 | 158 | <div class="flex items-center gap-2 px-3 py-2 rounded-md border border-rule bg-surface text-ink-faint text-[12px]"> |
| 131 | 159 | <svg class="w-3.5 h-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /></svg> |
| 132 | 160 | <span data-search-label>Search 347 tools...</span> |
| 133 | 161 | </div> |
| 134 | 162 | </div> |
| 135 | 163 | |
| 136 | | − <!-- Tool rows container --> |
| 137 | | − <div class="divide-y divide-rule" data-tool-list> |
| 138 | | − <!-- Populated by JS --> |
| 164 | + <!-- Scrollable tool tree with bottom fade --> |
| 165 | + <div class="relative flex-1 min-h-0"> |
| 166 | + <div class="overflow-y-auto h-[340px] scrollbar-thin" data-tool-list> |
| 167 | + <!-- Populated by JS --> |
| 168 | + </div> |
| 169 | + <div class="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-surface-raised to-transparent pointer-events-none transition-opacity duration-300" data-fade></div> |
| 139 | 170 | </div> |
| 140 | 171 | </div> |
| 141 | 172 | |
| @@ -149,60 +180,116 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 7 unmodified lines |
| 149 | 180 | name: 'GitHub', count: 347, tools: [ |
| 150 | 181 | { name: 'issues.list', desc: 'List repository issues', policy: 'read', method: 'GET' }, |
| 151 | 182 | { name: 'issues.create', desc: 'Create a new issue', policy: 'write', method: 'POST' }, |
| 183 | + { name: 'issues.get', desc: 'Get a single issue', policy: 'read', method: 'GET' }, |
| 184 | + { name: 'issues.update', desc: 'Update an issue', policy: 'write', method: 'PATCH' }, |
| 185 | + { name: 'issues.lock', desc: 'Lock an issue conversation', policy: 'write', method: 'PUT' }, |
| 186 | + { name: 'issues.comments.list', desc: 'List issue comments', policy: 'read', method: 'GET' }, |
| 187 | + { name: 'issues.comments.create', desc: 'Create an issue comment', policy: 'write', method: 'POST' }, |
| 188 | + { name: 'issues.labels.list', desc: 'List labels on an issue', policy: 'read', method: 'GET' }, |
| 189 | + { name: 'issues.labels.add', desc: 'Add labels to an issue', policy: 'write', method: 'POST' }, |
| 152 | 190 | { name: 'pulls.list', desc: 'List pull requests', policy: 'read', method: 'GET' }, |
| 191 | + { name: 'pulls.get', desc: 'Get a pull request', policy: 'read', method: 'GET' }, |
| 192 | + { name: 'pulls.create', desc: 'Create a pull request', policy: 'write', method: 'POST' }, |
| 153 | 193 | { name: 'pulls.merge', desc: 'Merge a pull request', policy: 'destructive', method: 'PUT' }, |
| 194 | + { name: 'pulls.reviews.list', desc: 'List reviews on a PR', policy: 'read', method: 'GET' }, |
| 195 | + { name: 'pulls.reviews.create', desc: 'Create a PR review', policy: 'write', method: 'POST' }, |
| 154 | 196 | { name: 'repos.get', desc: 'Get a repository', policy: 'read', method: 'GET' }, |
| 197 | + { name: 'repos.list', desc: 'List user repositories', policy: 'read', method: 'GET' }, |
| 198 | + { name: 'repos.create', desc: 'Create a repository', policy: 'write', method: 'POST' }, |
| 199 | + { name: 'repos.delete', desc: 'Delete a repository', policy: 'destructive', method: 'DELETE' }, |
| 155 | 200 | { name: 'actions.workflows.list', desc: 'List repository workflows', policy: 'read', method: 'GET' }, |
| 201 | + { name: 'actions.workflows.dispatch', desc: 'Trigger a workflow run', policy: 'write', method: 'POST' }, |
| 202 | + { name: 'actions.runs.list', desc: 'List workflow runs', policy: 'read', method: 'GET' }, |
| 203 | + { name: 'actions.runs.cancel', desc: 'Cancel a workflow run', policy: 'destructive', method: 'POST' }, |
| 156 | 204 | ] |
| 157 | 205 | }, |
| 158 | 206 | slack: { |
| 159 | 207 | name: 'Slack', count: 42, tools: [ |
| 160 | 208 | { name: 'chat.postMessage', desc: 'Send a message to a channel', policy: 'write', method: 'POST' }, |
| 209 | + { name: 'chat.update', desc: 'Update an existing message', policy: 'write', method: 'POST' }, |
| 210 | + { name: 'chat.delete', desc: 'Delete a message', policy: 'destructive', method: 'POST' }, |
| 161 | 211 | { name: 'conversations.list', desc: 'List all channels', policy: 'read', method: 'GET' }, |
| 162 | 212 | { name: 'conversations.history', desc: 'Fetch message history', policy: 'read', method: 'GET' }, |
| 213 | + { name: 'conversations.create', desc: 'Create a new channel', policy: 'write', method: 'POST' }, |
| 214 | + { name: 'conversations.archive', desc: 'Archive a channel', policy: 'destructive', method: 'POST' }, |
| 215 | + { name: 'conversations.members', desc: 'List channel members', policy: 'read', method: 'GET' }, |
| 163 | 216 | { name: 'users.list', desc: 'List all users in workspace', policy: 'read', method: 'GET' }, |
| 217 | + { name: 'users.info', desc: 'Get user profile info', policy: 'read', method: 'GET' }, |
| 164 | 218 | { name: 'reactions.add', desc: 'Add a reaction to a message', policy: 'write', method: 'POST' }, |
| 219 | + { name: 'reactions.remove', desc: 'Remove a reaction', policy: 'write', method: 'POST' }, |
| 165 | 220 | { name: 'files.upload', desc: 'Upload a file to a channel', policy: 'write', method: 'POST' }, |
| 221 | + { name: 'files.list', desc: 'List files in workspace', policy: 'read', method: 'GET' }, |
| 166 | 222 | ] |
| 167 | 223 | }, |
| 168 | 224 | calendar: { |
| 169 | 225 | name: 'Google Calendar', count: 18, tools: [ |
| 170 | 226 | { name: 'events.list', desc: 'List upcoming events', policy: 'read', method: 'GET' }, |
| 227 | + { name: 'events.get', desc: 'Get a single event', policy: 'read', method: 'GET' }, |
| 171 | 228 | { name: 'events.insert', desc: 'Create a new event', policy: 'write', method: 'POST' }, |
| 229 | + { name: 'events.update', desc: 'Update an existing event', policy: 'write', method: 'PATCH' }, |
| 172 | 230 | { name: 'events.delete', desc: 'Delete an event', policy: 'destructive', method: 'DELETE' }, |
| 231 | + { name: 'events.move', desc: 'Move event to another calendar', policy: 'write', method: 'POST' }, |
| 173 | 232 | { name: 'calendars.get', desc: 'Get calendar metadata', policy: 'read', method: 'GET' }, |
| 174 | | − { name: 'events.update', desc: 'Update an existing event', policy: 'write', method: 'PATCH' }, |
| 233 | + { name: 'calendars.insert', desc: 'Create a secondary calendar', policy: 'write', method: 'POST' }, |
| 234 | + { name: 'calendars.delete', desc: 'Delete a calendar', policy: 'destructive', method: 'DELETE' }, |
| 235 | + { name: 'calendarList.list', desc: 'List calendars on user list', policy: 'read', method: 'GET' }, |
| 175 | 236 | { name: 'freebusy.query', desc: 'Check availability', policy: 'read', method: 'POST' }, |
| 237 | + { name: 'acl.list', desc: 'List access control rules', policy: 'read', method: 'GET' }, |
| 238 | + { name: 'acl.insert', desc: 'Add an access control rule', policy: 'write', method: 'POST' }, |
| 176 | 239 | ] |
| 177 | 240 | }, |
| 178 | 241 | linear: { |
| 179 | 242 | name: 'Linear', count: 56, tools: [ |
| 180 | 243 | { name: 'issues.list', desc: 'List issues with filters', policy: 'read', method: 'query' }, |
| 244 | + { name: 'issues.get', desc: 'Get a single issue', policy: 'read', method: 'query' }, |
| 181 | 245 | { name: 'issues.create', desc: 'Create a new issue', policy: 'write', method: 'mutation' }, |
| 182 | 246 | { name: 'issues.update', desc: 'Update issue status or fields', policy: 'write', method: 'mutation' }, |
| 247 | + { name: 'issues.delete', desc: 'Delete an issue permanently', policy: 'destructive', method: 'mutation' }, |
| 248 | + { name: 'issues.archive', desc: 'Archive an issue', policy: 'write', method: 'mutation' }, |
| 183 | 249 | { name: 'projects.list', desc: 'List all projects', policy: 'read', method: 'query' }, |
| 250 | + { name: 'projects.create', desc: 'Create a new project', policy: 'write', method: 'mutation' }, |
| 251 | + { name: 'projects.update', desc: 'Update project details', policy: 'write', method: 'mutation' }, |
| 252 | + { name: 'comments.list', desc: 'List comments on an issue', policy: 'read', method: 'query' }, |
| 184 | 253 | { name: 'comments.create', desc: 'Add a comment to an issue', policy: 'write', method: 'mutation' }, |
| 185 | | − { name: 'issues.delete', desc: 'Delete an issue permanently', policy: 'destructive', method: 'mutation' }, |
| 254 | + { name: 'cycles.list', desc: 'List all cycles', policy: 'read', method: 'query' }, |
| 255 | + { name: 'teams.list', desc: 'List all teams', policy: 'read', method: 'query' }, |
| 256 | + { name: 'labels.list', desc: 'List all labels', policy: 'read', method: 'query' }, |
| 186 | 257 | ] |
| 187 | 258 | }, |
| 188 | 259 | resend: { |
| 189 | 260 | name: 'Resend', count: 19, tools: [ |
| 190 | 261 | { name: 'emails.send', desc: 'Send a transactional email', policy: 'write', method: 'POST' }, |
| 191 | 262 | { name: 'emails.get', desc: 'Get email delivery status', policy: 'read', method: 'GET' }, |
| 263 | + { name: 'emails.batch', desc: 'Send a batch of emails', policy: 'write', method: 'POST' }, |
| 192 | 264 | { name: 'domains.list', desc: 'List verified domains', policy: 'read', method: 'GET' }, |
| 265 | + { name: 'domains.get', desc: 'Get domain details', policy: 'read', method: 'GET' }, |
| 266 | + { name: 'domains.create', desc: 'Add a new domain', policy: 'write', method: 'POST' }, |
| 193 | 267 | { name: 'domains.verify', desc: 'Verify a domain', policy: 'write', method: 'POST' }, |
| 268 | + { name: 'domains.delete', desc: 'Remove a domain', policy: 'destructive', method: 'DELETE' }, |
| 194 | 269 | { name: 'apiKeys.list', desc: 'List API keys', policy: 'read', method: 'GET' }, |
| 270 | + { name: 'apiKeys.create', desc: 'Create an API key', policy: 'write', method: 'POST' }, |
| 271 | + { name: 'contacts.list', desc: 'List contacts in audience', policy: 'read', method: 'GET' }, |
| 195 | 272 | { name: 'contacts.create', desc: 'Create a new contact', policy: 'write', method: 'POST' }, |
| 273 | + { name: 'contacts.remove', desc: 'Remove a contact', policy: 'destructive', method: 'DELETE' }, |
| 196 | 274 | ] |
| 197 | 275 | }, |
| 198 | 276 | axiom: { |
| 199 | 277 | name: 'Axiom', count: 17, tools: [ |
| 200 | | − { name: 'queryDataset', desc: 'Query datasets using APL', policy: 'read', method: 'tool' }, |
| 201 | | − { name: 'listDatasets', desc: 'List all available datasets', policy: 'read', method: 'tool' }, |
| 202 | | − { name: 'getDatasetFields', desc: 'List all fields in a dataset', policy: 'read', method: 'tool' }, |
| 203 | | − { name: 'checkMonitors', desc: 'Check all monitors and their statuses', policy: 'read', method: 'tool' }, |
| 204 | | − { name: 'listDashboards', desc: 'List all available dashboards', policy: 'read', method: 'tool' }, |
| 205 | | − { name: 'deleteDashboard', desc: 'Delete a dashboard by UID', policy: 'destructive', method: 'tool' }, |
| 278 | + { name: 'datasets.query', desc: 'Query datasets using APL', policy: 'read', method: 'tool' }, |
| 279 | + { name: 'datasets.list', desc: 'List all available datasets', policy: 'read', method: 'tool' }, |
| 280 | + { name: 'datasets.fields', desc: 'List all fields in a dataset', policy: 'read', method: 'tool' }, |
| 281 | + { name: 'datasets.info', desc: 'Get dataset metadata and stats', policy: 'read', method: 'tool' }, |
| 282 | + { name: 'datasets.create', desc: 'Create a new dataset', policy: 'write', method: 'tool' }, |
| 283 | + { name: 'datasets.trim', desc: 'Trim data from a dataset', policy: 'destructive', method: 'tool' }, |
| 284 | + { name: 'monitors.list', desc: 'List all monitors', policy: 'read', method: 'tool' }, |
| 285 | + { name: 'monitors.check', desc: 'Check monitor statuses', policy: 'read', method: 'tool' }, |
| 286 | + { name: 'monitors.create', desc: 'Create a new monitor', policy: 'write', method: 'tool' }, |
| 287 | + { name: 'dashboards.list', desc: 'List all dashboards', policy: 'read', method: 'tool' }, |
| 288 | + { name: 'dashboards.get', desc: 'Get dashboard by ID', policy: 'read', method: 'tool' }, |
| 289 | + { name: 'dashboards.create', desc: 'Create a new dashboard', policy: 'write', method: 'tool' }, |
| 290 | + { name: 'dashboards.delete', desc: 'Delete a dashboard by UID', policy: 'destructive', method: 'tool' }, |
| 291 | + { name: 'annotations.list', desc: 'List all annotations', policy: 'read', method: 'tool' }, |
| 292 | + { name: 'annotations.create', desc: 'Create an annotation', policy: 'write', method: 'tool' }, |
| 206 | 293 | ] |
| 207 | 294 | }, |
| 208 | 295 | }; |
| @@ -229,11 +316,17 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 20 unmodified lines |
| 229 | 316 | |
| 230 | 317 | const catalog = document.getElementById('hero-catalog'); |
| 231 | 318 | const toolList = catalog.querySelector('[data-tool-list]'); |
| 319 | + const fadeEl = catalog.querySelector('[data-fade]'); |
| 232 | 320 | const breadcrumb = catalog.querySelector('[data-breadcrumb]'); |
| 233 | 321 | const totalTools = catalog.querySelector('[data-total-tools]'); |
| 234 | 322 | const searchLabel = catalog.querySelector('[data-search-label]'); |
| 235 | | − const buttons = catalog.querySelectorAll('.catalog-source'); |
| 236 | 323 | |
| 324 | + function updateFade() { |
| 325 | + const atBottom = toolList.scrollHeight - toolList.scrollTop - toolList.clientHeight < 8; |
| 326 | + const noOverflow = toolList.scrollHeight <= toolList.clientHeight; |
| 327 | + fadeEl.style.opacity = (atBottom || noOverflow) ? '0' : '1'; |
| 328 | + } |
| 329 | + toolList.addEventListener('scroll', updateFade); |
| 237 | 330 | function renderSource(key) { |
| 238 | 331 | const src = sources[key]; |
| 239 | 332 | breadcrumb.textContent = src.name; |
| @@ -242,7 +335,7 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 2 unmodified lines |
| 242 | 335 | const totalCount = Object.values(sources).reduce((s, v) => s + v.count, 0); |
| 243 | 336 | totalTools.textContent = `${totalCount} tools indexed`; |
| 244 | 337 | |
| 245 | | − buttons.forEach(btn => { |
| 338 | + catalog.querySelectorAll('.catalog-source').forEach(btn => { |
| 246 | 339 | const isActive = btn.dataset.source === key; |
| 247 | 340 | if (isActive) { |
| 248 | 341 | btn.className = btn.className |
| @@ -257,26 +350,173 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 8 unmodified lines |
| 257 | 350 | } |
| 258 | 351 | }); |
| 259 | 352 | |
| 260 | | − toolList.innerHTML = src.tools.map((tool, i) => { |
| 261 | | − const isLast = i === src.tools.length - 1; |
| 262 | | − const colors = policyColors[tool.policy]; |
| 263 | | − const mColor = methodColors[tool.method] || 'text-ink-faint'; |
| 264 | | − return `<div class="flex items-center gap-4 px-4 py-3${isLast ? ' opacity-40' : ''}"> |
| 265 | | − <div class="flex-1 min-w-0"> |
| 266 | | − <div class="font-mono text-[12px] text-ink">${tool.name}</div> |
| 267 | | − <div class="text-[11px] text-ink-faint">${tool.desc}</div> |
| 268 | | − </div> |
| 269 | | − <span class="text-[10px] font-mono ${colors} rounded px-1.5 py-0.5 shrink-0">${tool.policy}</span> |
| 270 | | − <span class="text-[10px] font-mono ${mColor} shrink-0">${tool.method}</span> |
| 271 | | − </div>`; |
| 272 | | − }).join(''); |
| 353 | + // Build tree from dot-separated tool names |
| 354 | + const tree = {}; |
| 355 | + for (const tool of src.tools) { |
| 356 | + const parts = tool.name.split('.'); |
| 357 | + let node = tree; |
| 358 | + for (let i = 0; i < parts.length - 1; i++) { |
| 359 | + if (!node[parts[i]]) node[parts[i]] = { _children: {} }; |
| 360 | + node = node[parts[i]]._children; |
| 361 | + } |
| 362 | + const leaf = parts[parts.length - 1]; |
| 363 | + node[leaf] = { _tool: tool }; |
| 364 | + } |
| 365 | + |
| 366 | + function renderTree(node, depth = 0) { |
| 367 | + let html = ''; |
| 368 | + const keys = Object.keys(node); |
| 369 | + keys.forEach((key, idx) => { |
| 370 | + const entry = node[key]; |
| 371 | + const isLast = idx === keys.length - 1; |
| 372 | + const indent = depth * 20; |
| 373 | + const connector = isLast ? '└' : '├'; |
| 374 | + |
| 375 | + if (entry._tool) { |
| 376 | + // Leaf: actual tool |
| 377 | + const tool = entry._tool; |
| 378 | + const colors = policyColors[tool.policy]; |
| 379 | + const mColor = methodColors[tool.method] || 'text-ink-faint'; |
| 380 | + html += `<div class="flex items-center gap-3 py-1.5 pr-4 hover:bg-surface-overlay/50 transition-colors" style="padding-left:${16 + indent}px"> |
| 381 | + <span class="text-ink-faint font-mono text-[11px] w-3 shrink-0 select-none">${depth > 0 ? connector : '·'}</span> |
| 382 | + <span class="font-mono text-[12px] text-ink flex-1 min-w-0 truncate">${key}</span> |
| 383 | + <span class="text-[10px] font-mono ${colors} rounded px-1.5 py-0.5 shrink-0">${tool.policy}</span> |
| 384 | + <span class="text-[10px] font-mono ${mColor} shrink-0 w-14 text-right">${tool.method}</span> |
| 385 | + </div>`; |
| 386 | + } else { |
| 387 | + // Branch: namespace group |
| 388 | + const childCount = Object.keys(entry._children).length; |
| 389 | + html += `<div class="flex items-center gap-3 py-1.5 pr-4" style="padding-left:${16 + indent}px"> |
| 390 | + <span class="text-ink-faint font-mono text-[11px] w-3 shrink-0 select-none">${depth > 0 ? connector : '▸'}</span> |
| 391 | + <span class="font-mono text-[12px] text-ink-muted">${key}</span> |
| 392 | + <span class="text-[10px] text-ink-faint">${childCount}</span> |
| 393 | + </div>`; |
| 394 | + html += renderTree(entry._children, depth + 1); |
| 395 | + } |
| 396 | + }); |
| 397 | + return html; |
| 398 | + } |
| 399 | + |
| 400 | + toolList.innerHTML = renderTree(tree); |
| 401 | + toolList.scrollTop = 0; |
| 402 | + updateFade(); |
| 273 | 403 | } |
| 274 | 404 | |
| 275 | | − buttons.forEach(btn => { |
| 405 | + catalog.querySelectorAll('.catalog-source').forEach(btn => { |
| 276 | 406 | btn.addEventListener('click', () => renderSource(btn.dataset.source)); |
| 277 | 407 | }); |
| 278 | 408 | |
| 279 | 409 | renderSource('github'); |
| 410 | + |
| 411 | + // ── Live detection ── |
| 412 | + const detectInput = document.getElementById('detect-input'); |
| 413 | + const detectBtn = document.getElementById('detect-btn'); |
| 414 | + const detectStatus = document.getElementById('detect-status'); |
| 415 | + const detectExamples = document.getElementById('detect-examples'); |
| 416 | + |
| 417 | + const kindColors = { |
| 418 | + openapi: { bg: 'bg-emerald-400/15', text: 'text-emerald-400', letter: 'O' }, |
| 419 | + graphql: { bg: 'bg-pink-400/15', text: 'text-pink-400', letter: 'Q' }, |
| 420 | + mcp: { bg: 'bg-cyan-400/15', text: 'text-cyan-400', letter: 'M' }, |
| 421 | + googleDiscovery: { bg: 'bg-yellow-400/15', text: 'text-yellow-400', letter: 'G' }, |
| 422 | + }; |
| 423 | + |
| 424 | + // Tailwind safelist for dynamic classes: |
| 425 | + // bg-emerald-400/15 text-emerald-400 bg-pink-400/15 text-pink-400 bg-yellow-400/15 text-yellow-400 |
| 426 | + |
| 427 | + const kindLabels = { openapi: 'openapi', graphql: 'graphql', mcp: 'mcp', googleDiscovery: 'google' }; |
| 428 | + |
| 429 | + let detectedCount = 0; |
| 430 | + |
| 431 | + async function detectUrl(url) { |
| 432 | + detectBtn.disabled = true; |
| 433 | + detectBtn.textContent = 'Detecting...'; |
| 434 | + detectStatus.className = 'absolute left-0 right-0 -bottom-7 text-[11px] font-mono text-center text-ink-muted'; |
| 435 | + detectStatus.textContent = 'Probing URL...'; |
| 436 | + |
| 437 | + try { |
| 438 | + const res = await fetch('/api/detect', { |
| 439 | + method: 'POST', |
| 440 | + headers: { 'Content-Type': 'application/json' }, |
| 441 | + body: JSON.stringify({ url }), |
| 442 | + }); |
| 443 | + const data = await res.json(); |
| 444 | + |
| 445 | + if (!res.ok) { |
| 446 | + detectStatus.className = 'absolute left-0 right-0 -bottom-7 text-[11px] font-mono text-center text-red-400/70'; |
| 447 | + detectStatus.textContent = data.error || 'Detection failed'; |
| 448 | + return; |
| 449 | + } |
| 450 | + |
| 451 | + // Add detected source to the catalog |
| 452 | + detectedCount++; |
| 453 | + const key = `detected_${detectedCount}`; |
| 454 | + const colors = kindColors[data.kind] || kindColors.openapi; |
| 455 | + const kindLabel = kindLabels[data.kind] || data.kind; |
| 456 | + |
| 457 | + sources[key] = { |
| 458 | + name: data.name, |
| 459 | + count: data.count, |
| 460 | + tools: data.tools.map(t => ({ |
| 461 | + name: t.name, |
| 462 | + desc: t.desc, |
| 463 | + policy: t.policy, |
| 464 | + method: t.method, |
| 465 | + })), |
| 466 | + }; |
| 467 | + |
| 468 | + // Add sidebar button |
| 469 | + const sidebar = catalog.querySelector('.space-y-1'); |
| 470 | + const btn = document.createElement('button'); |
| 471 | + btn.dataset.source = key; |
| 472 | + btn.className = 'catalog-source w-full flex items-center gap-2.5 px-2 py-2 rounded-md text-left transition-colors border border-transparent hover:bg-surface-overlay'; |
| 473 | + btn.innerHTML = ` |
| 474 | + <div class="w-5 h-5 rounded ${colors.bg} flex items-center justify-center text-[9px] ${colors.text} font-mono font-medium shrink-0">${data.name.charAt(0).toUpperCase()}</div> |
| 475 | + <div class="flex-1 min-w-0"> |
| 476 | + <div class="text-ink-muted text-[12px] truncate">${data.name}</div> |
| 477 | + <div class="text-[10px] text-ink-faint">${data.count} tools</div> |
| 478 | + </div> |
| 479 | + <span class="text-[9px] font-mono text-ink-faint shrink-0">${kindLabel}</span> |
| 480 | + `; |
| 481 | + btn.addEventListener('click', () => renderSource(key)); |
| 482 | + sidebar.appendChild(btn); |
| 483 | + |
| 484 | + renderSource(key); |
| 485 | + |
| 486 | + detectStatus.className = 'absolute left-0 right-0 -bottom-7 text-[11px] font-mono text-center text-teal'; |
| 487 | + detectStatus.textContent = `${data.kind} detected — ${data.count} tools found`; |
| 488 | + detectExamples.classList.add('hidden'); |
| 489 | + |
| 490 | + // Clear input |
| 491 | + detectInput.value = ''; |
| 492 | + } catch (err) { |
| 493 | + detectStatus.className = 'absolute left-0 right-0 -bottom-7 text-[11px] font-mono text-center text-red-400/70'; |
| 494 | + detectStatus.textContent = 'Network error — could not reach detection API'; |
| 495 | + } finally { |
| 496 | + detectBtn.disabled = false; |
| 497 | + detectBtn.textContent = 'Detect'; |
| 498 | + } |
| 499 | + } |
| 500 | + |
| 501 | + detectBtn.addEventListener('click', () => { |
| 502 | + const url = detectInput.value.trim(); |
| 503 | + if (url) detectUrl(url); |
| 504 | + }); |
| 505 | + |
| 506 | + detectInput.addEventListener('keydown', (e) => { |
| 507 | + if (e.key === 'Enter') { |
| 508 | + const url = detectInput.value.trim(); |
| 509 | + if (url) detectUrl(url); |
| 510 | + } |
| 511 | + }); |
| 512 | + |
| 513 | + // Example buttons |
| 514 | + document.querySelectorAll('.detect-example').forEach(btn => { |
| 515 | + btn.addEventListener('click', () => { |
| 516 | + detectInput.value = btn.dataset.url; |
| 517 | + detectUrl(btn.dataset.url); |
| 518 | + }); |
| 519 | + }); |
| 280 | 520 | </script> |
| 281 | 521 | </div> |
| 282 | 522 | |