@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.
- package/package.json +1 -1
- package/src/agi/features/assistant.ts +14 -12
- package/src/agi/features/docs-reader.ts +25 -1
- package/src/bootstrap/generated.ts +1 -1
- package/src/cli/build-info.ts +2 -2
- package/src/command.ts +75 -0
- package/src/commands/describe.ts +29 -1089
- package/src/container-describer.ts +1098 -0
- package/src/container.ts +11 -0
- package/src/introspection/generated.agi.ts +513 -429
- package/src/introspection/generated.node.ts +503 -419
- package/src/introspection/generated.web.ts +9 -1
- package/src/node/features/content-db.ts +17 -0
- package/src/node/features/fs.ts +18 -0
- package/src/scaffolds/generated.ts +1 -1
- package/src/server.ts +40 -0
- package/src/servers/express.ts +2 -0
- package/src/servers/mcp.ts +1 -0
- package/src/servers/socket.ts +2 -0
package/src/commands/describe.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
103
|
-
* then
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
|