History

add sources to homepage

Rhys Sullivan committed 7 months ago

Commit becd9101 parent bc3e83a6 files · +559 −31Browse files

Unified · Split

6 files
  1. apps/marketing/astro.config.mjs +7 −1
  2. apps/marketing/package.json +1 −0
  3. apps/marketing/src/pages/api/detect.ts +260 −0
  4. apps/marketing/src/pages/index.astro +270 −30
  5. apps/marketing/src/styles/global.css +16 −0
  6. bun.lock +5 −0
apps/marketing/astro.config.mjs+7−1
@@ -3,9 +3,15 @@ import { defineConfig } from 'astro/config'; · 2 unmodified lines
33
44 import tailwindcss from '@tailwindcss/vite';
55
6+import node from '@astrojs/node';
7+
68 // https://astro.build/config
79 export default defineConfig({
810 vite: {
911 plugins: [tailwindcss()]
10− }
12+ },
13+
14+ adapter: node({
15+ mode: 'standalone'
16+ })
1117 });
apps/marketing/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "astro": "astro"
1313 },
1414 "dependencies": {
15+ "@astrojs/node": "^10.0.4",
1516 "@astrojs/tailwind": "^6.0.2",
1617 "@tailwindcss/vite": "^4.2.2",
1718 "astro": "^6.1.3",
apps/marketing/src/pages/api/detect.ts+260−0
@@ -0,0 +1,260 @@
1+import type { APIRoute } from 'astro'
2+
3+export const prerender = false
4+
5+interface DetectedTool {
6+ name: string
7+ desc: string
8+ method: string
9+ policy: 'read' | 'write' | 'destructive'
10+}
11+
12+interface DetectionResult {
13+ kind: 'openapi' | 'graphql' | 'mcp' | 'googleDiscovery'
14+ name: string
15+ count: number
16+ tools: DetectedTool[]
17+}
18+
19+function policyFromMethod(method: string): 'read' | 'write' | 'destructive' {
20+ const m = method.toUpperCase()
21+ if (m === 'GET' || m === 'HEAD' || m === 'OPTIONS' || m === 'QUERY') return 'read'
22+ if (m === 'DELETE') return 'destructive'
23+ return 'write'
24+}
25+
26+async function tryOpenAPI(url: string, signal: AbortSignal): Promise<DetectionResult | null> {
27+ try {
28+ const res = await fetch(url, { signal, headers: { Accept: 'application/json, application/yaml, text/yaml, */*' } })
29+ if (!res.ok) return null
30+ const text = await res.text()
31+ let doc: any
32+ try {
33+ doc = JSON.parse(text)
34+ } catch {
35+ // Could be YAML - check for openapi/swagger string markers
36+ if (text.includes('openapi:') || text.includes('swagger:')) {
37+ // Basic YAML detection - we can't fully parse YAML without a dep,
38+ // but we can confirm it's an OpenAPI spec
39+ return {
40+ kind: 'openapi',
41+ name: 'API',
42+ count: 0,
43+ tools: [{ name: 'spec.detected', desc: 'OpenAPI spec detected (YAML)', method: 'GET', policy: 'read' }],
44+ }
45+ }
46+ return null
47+ }
48+
49+ // Check for OpenAPI 3.x or Swagger 2.x
50+ if (!doc.openapi && !doc.swagger) return null
51+
52+ const title = doc.info?.title || 'API'
53+ const tools: DetectedTool[] = []
54+
55+ if (doc.paths) {
56+ for (const [path, methods] of Object.entries(doc.paths as Record<string, any>)) {
57+ for (const method of ['get', 'post', 'put', 'delete', 'patch', 'head', 'options']) {
58+ const op = methods[method]
59+ if (!op) continue
60+ const opId = op.operationId || `${method}.${path.replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')}`
61+ tools.push({
62+ name: opId,
63+ desc: op.summary || op.description?.slice(0, 80) || `${method.toUpperCase()} ${path}`,
64+ method: method.toUpperCase(),
65+ policy: policyFromMethod(method),
66+ })
67+ }
68+ }
69+ }
70+
71+ return {
72+ kind: 'openapi',
73+ name: title,
74+ count: tools.length,
75+ tools: tools.slice(0, 12),
76+ }
77+ } catch {
78+ return null
79+ }
80+}
81+
82+async function tryGraphQL(url: string, signal: AbortSignal): Promise<DetectionResult | null> {
83+ try {
84+ const introspectionQuery = `{
85+ __schema {
86+ queryType { name }
87+ mutationType { name }
88+ types {
89+ kind name
90+ fields(includeDeprecated: false) { name description }
91+ }
92+ }
93+ }`
94+
95+ const res = await fetch(url, {
96+ method: 'POST',
97+ signal,
98+ headers: { 'Content-Type': 'application/json' },
99+ body: JSON.stringify({ query: introspectionQuery }),
100+ })
101+ if (!res.ok) return null
102+ const json = await res.json() as any
103+
104+ const schema = json.data?.__schema || json.__schema
105+ if (!schema) return null
106+
107+ const tools: DetectedTool[] = []
108+ const queryTypeName = schema.queryType?.name || 'Query'
109+ const mutationTypeName = schema.mutationType?.name || 'Mutation'
110+
111+ for (const type of schema.types || []) {
112+ if (type.name === queryTypeName && type.fields) {
113+ for (const field of type.fields) {
114+ if (field.name.startsWith('__')) continue
115+ tools.push({
116+ name: field.name,
117+ desc: field.description?.slice(0, 80) || `Query ${field.name}`,
118+ method: 'query',
119+ policy: 'read',
120+ })
121+ }
122+ }
123+ if (type.name === mutationTypeName && type.fields) {
124+ for (const field of type.fields) {
125+ if (field.name.startsWith('__')) continue
126+ const isDestructive = /delete|remove|destroy/i.test(field.name)
127+ tools.push({
128+ name: field.name,
129+ desc: field.description?.slice(0, 80) || `Mutation ${field.name}`,
130+ method: 'mutation',
131+ policy: isDestructive ? 'destructive' : 'write',
132+ })
133+ }
134+ }
135+ }
136+
137+ // Derive name from URL hostname
138+ const hostname = new URL(url).hostname.replace('api.', '').split('.')[0]
139+ const name = hostname.charAt(0).toUpperCase() + hostname.slice(1)
140+
141+ return {
142+ kind: 'graphql',
143+ name: name || 'GraphQL API',
144+ count: tools.length,
145+ tools: tools.slice(0, 12),
146+ }
147+ } catch {
148+ return null
149+ }
150+}
151+
152+async function tryGoogleDiscovery(url: string, signal: AbortSignal): Promise<DetectionResult | null> {
153+ try {
154+ // Only probe URLs that look like Google Discovery docs
155+ if (!url.includes('googleapis.com') && !url.includes('/discovery/')) return null
156+
157+ const res = await fetch(url, { signal })
158+ if (!res.ok) return null
159+ const doc = await res.json() as any
160+
161+ if (!doc.discoveryVersion && !doc.kind?.includes('discovery')) return null
162+
163+ const title = doc.title || doc.name || 'Google API'
164+ const tools: DetectedTool[] = []
165+
166+ function extractMethods(resources: any, prefix = '') {
167+ if (!resources) return
168+ for (const [resourceName, resource] of Object.entries(resources as Record<string, any>)) {
169+ if (resource.methods) {
170+ for (const [methodName, method] of Object.entries(resource.methods as Record<string, any>)) {
171+ const httpMethod = (method.httpMethod || 'GET').toUpperCase()
172+ tools.push({
173+ name: `${prefix}${resourceName}.${methodName}`,
174+ desc: method.description?.slice(0, 80) || `${httpMethod} ${method.path || ''}`,
175+ method: httpMethod,
176+ policy: policyFromMethod(httpMethod),
177+ })
178+ }
179+ }
180+ if (resource.resources) {
181+ extractMethods(resource.resources, `${prefix}${resourceName}.`)
182+ }
183+ }
184+ }
185+
186+ extractMethods(doc.resources)
187+
188+ // Also check top-level methods
189+ if (doc.methods) {
190+ for (const [methodName, method] of Object.entries(doc.methods as Record<string, any>)) {
191+ const httpMethod = (method.httpMethod || 'GET').toUpperCase()
192+ tools.push({
193+ name: methodName,
194+ desc: method.description?.slice(0, 80) || `${httpMethod} ${method.path || ''}`,
195+ method: httpMethod,
196+ policy: policyFromMethod(httpMethod),
197+ })
198+ }
199+ }
200+
201+ return {
202+ kind: 'googleDiscovery',
203+ name: title,
204+ count: tools.length,
205+ tools: tools.slice(0, 12),
206+ }
207+ } catch {
208+ return null
209+ }
210+}
211+
212+export const POST: APIRoute = async ({ request }) => {
213+ try {
214+ const body = await request.json() as { url?: string }
215+ const url = body.url?.trim()
216+ if (!url) {
217+ return new Response(JSON.stringify({ error: 'URL is required' }), { status: 400, headers: { 'Content-Type': 'application/json' } })
218+ }
219+
220+ // Validate URL
221+ try {
222+ new URL(url)
223+ } catch {
224+ return new Response(JSON.stringify({ error: 'Invalid URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } })
225+ }
226+
227+ const controller = new AbortController()
228+ const timeout = setTimeout(() => controller.abort(), 8000)
229+
230+ // Run all detectors in parallel
231+ const [openapi, graphql, google] = await Promise.all([
232+ tryOpenAPI(url, controller.signal),
233+ tryGraphQL(url, controller.signal),
234+ tryGoogleDiscovery(url, controller.signal),
235+ ])
236+
237+ clearTimeout(timeout)
238+
239+ // Return first successful detection (prefer by confidence: most tools wins)
240+ const results = [openapi, graphql, google].filter((r): r is DetectionResult => r !== null)
241+ results.sort((a, b) => b.count - a.count)
242+
243+ if (results.length === 0) {
244+ return new Response(
245+ JSON.stringify({ error: 'Could not detect an API at this URL. Try an OpenAPI spec, GraphQL endpoint, or Google Discovery document.' }),
246+ { status: 404, headers: { 'Content-Type': 'application/json' } }
247+ )
248+ }
249+
250+ return new Response(JSON.stringify(results[0]), {
251+ status: 200,
252+ headers: { 'Content-Type': 'application/json' },
253+ })
254+ } catch (err) {
255+ return new Response(
256+ JSON.stringify({ error: 'Detection failed' }),
257+ { status: 500, headers: { 'Content-Type': 'application/json' } }
258+ )
259+ }
260+}
apps/marketing/src/pages/index.astro+270−30
@@ -48,6 +48,34 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 47 unmodified lines
4848 </div>
4949 </div>
5050
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+
5179 <!-- Full-width interactive catalog UI -->
5280 <div class="rise-d4 relative" id="hero-catalog">
5381 <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
123151 </button>
124152 </div>
125153
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">
128156 <!-- 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">
130158 <div class="flex items-center gap-2 px-3 py-2 rounded-md border border-rule bg-surface text-ink-faint text-[12px]">
131159 <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>
132160 <span data-search-label>Search 347 tools...</span>
133161 </div>
134162 </div>
135163
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>
139170 </div>
140171 </div>
141172
@@ -149,60 +180,116 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 7 unmodified lines
149180 name: 'GitHub', count: 347, tools: [
150181 { name: 'issues.list', desc: 'List repository issues', policy: 'read', method: 'GET' },
151182 { 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' },
152190 { 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' },
153193 { 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' },
154196 { 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' },
155200 { 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' },
156204 ]
157205 },
158206 slack: {
159207 name: 'Slack', count: 42, tools: [
160208 { 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' },
161211 { name: 'conversations.list', desc: 'List all channels', policy: 'read', method: 'GET' },
162212 { 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' },
163216 { 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' },
164218 { 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' },
165220 { 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' },
166222 ]
167223 },
168224 calendar: {
169225 name: 'Google Calendar', count: 18, tools: [
170226 { name: 'events.list', desc: 'List upcoming events', policy: 'read', method: 'GET' },
227+ { name: 'events.get', desc: 'Get a single event', policy: 'read', method: 'GET' },
171228 { 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' },
172230 { 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' },
173232 { 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' },
175236 { 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' },
176239 ]
177240 },
178241 linear: {
179242 name: 'Linear', count: 56, tools: [
180243 { 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' },
181245 { name: 'issues.create', desc: 'Create a new issue', policy: 'write', method: 'mutation' },
182246 { 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' },
183249 { 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' },
184253 { 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' },
186257 ]
187258 },
188259 resend: {
189260 name: 'Resend', count: 19, tools: [
190261 { name: 'emails.send', desc: 'Send a transactional email', policy: 'write', method: 'POST' },
191262 { 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' },
192264 { 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' },
193267 { name: 'domains.verify', desc: 'Verify a domain', policy: 'write', method: 'POST' },
268+ { name: 'domains.delete', desc: 'Remove a domain', policy: 'destructive', method: 'DELETE' },
194269 { 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' },
195272 { name: 'contacts.create', desc: 'Create a new contact', policy: 'write', method: 'POST' },
273+ { name: 'contacts.remove', desc: 'Remove a contact', policy: 'destructive', method: 'DELETE' },
196274 ]
197275 },
198276 axiom: {
199277 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' },
206293 ]
207294 },
208295 };
@@ -229,11 +316,17 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 20 unmodified lines
229316
230317 const catalog = document.getElementById('hero-catalog');
231318 const toolList = catalog.querySelector('[data-tool-list]');
319+ const fadeEl = catalog.querySelector('[data-fade]');
232320 const breadcrumb = catalog.querySelector('[data-breadcrumb]');
233321 const totalTools = catalog.querySelector('[data-total-tools]');
234322 const searchLabel = catalog.querySelector('[data-search-label]');
235− const buttons = catalog.querySelectorAll('.catalog-source');
236323
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);
237330 function renderSource(key) {
238331 const src = sources[key];
239332 breadcrumb.textContent = src.name;
@@ -242,7 +335,7 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 2 unmodified lines
242335 const totalCount = Object.values(sources).reduce((s, v) => s + v.count, 0);
243336 totalTools.textContent = `${totalCount} tools indexed`;
244337
245− buttons.forEach(btn => {
338+ catalog.querySelectorAll('.catalog-source').forEach(btn => {
246339 const isActive = btn.dataset.source === key;
247340 if (isActive) {
248341 btn.className = btn.className
@@ -257,26 +350,173 @@ const mcpCode = `<span class="text-ink-muted">$</span> executor mcp --stdio · 8 unmodified lines
257350 }
258351 });
259352
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();
273403 }
274404
275− buttons.forEach(btn => {
405+ catalog.querySelectorAll('.catalog-source').forEach(btn => {
276406 btn.addEventListener('click', () => renderSource(btn.dataset.source));
277407 });
278408
279409 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+ });
280520 </script>
281521 </div>
282522
apps/marketing/src/styles/global.css+16−0
@@ -66,3 +66,19 @@ body { · 65 unmodified lines
6666 opacity: 1;
6767 transform: translateY(0);
6868 }
69+
70+/* Thin scrollbar for catalog */
71+.scrollbar-thin {
72+ scrollbar-width: thin;
73+ scrollbar-color: rgba(255,255,255,0.08) transparent;
74+}
75+.scrollbar-thin::-webkit-scrollbar {
76+ width: 4px;
77+}
78+.scrollbar-thin::-webkit-scrollbar-track {
79+ background: transparent;
80+}
81+.scrollbar-thin::-webkit-scrollbar-thumb {
82+ background: rgba(255,255,255,0.1);
83+ border-radius: 2px;
84+}
bun.lock+5−0
@@ -42,6 +42,7 @@ · 41 unmodified lines
4242 "name": "@executor/marketing",
4343 "version": "0.0.1",
4444 "dependencies": {
45+ "@astrojs/node": "^10.0.4",
4546 "@astrojs/tailwind": "^6.0.2",
4647 "@tailwindcss/vite": "^4.2.2",
4748 "astro": "^6.1.3",
@@ -571,6 +572,8 @@ · 523 unmodified lines
571572
572573 "@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.1.0", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "@astrojs/prism": "4.0.1", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "retext-smartypants": "^6.2.0", "shiki": "^4.0.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-P+HnCsu2js3BoTc8kFmu+E9gOcFeMdPris75g+Zl4sY8+bBRbSQV6xzcBDbZ27eE7yBGEGQoqjpChx+KJYIPYQ=="],
573574
575+ "@astrojs/node": ["@astrojs/node@10.0.4", "", { "dependencies": { "@astrojs/internal-helpers": "0.8.0", "send": "^1.2.1", "server-destroy": "^1.0.1" }, "peerDependencies": { "astro": "^6.0.0" } }, "sha512-7pVgiVSscQHRC2WqjlXcnbbcKMYp2GXrYpmuvdGg5zgA8J1lFm2vmwVhHZFuZK3Ik5PzoxiDROaEgoDGLbfhLw=="],
576+
574577 "@astrojs/prism": ["@astrojs/prism@4.0.1", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-nksZQVjlferuWzhPsBpQ1JE5XuKAf1id1/9Hj4a9KG4+ofrlzxUUwX4YGQF/SuDiuiGKEnzopGOt38F3AnVWsQ=="],
575578
576579 "@astrojs/tailwind": ["@astrojs/tailwind@6.0.2", "", { "dependencies": { "autoprefixer": "^10.4.21", "postcss": "^8.5.3", "postcss-load-config": "^4.0.2" }, "peerDependencies": { "astro": "^3.0.0 || ^4.0.0 || ^5.0.0", "tailwindcss": "^3.0.24" } }, "sha512-j3mhLNeugZq6A8dMNXVarUa8K6X9AW+QHU9u3lKNrPLMHhOQ0S7VeWhHwEeJFpEK1BTKEUY1U78VQv2gN6hNGg=="],
@@ -2367,6 +2370,8 @@ · 1790 unmodified lines
23672370
23682371 "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
23692372
2373+ "server-destroy": ["server-destroy@1.0.1", "", {}, "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="],
2374+
23702375 "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
23712376
23722377 "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t