@soederpop/luca 0.0.19 → 0.0.21

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.
@@ -2,7 +2,10 @@ import { z } from 'zod'
2
2
  import { commands } from '../command.js'
3
3
  import { CommandOptionsSchema } from '../schemas/base.js'
4
4
  import type { ContainerContext } from '../container.js'
5
- import type { IntrospectionSection, MethodIntrospection, GetterIntrospection } from '../introspection/index.js'
5
+ import type { IntrospectionSection, MethodIntrospection, GetterIntrospection, HelperIntrospection } from '../introspection/index.js'
6
+ import { __INTROSPECTION__, __BROWSER_INTROSPECTION__ } from '../introspection/index.js'
7
+ import { features } from '../feature.js'
8
+ import { presentIntrospectionAsMarkdown } from '../helper.js'
6
9
 
7
10
  declare module '../command.js' {
8
11
  interface AvailableCommands {
@@ -61,13 +64,18 @@ export const argsSchema = CommandOptionsSchema.extend({
61
64
  'only-env-vars': z.boolean().default(false).describe('Show only the envVars section'),
62
65
  'only-envvars': z.boolean().default(false).describe('Show only the envVars section'),
63
66
  'only-examples': z.boolean().default(false).describe('Show only the examples section'),
67
+ platform: z.enum(['browser', 'web', 'server', 'node', 'all']).default('all').describe('Which platform features to show: browser/web, server/node, or all'),
64
68
  })
65
69
 
70
+ type Platform = 'browser' | 'server' | 'node' | 'all'
71
+
66
72
  type ResolvedTarget =
67
73
  | { kind: 'container' }
68
74
  | { kind: 'registry'; name: RegistryName }
69
75
  | { kind: 'helper'; registry: RegistryName; id: string }
70
76
  | { kind: 'member'; registry: RegistryName; id: string; member: string; memberType: 'method' | 'getter' }
77
+ | { kind: 'browser-helper'; id: string }
78
+ | { kind: 'browser-member'; id: string; member: string; memberType: 'method' | 'getter' }
71
79
 
72
80
  class DescribeError extends Error {
73
81
  constructor(message: string) {
@@ -76,6 +84,303 @@ class DescribeError extends Error {
76
84
  }
77
85
  }
78
86
 
87
+ // --- Browser feature loading ---
88
+
89
+ const WEB_FEATURE_IDS = ['speech', 'voice', 'assetLoader', 'network', 'vault', 'vm', 'esbuild', 'helpers', 'containerLink']
90
+
91
+ type BrowserFeatureData = {
92
+ introspection: Map<string, HelperIntrospection>
93
+ constructors: Map<string, any>
94
+ available: string[]
95
+ collidingIds: Set<string>
96
+ }
97
+
98
+ let _browserData: BrowserFeatureData | null = null
99
+
100
+ /**
101
+ * Load web/browser feature introspection data into a separate map.
102
+ * Imports the web feature class files to get full Zod-derived data,
103
+ * then restores the node registry and introspection to their original state.
104
+ */
105
+ async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
106
+ if (_browserData) return _browserData
107
+
108
+ // Snapshot current node state
109
+ const nodeFeatureIds = new Set(features.available)
110
+ const nodeIntrospection = new Map<string, HelperIntrospection>()
111
+ const nodeConstructors = new Map<string, any>()
112
+
113
+ for (const id of nodeFeatureIds) {
114
+ const data = __INTROSPECTION__.get(`features.${id}`)
115
+ if (data) nodeIntrospection.set(`features.${id}`, structuredClone(data))
116
+ try { nodeConstructors.set(id, features.lookup(id)) } catch {}
117
+ }
118
+
119
+ // Import generated web build-time data (descriptions, methods, getters from AST)
120
+ await import('../introspection/generated.web.js')
121
+
122
+ // Import web feature class files (triggers Feature.register → interceptRegistration → Zod data)
123
+ await Promise.all([
124
+ import('../web/features/speech.js'),
125
+ import('../web/features/voice-recognition.js'),
126
+ import('../web/features/asset-loader.js'),
127
+ import('../web/features/network.js'),
128
+ import('../web/features/vault.js'),
129
+ import('../web/features/vm.js'),
130
+ import('../web/features/esbuild.js'),
131
+ import('../web/features/helpers.js'),
132
+ import('../web/features/container-link.js'),
133
+ ])
134
+
135
+ // Capture browser introspection data and constructors
136
+ const browserIntrospection = new Map<string, HelperIntrospection>()
137
+ const browserConstructors = new Map<string, any>()
138
+ const collidingIds = new Set<string>()
139
+
140
+ for (const id of WEB_FEATURE_IDS) {
141
+ const key = `features.${id}`
142
+ const data = __INTROSPECTION__.get(key)
143
+ if (data) browserIntrospection.set(key, structuredClone(data))
144
+ try { browserConstructors.set(id, features.lookup(id)) } catch {}
145
+ if (nodeFeatureIds.has(id)) collidingIds.add(id)
146
+ }
147
+
148
+ // Restore node registry: re-register all node constructors
149
+ for (const [id, ctor] of nodeConstructors) {
150
+ features.register(id, ctor)
151
+ }
152
+
153
+ // Fully restore node introspection (overwrite whatever interceptRegistration did during re-register)
154
+ for (const [key, data] of nodeIntrospection) {
155
+ __INTROSPECTION__.set(key, data)
156
+ }
157
+
158
+ // Clean up: remove web-only entries from __INTROSPECTION__ and the registry
159
+ for (const id of WEB_FEATURE_IDS) {
160
+ const key = `features.${id}`
161
+ if (!nodeIntrospection.has(key)) {
162
+ __INTROSPECTION__.delete(key)
163
+ }
164
+ if (!nodeFeatureIds.has(id)) {
165
+ features.unregister(id)
166
+ }
167
+ }
168
+
169
+ // Store in __BROWSER_INTROSPECTION__ for other potential consumers
170
+ for (const [key, data] of browserIntrospection) {
171
+ __BROWSER_INTROSPECTION__.set(key, data)
172
+ }
173
+
174
+ _browserData = {
175
+ introspection: browserIntrospection,
176
+ constructors: browserConstructors,
177
+ available: WEB_FEATURE_IDS.filter(id => browserIntrospection.has(`features.${id}`)),
178
+ collidingIds,
179
+ }
180
+
181
+ return _browserData
182
+ }
183
+
184
+ /** Render a browser feature's introspection data directly (no Ctor needed). */
185
+ function renderBrowserHelperText(data: HelperIntrospection, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): string {
186
+ const h = '#'.repeat(headingDepth)
187
+ const className = data.className || data.id
188
+
189
+ if (sections.length === 0) {
190
+ const body = presentIntrospectionAsMarkdown(data, headingDepth)
191
+ if (noTitle) {
192
+ // Strip the title heading and return description + sections
193
+ const lines = body.split('\n')
194
+ const sectionHeading = '#'.repeat(headingDepth + 1) + ' '
195
+ const firstSectionIdx = lines.findIndex((l, i) => i > 0 && l.startsWith(sectionHeading))
196
+ const desc = data.description ? data.description + '\n\n' : ''
197
+ if (firstSectionIdx > 0) {
198
+ return desc + lines.slice(firstSectionIdx).join('\n')
199
+ }
200
+ // No subsections — just return the description
201
+ return desc.trim() || 'No introspection data available.'
202
+ }
203
+ return body
204
+ }
205
+
206
+ const parts: string[] = []
207
+ if (!noTitle) {
208
+ parts.push(`${h} ${className} (${data.id})`)
209
+ if (data.description) parts.push(data.description)
210
+ }
211
+
212
+ const introspectionSections = sections.filter((s): s is IntrospectionSection => s !== 'description')
213
+ for (const section of introspectionSections) {
214
+ const text = presentIntrospectionAsMarkdown(data, headingDepth, section)
215
+ if (text) parts.push(text)
216
+ }
217
+
218
+ return parts.join('\n\n') || `${noTitle ? '' : `${h} ${className}\n\n`}No introspection data available.`
219
+ }
220
+
221
+ function renderBrowserHelperJson(data: HelperIntrospection, sections: (IntrospectionSection | 'description')[], noTitle = false): any {
222
+ if (sections.length === 0) return data
223
+
224
+ const result: Record<string, any> = {}
225
+ if (!noTitle) {
226
+ result.id = data.id
227
+ if (data.className) result.className = data.className
228
+ }
229
+ for (const section of sections) {
230
+ if (section === 'description') {
231
+ result.id = data.id
232
+ if (data.className) result.className = data.className
233
+ result.description = data.description
234
+ } else if (section === 'usage') {
235
+ result.usage = { shortcut: data.shortcut, options: data.options }
236
+ } else {
237
+ result[section] = (data as any)[section]
238
+ }
239
+ }
240
+ return result
241
+ }
242
+
243
+ /** Build a concise summary for a browser feature from introspection data. */
244
+ function buildBrowserHelperSummary(data: HelperIntrospection): string {
245
+ const ownMethods = Object.keys(data.methods || {}).sort()
246
+ const ownGetters = Object.keys(data.getters || {}).sort()
247
+ const lines: string[] = []
248
+ if (ownGetters.length) lines.push(`getters: ${ownGetters.join(', ')}`)
249
+ if (ownMethods.length) lines.push(`methods: ${ownMethods.map(m => m + '()').join(', ')}`)
250
+ return lines.join('\n')
251
+ }
252
+
253
+ function getBrowserHelperData(id: string, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): { json: any; text: string } {
254
+ const data = _browserData!.introspection.get(`features.${id}`)
255
+ if (!data) return { json: {}, text: `No browser introspection data for ${id}` }
256
+
257
+ const text = renderBrowserHelperText(data, sections, noTitle, headingDepth)
258
+
259
+ // Inject summary after title for full renders
260
+ let finalText = text
261
+ if (sections.length === 0 && !noTitle) {
262
+ const summary = buildBrowserHelperSummary(data)
263
+ if (summary) {
264
+ const sectionHeading = '#'.repeat(headingDepth + 1) + ' '
265
+ const idx = text.indexOf('\n' + sectionHeading)
266
+ if (idx >= 0) {
267
+ finalText = text.slice(0, idx) + '\n\n' + summary + '\n' + text.slice(idx)
268
+ }
269
+ }
270
+ }
271
+
272
+ return {
273
+ json: renderBrowserHelperJson(data, sections, noTitle),
274
+ text: finalText,
275
+ }
276
+ }
277
+
278
+ function getBrowserMemberData(id: string, member: string, memberType: 'method' | 'getter', headingDepth = 1): { json: any; text: string } {
279
+ const data = _browserData!.introspection.get(`features.${id}`)
280
+ if (!data) return { json: {}, text: `No browser introspection data for ${id}` }
281
+
282
+ const h = '#'.repeat(headingDepth)
283
+ const hSub = '#'.repeat(headingDepth + 1)
284
+
285
+ if (memberType === 'method') {
286
+ const method = data.methods?.[member] as MethodIntrospection | undefined
287
+ if (!method) return { json: {}, text: `No introspection data for ${id}.${member}()` }
288
+
289
+ const parts: string[] = [`${h} ${id}.${member}() (browser)`]
290
+ parts.push(`> method on **${data.className || id}**`)
291
+ if (method.description) parts.push(method.description)
292
+
293
+ const paramEntries = Object.entries(method.parameters || {})
294
+ if (paramEntries.length > 0) {
295
+ const paramLines = [`${hSub} Parameters`, '']
296
+ for (const [name, info] of paramEntries) {
297
+ const req = (method.required || []).includes(name) ? ' *(required)*' : ''
298
+ paramLines.push(`- **${name}** \`${info.type}\`${req}${info.description ? ' — ' + info.description : ''}`)
299
+ }
300
+ parts.push(paramLines.join('\n'))
301
+ }
302
+
303
+ if (method.returns && method.returns !== 'void') {
304
+ parts.push(`${hSub} Returns\n\n\`${method.returns}\``)
305
+ }
306
+
307
+ return { json: { [member]: method, _helper: id, _type: 'method', _platform: 'browser' }, text: parts.join('\n\n') }
308
+ }
309
+
310
+ const getter = data.getters?.[member] as GetterIntrospection | undefined
311
+ if (!getter) return { json: {}, text: `No introspection data for ${id}.${member}` }
312
+
313
+ const parts: string[] = [`${h} ${id}.${member} (browser)`]
314
+ parts.push(`> getter on **${data.className || id}** — returns \`${getter.returns || 'unknown'}\``)
315
+ if (getter.description) parts.push(getter.description)
316
+
317
+ return { json: { [member]: getter, _helper: id, _type: 'getter', _platform: 'browser' }, text: parts.join('\n\n') }
318
+ }
319
+
320
+ function getBrowserRegistryData(sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): { json: any; text: string } {
321
+ const browserData = _browserData!
322
+ const available = browserData.available
323
+
324
+ if (available.length === 0) {
325
+ return { json: {}, text: 'No browser features are registered.' }
326
+ }
327
+
328
+ if (sections.length === 0) {
329
+ const h = '#'.repeat(headingDepth)
330
+ const hSub = '#'.repeat(headingDepth + 1)
331
+ const jsonResult: Record<string, any> = {}
332
+ const textParts: string[] = [`${h} Available browser features (${available.length})\n`]
333
+
334
+ for (const id of available.sort()) {
335
+ const data = browserData.introspection.get(`features.${id}`)
336
+ if (!data) continue
337
+
338
+ const summary = extractSummary(data.description || 'No description provided')
339
+ const featureGetters = Object.keys(data.getters || {}).sort()
340
+ const featureMethods = Object.keys(data.methods || {}).sort()
341
+
342
+ jsonResult[id] = { description: summary, methods: featureMethods, getters: featureGetters, platform: 'browser' }
343
+
344
+ const memberLines: string[] = []
345
+ if (featureGetters.length) memberLines.push(` getters: ${featureGetters.join(', ')}`)
346
+ if (featureMethods.length) memberLines.push(` methods: ${featureMethods.map(m => m + '()').join(', ')}`)
347
+ const memberBlock = memberLines.length ? '\n' + memberLines.join('\n') + '\n' : ''
348
+
349
+ textParts.push(`${hSub} ${id}\n${summary}\n${memberBlock}`)
350
+ }
351
+
352
+ return { json: jsonResult, text: textParts.join('\n') }
353
+ }
354
+
355
+ // Sections specified: render each helper in detail
356
+ const jsonResult: Record<string, any> = {}
357
+ const textParts: string[] = []
358
+ for (const id of available) {
359
+ const data = browserData.introspection.get(`features.${id}`)
360
+ if (!data) continue
361
+ jsonResult[id] = renderBrowserHelperJson(data, sections, noTitle)
362
+ textParts.push(renderBrowserHelperText(data, sections, noTitle, headingDepth))
363
+ }
364
+
365
+ return { json: jsonResult, text: textParts.join('\n\n---\n\n') }
366
+ }
367
+
368
+ function normalizePlatform(p: string): Platform {
369
+ if (p === 'node') return 'server'
370
+ if (p === 'web') return 'browser'
371
+ return p as Platform
372
+ }
373
+
374
+ function shouldIncludeNode(platform: Platform): boolean {
375
+ return platform === 'server' || platform === 'node' || platform === 'all'
376
+ }
377
+
378
+ function shouldIncludeBrowser(platform: Platform): boolean {
379
+ return platform === 'browser' || platform === 'all'
380
+ }
381
+
382
+ // --- End browser feature loading ---
383
+
79
384
  /**
80
385
  * Extract a short summary from a potentially long description string.
81
386
  * Takes text up to the first markdown heading, bullet list, or code block,
@@ -153,16 +458,50 @@ function resolveHelperMember(helperName: string, memberName: string, container:
153
458
  return null
154
459
  }
155
460
 
461
+ /** Find a browser feature by normalized name. */
462
+ function fuzzyFindBrowser(input: string): string | undefined {
463
+ if (!_browserData) return undefined
464
+ const norm = normalize(input)
465
+ return _browserData.available.find(id => normalize(id) === norm)
466
+ }
467
+
468
+ /** Try to resolve "browserHelper.member" for browser features. */
469
+ function resolveBrowserHelperMember(helperName: string, memberName: string): ResolvedTarget | null {
470
+ if (!_browserData) return null
471
+ const found = fuzzyFindBrowser(helperName)
472
+ if (!found) return null
473
+
474
+ const data = _browserData.introspection.get(`features.${found}`)
475
+ if (!data) return null
476
+
477
+ if (data.methods?.[memberName]) {
478
+ return { kind: 'browser-member', id: found, member: memberName, memberType: 'method' }
479
+ }
480
+ if (data.getters?.[memberName]) {
481
+ return { kind: 'browser-member', id: found, member: memberName, memberType: 'getter' }
482
+ }
483
+
484
+ const allMembers = [
485
+ ...Object.keys(data.methods || {}).map((m: string) => m + '()'),
486
+ ...Object.keys(data.getters || {}),
487
+ ].sort()
488
+ throw new DescribeError(
489
+ `"${memberName}" is not a known method or getter on ${found} (browser).\n\nAvailable members:\n ${allMembers.join(', ')}`
490
+ )
491
+ }
492
+
156
493
  /**
157
494
  * Parse a single target string into a resolved target.
158
495
  * Accepts: "container", "features", "features.fs", "fs", "ui.banner", etc.
159
496
  */
160
- function resolveTarget(target: string, container: any): ResolvedTarget {
497
+ function resolveTarget(target: string, container: any, platform: Platform): ResolvedTarget[] {
161
498
  const lower = target.toLowerCase()
499
+ const includeNode = shouldIncludeNode(platform)
500
+ const includeBrowser = shouldIncludeBrowser(platform)
162
501
 
163
502
  // "container" or "self"
164
503
  if (lower === 'container' || lower === 'self') {
165
- return { kind: 'container' }
504
+ return [{ kind: 'container' }]
166
505
  }
167
506
 
168
507
  // Registry name: "features", "clients", "servers", "commands", "endpoints"
@@ -170,7 +509,7 @@ function resolveTarget(target: string, container: any): ResolvedTarget {
170
509
  (r) => r === lower || r === lower + 's' || r.replace(/s$/, '') === lower
171
510
  )
172
511
  if (registryMatch && !target.includes('.')) {
173
- return { kind: 'registry', name: registryMatch }
512
+ return [{ kind: 'registry', name: registryMatch }]
174
513
  }
175
514
 
176
515
  // Qualified name: "features.fs", "clients.rest", etc.
@@ -182,53 +521,103 @@ function resolveTarget(target: string, container: any): ResolvedTarget {
182
521
  )
183
522
 
184
523
  if (registry) {
185
- const reg = container[registry]
186
- const resolved = fuzzyFind(reg, id)
187
- if (!resolved) {
188
- throw new DescribeError(`"${id}" is not registered in ${registry}. Available: ${reg.available.join(', ')}`)
524
+ const results: ResolvedTarget[] = []
525
+
526
+ if (includeNode) {
527
+ const reg = container[registry]
528
+ const resolved = fuzzyFind(reg, id)
529
+ if (resolved) results.push({ kind: 'helper', registry, id: resolved })
530
+ }
531
+
532
+ if (includeBrowser && registry === 'features') {
533
+ const browserFound = fuzzyFindBrowser(id)
534
+ if (browserFound) results.push({ kind: 'browser-helper', id: browserFound })
189
535
  }
190
- return { kind: 'helper', registry, id: resolved }
536
+
537
+ if (results.length === 0) {
538
+ const reg = container[registry]
539
+ const availableMsg = includeNode ? reg.available.join(', ') : ''
540
+ const browserMsg = includeBrowser && _browserData ? _browserData.available.join(', ') : ''
541
+ const combined = [availableMsg, browserMsg].filter(Boolean).join(', ')
542
+ throw new DescribeError(`"${id}" is not registered in ${registry}. Available: ${combined}`)
543
+ }
544
+
545
+ return results
191
546
  }
192
547
 
193
548
  // Not a registry prefix — try "helper.member" (e.g. "ui.banner", "fs.readFile")
194
549
  const helperName = prefix!
195
550
  const memberName = rest.join('.')
196
- const memberResult = resolveHelperMember(helperName, memberName, container)
197
- if (memberResult) return memberResult
551
+ const results: ResolvedTarget[] = []
552
+
553
+ if (includeNode) {
554
+ const memberResult = resolveHelperMember(helperName, memberName, container)
555
+ if (memberResult) results.push(memberResult)
556
+ }
557
+
558
+ if (includeBrowser) {
559
+ try {
560
+ const browserResult = resolveBrowserHelperMember(helperName, memberName)
561
+ if (browserResult) results.push(browserResult)
562
+ } catch (e) {
563
+ if (results.length === 0) throw e
564
+ }
565
+ }
566
+
567
+ if (results.length > 0) return results
198
568
  }
199
569
 
200
- // Unqualified name: search all registries (fuzzy)
201
- const matches: { registry: RegistryName; id: string }[] = []
202
- for (const registryName of REGISTRY_NAMES) {
203
- const reg = container[registryName]
204
- if (!reg) continue
205
- const found = fuzzyFind(reg, target)
206
- if (found) {
207
- matches.push({ registry: registryName, id: found })
570
+ // Unqualified name: search all registries (fuzzy) + browser features
571
+ const matches: ResolvedTarget[] = []
572
+
573
+ if (includeNode) {
574
+ for (const registryName of REGISTRY_NAMES) {
575
+ const reg = container[registryName]
576
+ if (!reg) continue
577
+ const found = fuzzyFind(reg, target)
578
+ if (found) {
579
+ matches.push({ kind: 'helper', registry: registryName, id: found })
580
+ }
581
+ }
582
+ }
583
+
584
+ if (includeBrowser) {
585
+ const browserFound = fuzzyFindBrowser(target)
586
+ if (browserFound) {
587
+ // If there's already a node feature with the same id, include both
588
+ matches.push({ kind: 'browser-helper', id: browserFound })
208
589
  }
209
590
  }
210
591
 
211
592
  if (matches.length === 0) {
212
593
  const lines = [`"${target}" was not found in any registry.`, '', 'Available:']
213
- for (const registryName of REGISTRY_NAMES) {
214
- const reg = container[registryName]
215
- if (reg && reg.available.length > 0) {
216
- lines.push(` ${registryName}: ${reg.available.join(', ')}`)
594
+ if (includeNode) {
595
+ for (const registryName of REGISTRY_NAMES) {
596
+ const reg = container[registryName]
597
+ if (reg && reg.available.length > 0) {
598
+ lines.push(` ${registryName}: ${reg.available.join(', ')}`)
599
+ }
217
600
  }
218
601
  }
602
+ if (includeBrowser && _browserData && _browserData.available.length > 0) {
603
+ lines.push(` browser features: ${_browserData.available.join(', ')}`)
604
+ }
219
605
  throw new DescribeError(lines.join('\n'))
220
606
  }
221
607
 
222
- if (matches.length > 1) {
608
+ // For unqualified names with a single node match and no browser match (or vice versa), return as-is
609
+ // For ambiguous node matches (multiple registries), report ambiguity
610
+ const nodeMatches = matches.filter(m => m.kind === 'helper')
611
+ if (nodeMatches.length > 1) {
223
612
  const lines = [`"${target}" is ambiguous — found in multiple registries:`]
224
- for (const m of matches) {
225
- lines.push(` ${m.registry}.${m.id}`)
613
+ for (const m of nodeMatches) {
614
+ if (m.kind === 'helper') lines.push(` ${m.registry}.${m.id}`)
226
615
  }
227
- lines.push('', `Please qualify it, e.g.: ${matches[0]!.registry}.${target}`)
616
+ lines.push('', `Please qualify it, e.g.: ${(nodeMatches[0] as any).registry}.${target}`)
228
617
  throw new DescribeError(lines.join('\n'))
229
618
  }
230
619
 
231
- return { kind: 'helper', registry: matches[0]!.registry, id: matches[0]!.id }
620
+ return matches
232
621
  }
233
622
 
234
623
  /** Collect all requested sections from flags. Empty array = show everything. */
@@ -450,85 +839,135 @@ function findIntermediateParent(Ctor: any, baseClass: any): { name: string; meth
450
839
  }
451
840
  }
452
841
 
453
- function getRegistryData(container: any, registryName: RegistryName, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): { json: any; text: string } {
842
+ function getRegistryData(container: any, registryName: RegistryName, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1, platform: Platform = 'all'): { json: any; text: string } {
843
+ const includeNode = shouldIncludeNode(platform)
844
+ const includeBrowser = shouldIncludeBrowser(platform) && registryName === 'features' && _browserData
845
+
454
846
  const registry = container[registryName]
455
- const available: string[] = registry.available
847
+ const nodeAvailable: string[] = includeNode ? registry.available : []
848
+ const browserAvailable: string[] = includeBrowser ? _browserData!.available : []
456
849
 
457
- if (available.length === 0) {
850
+ // Deduplicate: for --platform all, colliding features appear in both lists
851
+ const collidingIds = includeBrowser ? _browserData!.collidingIds : new Set<string>()
852
+
853
+ const totalCount = nodeAvailable.length + browserAvailable.filter(id => !includeNode || !collidingIds.has(id)).length
854
+
855
+ if (totalCount === 0) {
458
856
  return { json: {}, text: `No ${registryName} are registered.` }
459
857
  }
460
858
 
461
- // When no section filters are specified, render a concise index (like describeAll)
462
- // rather than full introspection for every single helper
859
+ // When no section filters are specified, render a concise index
463
860
  if (sections.length === 0) {
464
861
  const h = '#'.repeat(headingDepth)
465
862
  const hSub = '#'.repeat(headingDepth + 1)
466
863
  const jsonResult: Record<string, any> = {}
467
- const textParts: string[] = [`${h} Available ${registryName} (${available.length})\n`]
864
+ const textParts: string[] = [`${h} Available ${registryName} (${totalCount})\n`]
468
865
 
469
866
  // Show shared methods/getters from the base class at the top
470
- const baseClass = registry.baseClass
471
- if (baseClass) {
472
- const shared = collectSharedMembers(baseClass)
473
- const label = registryName === 'features' ? 'Feature'
474
- : registryName === 'clients' ? 'Client'
475
- : registryName === 'servers' ? 'Server'
476
- : registryName[0]!.toUpperCase() + registryName.slice(1).replace(/s$/, '')
477
-
478
- if (shared.getters.length) {
479
- textParts.push(`**Shared ${label} Getters:** ${shared.getters.join(', ')}\n`)
480
- }
481
- if (shared.methods.length) {
482
- textParts.push(`**Shared ${label} Methods:** ${shared.methods.map(m => m + '()').join(', ')}\n`)
867
+ if (includeNode) {
868
+ const baseClass = registry.baseClass
869
+ if (baseClass) {
870
+ const shared = collectSharedMembers(baseClass)
871
+ const label = registryName === 'features' ? 'Feature'
872
+ : registryName === 'clients' ? 'Client'
873
+ : registryName === 'servers' ? 'Server'
874
+ : registryName[0]!.toUpperCase() + registryName.slice(1).replace(/s$/, '')
875
+
876
+ if (shared.getters.length) {
877
+ textParts.push(`**Shared ${label} Getters:** ${shared.getters.join(', ')}\n`)
878
+ }
879
+ if (shared.methods.length) {
880
+ textParts.push(`**Shared ${label} Methods:** ${shared.methods.map(m => m + '()').join(', ')}\n`)
881
+ }
882
+
883
+ jsonResult._shared = { methods: shared.methods, getters: shared.getters }
483
884
  }
885
+ }
484
886
 
485
- jsonResult._shared = { methods: shared.methods, getters: shared.getters }
887
+ // Render node features
888
+ if (includeNode) {
889
+ const baseClass = registry.baseClass
890
+ const sorted = [...nodeAvailable].sort((a, b) => {
891
+ const aCtor = registry.lookup(a)
892
+ const bCtor = registry.lookup(b)
893
+ const aIsDirect = !findIntermediateParent(aCtor, baseClass)
894
+ const bIsDirect = !findIntermediateParent(bCtor, baseClass)
895
+ if (aIsDirect && !bIsDirect) return -1
896
+ if (!aIsDirect && bIsDirect) return 1
897
+ return 0
898
+ })
899
+
900
+ for (const id of sorted) {
901
+ const Ctor = registry.lookup(id)
902
+ const introspection = Ctor.introspect?.()
903
+ const description = introspection?.description || Ctor.description || 'No description provided'
904
+ const summary = extractSummary(description)
905
+ const featureGetters = Object.keys(introspection?.getters || {}).sort()
906
+ const featureMethods = Object.keys(introspection?.methods || {}).sort()
907
+ const intermediate = findIntermediateParent(Ctor, baseClass)
908
+
909
+ // Tag colliding features with (node) when showing both platforms
910
+ const platformTag = includeBrowser && collidingIds.has(id) ? ' (node)' : ''
911
+
912
+ const entryJson: Record<string, any> = { description: summary, methods: featureMethods, getters: featureGetters }
913
+ if (intermediate) {
914
+ entryJson.extends = intermediate.name
915
+ entryJson.inheritedMethods = intermediate.methods
916
+ entryJson.inheritedGetters = intermediate.getters
917
+ }
918
+ if (platformTag) entryJson.platform = 'node'
919
+ jsonResult[id + (platformTag ? ':node' : '')] = entryJson
920
+
921
+ const extendsLine = intermediate ? `\n> extends ${intermediate.name}\n` : ''
922
+ const memberLines: string[] = []
923
+ if (featureGetters.length) memberLines.push(` getters: ${featureGetters.join(', ')}`)
924
+ if (featureMethods.length) memberLines.push(` methods: ${featureMethods.map(m => m + '()').join(', ')}`)
925
+ if (intermediate) {
926
+ if (intermediate.getters.length) memberLines.push(` inherited getters: ${intermediate.getters.join(', ')}`)
927
+ if (intermediate.methods.length) memberLines.push(` inherited methods: ${intermediate.methods.map(m => m + '()').join(', ')}`)
928
+ }
929
+ const memberBlock = memberLines.length ? '\n' + memberLines.join('\n') + '\n' : ''
930
+ textParts.push(`${hSub} ${id}${platformTag}${extendsLine}\n${summary}\n${memberBlock}`)
931
+ }
486
932
  }
487
933
 
488
- // Sort: core framework classes (direct children of baseClass) first, then the rest
489
- const sorted = [...available].sort((a, b) => {
490
- const aCtor = registry.lookup(a)
491
- const bCtor = registry.lookup(b)
492
- const aIsDirect = !findIntermediateParent(aCtor, baseClass)
493
- const bIsDirect = !findIntermediateParent(bCtor, baseClass)
494
- if (aIsDirect && !bIsDirect) return -1
495
- if (!aIsDirect && bIsDirect) return 1
496
- return 0
497
- })
934
+ // Render browser features
935
+ if (includeBrowser) {
936
+ for (const id of browserAvailable.sort()) {
937
+ // Skip if already shown as a node feature and platform is not specifically browser
938
+ if (includeNode && collidingIds.has(id)) {
939
+ // Show the browser version too, tagged
940
+ const data = _browserData!.introspection.get(`features.${id}`)
941
+ if (!data) continue
942
+ const summary = extractSummary(data.description || 'No description provided')
943
+ const featureGetters = Object.keys(data.getters || {}).sort()
944
+ const featureMethods = Object.keys(data.methods || {}).sort()
945
+
946
+ jsonResult[id + ':browser'] = { description: summary, methods: featureMethods, getters: featureGetters, platform: 'browser' }
947
+
948
+ const memberLines: string[] = []
949
+ if (featureGetters.length) memberLines.push(` getters: ${featureGetters.join(', ')}`)
950
+ if (featureMethods.length) memberLines.push(` methods: ${featureMethods.map(m => m + '()').join(', ')}`)
951
+ const memberBlock = memberLines.length ? '\n' + memberLines.join('\n') + '\n' : ''
952
+ textParts.push(`${hSub} ${id} (browser)\n${summary}\n${memberBlock}`)
953
+ continue
954
+ }
498
955
 
499
- for (const id of sorted) {
500
- const Ctor = registry.lookup(id)
501
- const introspection = Ctor.introspect?.()
502
- const description = introspection?.description || Ctor.description || 'No description provided'
503
- // Take only the first 1-2 sentences as the summary
504
- const summary = extractSummary(description)
505
-
506
- const featureGetters = Object.keys(introspection?.getters || {}).sort()
507
- const featureMethods = Object.keys(introspection?.methods || {}).sort()
508
-
509
- // Detect intermediate parent class (e.g. RestClient between ElevenLabsClient and Client)
510
- const intermediate = findIntermediateParent(Ctor, baseClass)
511
-
512
- const entryJson: Record<string, any> = { description: summary, methods: featureMethods, getters: featureGetters }
513
- if (intermediate) {
514
- entryJson.extends = intermediate.name
515
- entryJson.inheritedMethods = intermediate.methods
516
- entryJson.inheritedGetters = intermediate.getters
517
- }
518
- jsonResult[id] = entryJson
956
+ const data = _browserData!.introspection.get(`features.${id}`)
957
+ if (!data) continue
958
+ const summary = extractSummary(data.description || 'No description provided')
959
+ const featureGetters = Object.keys(data.getters || {}).sort()
960
+ const featureMethods = Object.keys(data.methods || {}).sort()
519
961
 
520
- const extendsLine = intermediate ? `\n> extends ${intermediate.name}\n` : ''
962
+ const platformTag = !includeNode ? '' : ' (browser)'
963
+ jsonResult[id] = { description: summary, methods: featureMethods, getters: featureGetters, platform: 'browser' }
521
964
 
522
- const memberLines: string[] = []
523
- if (featureGetters.length) memberLines.push(` getters: ${featureGetters.join(', ')}`)
524
- if (featureMethods.length) memberLines.push(` methods: ${featureMethods.map(m => m + '()').join(', ')}`)
525
- if (intermediate) {
526
- if (intermediate.getters.length) memberLines.push(` inherited getters: ${intermediate.getters.join(', ')}`)
527
- if (intermediate.methods.length) memberLines.push(` inherited methods: ${intermediate.methods.map(m => m + '()').join(', ')}`)
965
+ const memberLines: string[] = []
966
+ if (featureGetters.length) memberLines.push(` getters: ${featureGetters.join(', ')}`)
967
+ if (featureMethods.length) memberLines.push(` methods: ${featureMethods.map(m => m + '()').join(', ')}`)
968
+ const memberBlock = memberLines.length ? '\n' + memberLines.join('\n') + '\n' : ''
969
+ textParts.push(`${hSub} ${id}${platformTag}\n${summary}\n${memberBlock}`)
528
970
  }
529
-
530
- const memberBlock = memberLines.length ? '\n' + memberLines.join('\n') + '\n' : ''
531
- textParts.push(`${hSub} ${id}${extendsLine}\n${summary}\n${memberBlock}`)
532
971
  }
533
972
 
534
973
  return { json: jsonResult, text: textParts.join('\n') }
@@ -537,10 +976,23 @@ function getRegistryData(container: any, registryName: RegistryName, sections: (
537
976
  // When specific sections are requested, render full detail for each helper
538
977
  const jsonResult: Record<string, any> = {}
539
978
  const textParts: string[] = []
540
- for (const id of available) {
541
- const Ctor = registry.lookup(id)
542
- jsonResult[id] = renderHelperJson(Ctor, sections, noTitle)
543
- textParts.push(renderHelperText(Ctor, sections, noTitle, headingDepth))
979
+
980
+ if (includeNode) {
981
+ for (const id of nodeAvailable) {
982
+ const Ctor = registry.lookup(id)
983
+ jsonResult[id] = renderHelperJson(Ctor, sections, noTitle)
984
+ textParts.push(renderHelperText(Ctor, sections, noTitle, headingDepth))
985
+ }
986
+ }
987
+
988
+ if (includeBrowser) {
989
+ for (const id of browserAvailable) {
990
+ if (includeNode && collidingIds.has(id)) continue // already shown
991
+ const data = _browserData!.introspection.get(`features.${id}`)
992
+ if (!data) continue
993
+ jsonResult[id] = renderBrowserHelperJson(data, sections, noTitle)
994
+ textParts.push(renderBrowserHelperText(data, sections, noTitle, headingDepth))
995
+ }
544
996
  }
545
997
 
546
998
  return { json: jsonResult, text: textParts.join('\n\n---\n\n') }
@@ -688,6 +1140,13 @@ export default async function describe(options: z.infer<typeof argsSchema>, cont
688
1140
 
689
1141
  await container.helpers.discoverAll()
690
1142
 
1143
+ const platform = normalizePlatform(options.platform)
1144
+
1145
+ // Load browser features if needed
1146
+ if (shouldIncludeBrowser(platform)) {
1147
+ await loadBrowserFeatures()
1148
+ }
1149
+
691
1150
  const args = container.argv._ as string[]
692
1151
  // args[0] is "describe", the rest are targets
693
1152
  const targets = args.slice(1)
@@ -718,7 +1177,7 @@ export default async function describe(options: z.infer<typeof argsSchema>, cont
718
1177
 
719
1178
  for (const target of targets) {
720
1179
  try {
721
- resolved.push(resolveTarget(target, container))
1180
+ resolved.push(...resolveTarget(target, container, platform))
722
1181
  } catch (err: any) {
723
1182
  if (err instanceof DescribeError) {
724
1183
  console.error(err.message)
@@ -737,11 +1196,15 @@ export default async function describe(options: z.infer<typeof argsSchema>, cont
737
1196
  case 'container':
738
1197
  return getContainerData(container, sections, noTitle, headingDepth)
739
1198
  case 'registry':
740
- return getRegistryData(container, item.name, sections, noTitle, headingDepth)
1199
+ return getRegistryData(container, item.name, sections, noTitle, headingDepth, platform)
741
1200
  case 'helper':
742
1201
  return getHelperData(container, item.registry, item.id, sections, noTitle, headingDepth)
743
1202
  case 'member':
744
1203
  return getMemberData(container, item.registry, item.id, item.member, item.memberType, headingDepth)
1204
+ case 'browser-helper':
1205
+ return getBrowserHelperData(item.id, sections, noTitle, headingDepth)
1206
+ case 'browser-member':
1207
+ return getBrowserMemberData(item.id, item.member, item.memberType, headingDepth)
745
1208
  }
746
1209
  }
747
1210