@soederpop/luca 0.0.25 → 0.0.26

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,10 +2,11 @@ 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, HelperIntrospection } from '../introspection/index.js'
5
+ import type { HelperIntrospection } from '../introspection/index.js'
6
6
  import { __INTROSPECTION__, __BROWSER_INTROSPECTION__ } from '../introspection/index.js'
7
7
  import { features } from '../feature.js'
8
- import { presentIntrospectionAsMarkdown } from '../helper.js'
8
+ import { ContainerDescriber } from '../container-describer.js'
9
+ import type { BrowserFeatureData } from '../container-describer.js'
9
10
 
10
11
  declare module '../command.js' {
11
12
  interface AvailableCommands {
@@ -13,33 +14,6 @@ declare module '../command.js' {
13
14
  }
14
15
  }
15
16
 
16
- const REGISTRY_NAMES = ['features', 'clients', 'servers', 'commands', 'endpoints', 'selectors'] as const
17
- type RegistryName = (typeof REGISTRY_NAMES)[number]
18
-
19
- /** Maps flag names to the section they represent. 'description' is handled specially. */
20
- const SECTION_FLAGS: Record<string, IntrospectionSection | 'description'> = {
21
- // Clean flag names (combinable)
22
- 'description': 'description',
23
- 'usage': 'usage',
24
- 'methods': 'methods',
25
- 'getters': 'getters',
26
- 'events': 'events',
27
- 'state': 'state',
28
- 'options': 'options',
29
- 'env-vars': 'envVars',
30
- 'envvars': 'envVars',
31
- 'examples': 'examples',
32
- // Legacy --only-* flags (still work, map into same system)
33
- 'only-methods': 'methods',
34
- 'only-getters': 'getters',
35
- 'only-events': 'events',
36
- 'only-state': 'state',
37
- 'only-options': 'options',
38
- 'only-env-vars': 'envVars',
39
- 'only-envvars': 'envVars',
40
- 'only-examples': 'examples',
41
- }
42
-
43
17
  export const argsSchema = CommandOptionsSchema.extend({
44
18
  json: z.boolean().default(false).describe('Output introspection data as JSON instead of markdown'),
45
19
  pretty: z.boolean().default(false).describe('Render markdown with terminal styling via ui.markdown'),
@@ -67,45 +41,21 @@ export const argsSchema = CommandOptionsSchema.extend({
67
41
  platform: z.enum(['browser', 'web', 'server', 'node', 'all']).default('all').describe('Which platform features to show: browser/web, server/node, or all'),
68
42
  })
69
43
 
70
- type Platform = 'browser' | 'server' | 'node' | 'all'
71
-
72
- type ResolvedTarget =
73
- | { kind: 'container' }
74
- | { kind: 'registry'; name: RegistryName }
75
- | { kind: 'helper'; registry: RegistryName; id: string }
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' }
79
-
80
- class DescribeError extends Error {
81
- constructor(message: string) {
82
- super(message)
83
- this.name = 'DescribeError'
84
- }
85
- }
86
-
87
- // --- Browser feature loading ---
44
+ // --- Browser feature loading (build-time concern, lives here not in ContainerDescriber) ---
88
45
 
89
46
  const WEB_FEATURE_IDS = ['speech', 'voice', 'assetLoader', 'network', 'vault', 'vm', 'esbuild', 'helpers', 'containerLink']
90
47
 
91
- type BrowserFeatureData = {
92
- introspection: Map<string, HelperIntrospection>
93
- constructors: Map<string, any>
94
- available: string[]
95
- collidingIds: Set<string>
96
- }
97
-
98
48
  let _browserData: BrowserFeatureData | null = null
99
49
 
100
50
  /**
101
51
  * 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.
52
+ * This is a build-time hack: we import web feature classes into the node process,
53
+ * snapshot their introspection, then restore the node registry.
54
+ * The ContainerDescriber itself knows nothing about this.
104
55
  */
105
56
  async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
106
57
  if (_browserData) return _browserData
107
58
 
108
- // Snapshot current node state
109
59
  const nodeFeatureIds = new Set(features.available)
110
60
  const nodeIntrospection = new Map<string, HelperIntrospection>()
111
61
  const nodeConstructors = new Map<string, any>()
@@ -116,10 +66,8 @@ async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
116
66
  try { nodeConstructors.set(id, features.lookup(id)) } catch {}
117
67
  }
118
68
 
119
- // Import generated web build-time data (descriptions, methods, getters from AST)
120
69
  await import('../introspection/generated.web.js')
121
70
 
122
- // Import web feature class files (triggers Feature.register → interceptRegistration → Zod data)
123
71
  await Promise.all([
124
72
  import('../web/features/speech.js'),
125
73
  import('../web/features/voice-recognition.js'),
@@ -132,7 +80,6 @@ async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
132
80
  import('../web/features/container-link.js'),
133
81
  ])
134
82
 
135
- // Capture browser introspection data and constructors
136
83
  const browserIntrospection = new Map<string, HelperIntrospection>()
137
84
  const browserConstructors = new Map<string, any>()
138
85
  const collidingIds = new Set<string>()
@@ -145,28 +92,20 @@ async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
145
92
  if (nodeFeatureIds.has(id)) collidingIds.add(id)
146
93
  }
147
94
 
148
- // Restore node registry: re-register all node constructors
149
95
  for (const [id, ctor] of nodeConstructors) {
150
96
  features.register(id, ctor)
151
97
  }
152
98
 
153
- // Fully restore node introspection (overwrite whatever interceptRegistration did during re-register)
154
99
  for (const [key, data] of nodeIntrospection) {
155
100
  __INTROSPECTION__.set(key, data)
156
101
  }
157
102
 
158
- // Clean up: remove web-only entries from __INTROSPECTION__ and the registry
159
103
  for (const id of WEB_FEATURE_IDS) {
160
104
  const key = `features.${id}`
161
- if (!nodeIntrospection.has(key)) {
162
- __INTROSPECTION__.delete(key)
163
- }
164
- if (!nodeFeatureIds.has(id)) {
165
- features.unregister(id)
166
- }
105
+ if (!nodeIntrospection.has(key)) __INTROSPECTION__.delete(key)
106
+ if (!nodeFeatureIds.has(id)) features.unregister(id)
167
107
  }
168
108
 
169
- // Store in __BROWSER_INTROSPECTION__ for other potential consumers
170
109
  for (const [key, data] of browserIntrospection) {
171
110
  __BROWSER_INTROSPECTION__.set(key, data)
172
111
  }
@@ -181,988 +120,18 @@ async function loadBrowserFeatures(): Promise<BrowserFeatureData> {
181
120
  return _browserData
182
121
  }
183
122
 
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
-
384
- /**
385
- * Extract a short summary from a potentially long description string.
386
- * Takes text up to the first markdown heading, bullet list, or code block,
387
- * capped at ~300 chars on a sentence boundary.
388
- */
389
- function extractSummary(description: string): string {
390
- // Strip from the first markdown heading/bullet/code block onward
391
- const cut = description.search(/\s\*\*[A-Z][\w\s]+:\*\*|```|^\s*[-*]\s/m)
392
- const text = cut > 0 ? description.slice(0, cut).trim() : description
393
-
394
- if (text.length <= 300) return text
395
-
396
- // Truncate on sentence boundary
397
- const sentenceEnd = text.lastIndexOf('. ', 300)
398
- if (sentenceEnd > 100) return text.slice(0, sentenceEnd + 1)
399
- return text.slice(0, 300).trim() + '...'
400
- }
401
-
402
- /**
403
- * Normalize an identifier to a comparable form by stripping file extensions,
404
- * converting kebab-case and snake_case to lowercase-no-separators.
405
- * e.g. "disk-cache.ts" | "diskCache" | "disk_cache" → "diskcache"
406
- */
407
- function normalize(name: string): string {
408
- return name
409
- .replace(/\.[tj]sx?$/, '') // strip .ts/.js/.tsx/.jsx
410
- .replace(/[-_]/g, '') // remove dashes and underscores
411
- .toLowerCase()
412
- }
413
-
414
- /**
415
- * Find a registry entry by normalized name.
416
- * Returns the canonical registered id, or undefined if no match.
417
- */
418
- function fuzzyFind(registry: any, input: string): string | undefined {
419
- // Exact match first
420
- if (registry.has(input)) return input
421
-
422
- const norm = normalize(input)
423
- return (registry.available as string[]).find((id: string) => normalize(id) === norm)
424
- }
425
-
426
- /**
427
- * Try to resolve "helperName.memberName" by searching all registries for the helper,
428
- * then checking if memberName is a method or getter on it.
429
- * Returns a 'member' target or null if no match.
430
- */
431
- function resolveHelperMember(helperName: string, memberName: string, container: any): ResolvedTarget | null {
432
- for (const registryName of REGISTRY_NAMES) {
433
- const reg = container[registryName]
434
- if (!reg) continue
435
- const found = fuzzyFind(reg, helperName)
436
- if (!found) continue
437
-
438
- const Ctor = reg.lookup(found)
439
- const introspection = Ctor.introspect?.()
440
- if (!introspection) continue
441
-
442
- if (introspection.methods?.[memberName]) {
443
- return { kind: 'member', registry: registryName, id: found, member: memberName, memberType: 'method' }
444
- }
445
- if (introspection.getters?.[memberName]) {
446
- return { kind: 'member', registry: registryName, id: found, member: memberName, memberType: 'getter' }
447
- }
448
-
449
- // If we found the helper but not the member, give a helpful error
450
- const allMembers = [
451
- ...Object.keys(introspection.methods || {}).map((m: string) => m + '()'),
452
- ...Object.keys(introspection.getters || {}),
453
- ].sort()
454
- throw new DescribeError(
455
- `"${memberName}" is not a known method or getter on ${found}.\n\nAvailable members:\n ${allMembers.join(', ')}`
456
- )
457
- }
458
- return null
459
- }
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
-
493
- /**
494
- * Parse a single target string into a resolved target.
495
- * Accepts: "container", "features", "features.fs", "fs", "ui.banner", etc.
496
- */
497
- function resolveTarget(target: string, container: any, platform: Platform): ResolvedTarget[] {
498
- const lower = target.toLowerCase()
499
- const includeNode = shouldIncludeNode(platform)
500
- const includeBrowser = shouldIncludeBrowser(platform)
501
-
502
- // "container" or "self"
503
- if (lower === 'container' || lower === 'self') {
504
- return [{ kind: 'container' }]
505
- }
506
-
507
- // Registry name: "features", "clients", "servers", "commands", "endpoints"
508
- const registryMatch = REGISTRY_NAMES.find(
509
- (r) => r === lower || r === lower + 's' || r.replace(/s$/, '') === lower
510
- )
511
- if (registryMatch && !target.includes('.')) {
512
- return [{ kind: 'registry', name: registryMatch }]
513
- }
514
-
515
- // Qualified name: "features.fs", "clients.rest", etc.
516
- if (target.includes('.')) {
517
- const [prefix, ...rest] = target.split('.')
518
- const id = rest.join('.')
519
- const registry = REGISTRY_NAMES.find(
520
- (r) => r === prefix!.toLowerCase() || r === prefix!.toLowerCase() + 's' || r.replace(/s$/, '') === prefix!.toLowerCase()
521
- )
522
-
523
- if (registry) {
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 })
535
- }
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
546
- }
547
-
548
- // Not a registry prefix — try "helper.member" (e.g. "ui.banner", "fs.readFile")
549
- const helperName = prefix!
550
- const memberName = rest.join('.')
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
568
- }
569
-
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 })
589
- }
590
- }
591
-
592
- if (matches.length === 0) {
593
- const lines = [`"${target}" was not found in any registry.`, '', 'Available:']
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
- }
600
- }
601
- }
602
- if (includeBrowser && _browserData && _browserData.available.length > 0) {
603
- lines.push(` browser features: ${_browserData.available.join(', ')}`)
604
- }
605
- throw new DescribeError(lines.join('\n'))
606
- }
607
-
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) {
612
- const lines = [`"${target}" is ambiguous — found in multiple registries:`]
613
- for (const m of nodeMatches) {
614
- if (m.kind === 'helper') lines.push(` ${m.registry}.${m.id}`)
615
- }
616
- lines.push('', `Please qualify it, e.g.: ${(nodeMatches[0] as any).registry}.${target}`)
617
- throw new DescribeError(lines.join('\n'))
618
- }
619
-
620
- return matches
621
- }
622
-
623
- /** Collect all requested sections from flags. Empty array = show everything. */
624
- function getSections(options: z.infer<typeof argsSchema>): (IntrospectionSection | 'description')[] {
625
- const sections: (IntrospectionSection | 'description')[] = []
626
- for (const [flag, section] of Object.entries(SECTION_FLAGS)) {
627
- if ((options as any)[flag] && !sections.includes(section)) {
628
- sections.push(section)
629
- }
630
- }
631
- return sections
632
- }
633
-
634
- /**
635
- * Build the title header for a helper. Includes className when available.
636
- * headingDepth controls the markdown heading level (1 = #, 2 = ##, etc.)
637
- */
638
- function renderTitle(Ctor: any, headingDepth = 1): string {
639
- const data = Ctor.introspect?.()
640
- const id = data?.id || Ctor.shortcut || Ctor.name
641
- const className = data?.className || Ctor.name
642
- const h = '#'.repeat(headingDepth)
643
- return className ? `${h} ${className} (${id})` : `${h} ${id}`
644
- }
645
-
646
- /**
647
- * Render text output for a helper given requested sections.
648
- * When sections is empty, renders everything. When sections are specified,
649
- * renders only those sections (calling introspectAsText per section and concatenating).
650
- * 'description' is handled specially as the description paragraph (title is always included).
651
- * Pass noTitle to suppress the title header.
652
- * headingDepth controls the starting heading level (1 = #, 2 = ##, etc.)
653
- */
654
- function renderHelperText(Ctor: any, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): string {
655
- if (sections.length === 0) {
656
- if (noTitle) {
657
- // Render everything except the title
658
- const data = Ctor.introspect?.()
659
- if (!data) return 'No introspection data available.'
660
- const parts: string[] = [data.description]
661
- const text = Ctor.introspectAsText?.(headingDepth)
662
- if (text) {
663
- // Strip the first heading + description block that introspectAsText renders
664
- const lines = text.split('\n')
665
- const headingPrefix = '#'.repeat(headingDepth + 1) + ' '
666
- let startIdx = 0
667
- for (let i = 0; i < lines.length; i++) {
668
- if (i > 0 && lines[i]!.startsWith(headingPrefix)) {
669
- startIdx = i
670
- break
671
- }
672
- }
673
- if (startIdx > 0) {
674
- parts.length = 0
675
- parts.push(data.description)
676
- parts.push(lines.slice(startIdx).join('\n'))
677
- }
678
- }
679
- return parts.join('\n\n')
680
- }
681
- return Ctor.introspectAsText?.(headingDepth) ?? `${renderTitle(Ctor, headingDepth)}\n\nNo introspection data available.`
682
- }
683
-
684
- const introspectionSections = sections.filter((s): s is IntrospectionSection => s !== 'description')
685
- const parts: string[] = []
686
-
687
- // Always include the title and description unless noTitle
688
- if (!noTitle) {
689
- const data = Ctor.introspect?.()
690
- parts.push(renderTitle(Ctor, headingDepth))
691
- if (data?.description) {
692
- parts.push(data.description)
693
- }
694
- }
695
-
696
- for (const section of introspectionSections) {
697
- const text = Ctor.introspectAsText?.(section, headingDepth)
698
- if (text) parts.push(text)
699
- }
700
-
701
- return parts.join('\n\n') || `${noTitle ? '' : renderTitle(Ctor, headingDepth) + '\n\n'}No introspection data available.`
702
- }
703
-
704
- function renderHelperJson(Ctor: any, sections: (IntrospectionSection | 'description')[], noTitle = false): any {
705
- if (sections.length === 0) {
706
- return Ctor.introspect?.() ?? {}
707
- }
708
-
709
- const data = Ctor.introspect?.() ?? {}
710
- const result: Record<string, any> = {}
711
-
712
- // Always include id and className in JSON unless noTitle
713
- if (!noTitle) {
714
- result.id = data.id
715
- if (data.className) result.className = data.className
716
- }
717
-
718
- for (const section of sections) {
719
- if (section === 'description') {
720
- result.id = data.id
721
- if (data.className) result.className = data.className
722
- result.description = data.description
723
- } else if (section === 'usage') {
724
- // Usage is a derived section — include shortcut and options as its JSON form
725
- result.usage = { shortcut: data.shortcut, options: data.options }
726
- } else {
727
- const sectionData = Ctor.introspect?.(section)
728
- if (sectionData) {
729
- result[section] = sectionData[section]
730
- }
731
- }
732
- }
733
-
734
- return result
735
- }
736
-
737
- function getContainerData(container: any, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): { json: any; text: string } {
738
- if (sections.length === 0) {
739
- const data = container.inspect()
740
- return { json: data, text: container.inspectAsText(undefined, headingDepth) }
741
- }
742
-
743
- const data = container.inspect()
744
- const introspectionSections = sections.filter((s): s is IntrospectionSection => s !== 'description')
745
- const textParts: string[] = []
746
- const jsonResult: Record<string, any> = {}
747
- const h = '#'.repeat(headingDepth)
748
-
749
- // Always include container title and description unless noTitle
750
- if (!noTitle) {
751
- const className = data.className || 'Container'
752
- textParts.push(`${h} ${className} (Container)`)
753
- jsonResult.className = className
754
- if (data.description) {
755
- textParts.push(data.description)
756
- jsonResult.description = data.description
757
- }
758
- }
759
-
760
- for (const section of introspectionSections) {
761
- textParts.push(container.inspectAsText(section, headingDepth))
762
- jsonResult[section] = data[section]
763
- }
764
-
765
- return {
766
- json: jsonResult,
767
- text: textParts.join('\n\n'),
768
- }
769
- }
770
-
771
- /**
772
- * Walk the prototype chain of a base class to collect shared methods and getters.
773
- * These are the methods/getters inherited by all helpers in a registry.
774
- */
775
- function collectSharedMembers(baseClass: any): { methods: string[]; getters: string[] } {
776
- const methods: string[] = []
777
- const getters: string[] = []
778
-
779
- let proto = baseClass?.prototype
780
- while (proto && proto.constructor.name !== 'Object') {
781
- for (const k of Object.getOwnPropertyNames(proto)) {
782
- if (k === 'constructor' || k.startsWith('_')) continue
783
- const desc = Object.getOwnPropertyDescriptor(proto, k)
784
- if (!desc) continue
785
- if (desc.get && !getters.includes(k)) getters.push(k)
786
- else if (typeof desc.value === 'function' && !methods.includes(k)) methods.push(k)
787
- }
788
- proto = Object.getPrototypeOf(proto)
789
- }
790
-
791
- return { methods: methods.sort(), getters: getters.sort() }
792
- }
793
-
794
- /**
795
- * Find the intermediate parent class between a helper constructor and the registry's
796
- * base class (e.g. RestClient between ElevenLabsClient and Client).
797
- * Returns the intermediate class constructor, or null if the helper extends the base directly.
798
- */
799
- function findIntermediateParent(Ctor: any, baseClass: any): { name: string; methods: string[]; getters: string[] } | null {
800
- if (!baseClass) return null
801
-
802
- // Walk up from Ctor's parent to find the chain
803
- let parent = Object.getPrototypeOf(Ctor)
804
- if (!parent || parent === baseClass) return null
805
-
806
- // Check if the parent itself is a direct child of baseClass (i.e., 2nd level)
807
- // We want to find the class between Ctor and baseClass
808
- const chain: any[] = []
809
- let current = parent
810
- while (current && current !== baseClass && current !== Function.prototype) {
811
- chain.push(current)
812
- current = Object.getPrototypeOf(current)
813
- }
814
-
815
- // If the chain is empty or parent IS the baseClass, no intermediate
816
- if (chain.length === 0 || current !== baseClass) return null
817
-
818
- // The first entry in the chain is the direct parent of Ctor — that's our intermediate
819
- const intermediate = chain[0]
820
- const methods: string[] = []
821
- const getters: string[] = []
822
-
823
- // Collect only the methods/getters defined directly on the intermediate class
824
- const proto = intermediate?.prototype
825
- if (proto) {
826
- for (const k of Object.getOwnPropertyNames(proto)) {
827
- if (k === 'constructor' || k.startsWith('_')) continue
828
- const desc = Object.getOwnPropertyDescriptor(proto, k)
829
- if (!desc) continue
830
- if (desc.get && !getters.includes(k)) getters.push(k)
831
- else if (typeof desc.value === 'function' && !methods.includes(k)) methods.push(k)
832
- }
833
- }
834
-
835
- return {
836
- name: intermediate.name,
837
- methods: methods.sort(),
838
- getters: getters.sort(),
839
- }
840
- }
841
-
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
-
846
- const registry = container[registryName]
847
- const nodeAvailable: string[] = includeNode ? registry.available : []
848
- const browserAvailable: string[] = includeBrowser ? _browserData!.available : []
849
-
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) {
856
- return { json: {}, text: `No ${registryName} are registered.` }
857
- }
858
-
859
- // When no section filters are specified, render a concise index
860
- if (sections.length === 0) {
861
- const h = '#'.repeat(headingDepth)
862
- const hSub = '#'.repeat(headingDepth + 1)
863
- const jsonResult: Record<string, any> = {}
864
- const textParts: string[] = [`${h} Available ${registryName} (${totalCount})\n`]
865
-
866
- // Show shared methods/getters from the base class at the top
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 }
884
- }
885
- }
886
-
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
- }
932
- }
933
-
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
- }
955
-
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()
961
-
962
- const platformTag = !includeNode ? '' : ' (browser)'
963
- jsonResult[id] = { description: summary, methods: featureMethods, getters: featureGetters, platform: 'browser' }
964
-
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}`)
970
- }
971
- }
972
-
973
- return { json: jsonResult, text: textParts.join('\n') }
974
- }
975
-
976
- // When specific sections are requested, render full detail for each helper
977
- const jsonResult: Record<string, any> = {}
978
- const textParts: string[] = []
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
- }
996
- }
997
-
998
- return { json: jsonResult, text: textParts.join('\n\n---\n\n') }
999
- }
1000
-
1001
- /** Known top-level helper base class names — anything above these is "shared" */
1002
- const BASE_CLASS_NAMES = new Set(['Helper', 'Feature', 'Client', 'Server'])
1003
-
1004
- /**
1005
- * Build a concise summary block for an individual helper listing its interface at a glance.
1006
- * Shows extends line if there's an intermediate parent, then own methods/getters,
1007
- * then inherited methods/getters from the intermediate parent.
1008
- */
1009
- function buildHelperSummary(Ctor: any): string {
1010
- const introspection = Ctor.introspect?.()
1011
- const ownMethods = Object.keys(introspection?.methods || {}).sort()
1012
- const ownGetters = Object.keys(introspection?.getters || {}).sort()
1013
-
1014
- // Walk up the prototype chain to find an intermediate parent
1015
- const chain: any[] = []
1016
- let current = Object.getPrototypeOf(Ctor)
1017
- while (current && current.name && !BASE_CLASS_NAMES.has(current.name) && current !== Function.prototype) {
1018
- chain.push(current)
1019
- current = Object.getPrototypeOf(current)
1020
- }
1021
-
1022
- const lines: string[] = []
1023
-
1024
- if (chain.length > 0) {
1025
- lines.push(`> extends ${chain[0].name}`)
1026
- }
1027
-
1028
- if (ownGetters.length) lines.push(`getters: ${ownGetters.join(', ')}`)
1029
- if (ownMethods.length) lines.push(`methods: ${ownMethods.map(m => m + '()').join(', ')}`)
1030
-
1031
- // Collect inherited members from intermediate parent(s)
1032
- for (const parent of chain) {
1033
- const parentIntrospection = parent.introspect?.()
1034
- const inheritedMethods = Object.keys(parentIntrospection?.methods || {}).sort()
1035
- const inheritedGetters = Object.keys(parentIntrospection?.getters || {}).sort()
1036
- if (inheritedGetters.length) lines.push(`inherited getters (${parent.name}): ${inheritedGetters.join(', ')}`)
1037
- if (inheritedMethods.length) lines.push(`inherited methods (${parent.name}): ${inheritedMethods.map(m => m + '()').join(', ')}`)
1038
- }
1039
-
1040
- return lines.join('\n')
1041
- }
1042
-
1043
- function getMemberData(container: any, registryName: RegistryName, id: string, member: string, memberType: 'method' | 'getter', headingDepth = 1): { json: any; text: string } {
1044
- const registry = container[registryName]
1045
- const Ctor = registry.lookup(id)
1046
- const introspection = Ctor.introspect?.()
1047
- const h = '#'.repeat(headingDepth)
1048
- const hSub = '#'.repeat(headingDepth + 1)
1049
-
1050
- if (memberType === 'method') {
1051
- const method = introspection?.methods?.[member] as MethodIntrospection | undefined
1052
- if (!method) return { json: {}, text: `No introspection data for ${id}.${member}()` }
1053
-
1054
- const parts: string[] = []
1055
- parts.push(`${h} ${id}.${member}()`)
1056
- parts.push(`> method on **${introspection.className || id}**`)
1057
-
1058
- if (method.description) parts.push(method.description)
1059
-
1060
- // Parameters
1061
- const paramEntries = Object.entries(method.parameters || {})
1062
- if (paramEntries.length > 0) {
1063
- const paramLines = [`${hSub} Parameters`, '']
1064
- for (const [name, info] of paramEntries) {
1065
- const req = (method.required || []).includes(name) ? ' *(required)*' : ''
1066
- paramLines.push(`- **${name}** \`${info.type}\`${req}${info.description ? ' — ' + info.description : ''}`)
1067
- // Nested properties (e.g. options objects)
1068
- if (info.properties) {
1069
- for (const [propName, propInfo] of Object.entries(info.properties)) {
1070
- paramLines.push(` - **${propName}** \`${propInfo.type}\`${propInfo.description ? ' — ' + propInfo.description : ''}`)
1071
- }
1072
- }
1073
- }
1074
- parts.push(paramLines.join('\n'))
1075
- }
1076
-
1077
- // Returns
1078
- if (method.returns && method.returns !== 'void') {
1079
- parts.push(`${hSub} Returns\n\n\`${method.returns}\``)
1080
- }
1081
-
1082
- // Examples
1083
- if (method.examples?.length) {
1084
- parts.push(`${hSub} Examples`)
1085
- for (const ex of method.examples) {
1086
- parts.push(`\`\`\`${ex.language || 'typescript'}\n${ex.code}\n\`\`\``)
1087
- }
1088
- }
1089
-
1090
- return { json: { [member]: method, _helper: id, _type: 'method' }, text: parts.join('\n\n') }
1091
- }
1092
-
1093
- // Getter
1094
- const getter = introspection?.getters?.[member] as GetterIntrospection | undefined
1095
- if (!getter) return { json: {}, text: `No introspection data for ${id}.${member}` }
1096
-
1097
- const parts: string[] = []
1098
- parts.push(`${h} ${id}.${member}`)
1099
- parts.push(`> getter on **${introspection.className || id}** — returns \`${getter.returns || 'unknown'}\``)
1100
-
1101
- if (getter.description) parts.push(getter.description)
1102
-
1103
- if (getter.examples?.length) {
1104
- parts.push(`${hSub} Examples`)
1105
- for (const ex of getter.examples) {
1106
- parts.push(`\`\`\`${ex.language || 'typescript'}\n${ex.code}\n\`\`\``)
1107
- }
1108
- }
1109
-
1110
- return { json: { [member]: getter, _helper: id, _type: 'getter' }, text: parts.join('\n\n') }
123
+ function shouldIncludeBrowser(platform: string): boolean {
124
+ return platform === 'browser' || platform === 'web' || platform === 'all'
1111
125
  }
1112
126
 
1113
- function getHelperData(container: any, registryName: RegistryName, id: string, sections: (IntrospectionSection | 'description')[], noTitle = false, headingDepth = 1): { json: any; text: string } {
1114
- const registry = container[registryName]
1115
- const Ctor = registry.lookup(id)
1116
- const text = renderHelperText(Ctor, sections, noTitle, headingDepth)
1117
-
1118
- // Inject summary after the title + description block for full (no-section) renders
1119
- let finalText = text
1120
- if (sections.length === 0 && !noTitle) {
1121
- const summary = buildHelperSummary(Ctor)
1122
- if (summary) {
1123
- // Find the first ## heading and insert the summary before it
1124
- const sectionHeading = '#'.repeat(headingDepth + 1) + ' '
1125
- const idx = text.indexOf('\n' + sectionHeading)
1126
- if (idx >= 0) {
1127
- finalText = text.slice(0, idx) + '\n\n' + summary + '\n' + text.slice(idx)
1128
- }
1129
- }
1130
- }
1131
-
1132
- return {
1133
- json: renderHelperJson(Ctor, sections, noTitle),
1134
- text: finalText,
1135
- }
1136
- }
127
+ // --- Command handler ---
1137
128
 
1138
129
  export default async function describe(options: z.infer<typeof argsSchema>, context: ContainerContext) {
1139
130
  const container = context.container as any
1140
-
1141
- await container.helpers.discoverAll()
1142
-
1143
- const platform = normalizePlatform(options.platform)
1144
-
1145
- // Load browser features if needed
1146
- if (shouldIncludeBrowser(platform)) {
1147
- await loadBrowserFeatures()
1148
- }
131
+ const describer = new ContainerDescriber(container)
1149
132
 
1150
133
  const args = container.argv._ as string[]
1151
- // args[0] is "describe", the rest are targets
1152
134
  const targets = args.slice(1)
1153
- const json = options.json
1154
- const pretty = options.pretty
1155
- const noTitle = !options.title
1156
- const sections = getSections(options)
1157
-
1158
- function output(text: string) {
1159
- if (pretty) {
1160
- const ui = container.feature('ui')
1161
- console.log(ui.markdown(text))
1162
- } else {
1163
- console.log(text)
1164
- }
1165
- }
1166
135
 
1167
136
  // No targets: show help screen
1168
137
  if (targets.length === 0) {
@@ -1173,56 +142,27 @@ export default async function describe(options: z.infer<typeof argsSchema>, cont
1173
142
  return
1174
143
  }
1175
144
 
1176
- const resolved: ResolvedTarget[] = []
1177
-
1178
- for (const target of targets) {
1179
- try {
1180
- resolved.push(...resolveTarget(target, container, platform))
1181
- } catch (err: any) {
1182
- if (err instanceof DescribeError) {
1183
- console.error(err.message)
1184
- return
1185
- }
1186
- throw err
1187
- }
145
+ // Build-time hack: load browser features into the describer if needed
146
+ if (shouldIncludeBrowser(options.platform)) {
147
+ const browserData = await loadBrowserFeatures()
148
+ describer.setBrowserData(browserData)
1188
149
  }
1189
150
 
1190
- // Multiple docs when there are multiple targets or any target is a registry
1191
- const isMulti = resolved.length > 1 || resolved.some((r) => r.kind === 'registry')
1192
- const headingDepth = isMulti ? 2 : 1
151
+ const sections = ContainerDescriber.getSectionsFromFlags(options)
1193
152
 
1194
- function getData(item: ResolvedTarget) {
1195
- switch (item.kind) {
1196
- case 'container':
1197
- return getContainerData(container, sections, noTitle, headingDepth)
1198
- case 'registry':
1199
- return getRegistryData(container, item.name, sections, noTitle, headingDepth, platform)
1200
- case 'helper':
1201
- return getHelperData(container, item.registry, item.id, sections, noTitle, headingDepth)
1202
- case 'member':
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)
1208
- }
1209
- }
153
+ const result = await describer.describe(targets, {
154
+ sections,
155
+ noTitle: !options.title,
156
+ platform: options.platform as any,
157
+ })
1210
158
 
1211
- if (json) {
1212
- if (resolved.length === 1) {
1213
- console.log(JSON.stringify(getData(resolved[0]!).json, null, 2))
1214
- } else {
1215
- const combined = resolved.map((item) => getData(item).json)
1216
- console.log(JSON.stringify(combined, null, 2))
1217
- }
159
+ if (options.json) {
160
+ console.log(JSON.stringify(result.json, null, 2))
161
+ } else if (options.pretty) {
162
+ const ui = container.feature('ui')
163
+ console.log(ui.markdown(result.text))
1218
164
  } else {
1219
- const parts = resolved.map((item) => getData(item).text)
1220
- const body = parts.join('\n\n---\n\n')
1221
- if (isMulti) {
1222
- output(`# Luca Helper Descriptions\n\nBelow you'll find documentation.\n\n${body}`)
1223
- } else {
1224
- output(body)
1225
- }
165
+ console.log(result.text)
1226
166
  }
1227
167
  }
1228
168