pulumi-package-docs 0.1.0

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.
Files changed (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/bin/cli.js +80 -0
  4. package/dist/client/assets/MemberDetail-CX9jiyic.js +1 -0
  5. package/dist/client/assets/index-CZRUHwIq.js +13 -0
  6. package/dist/client/assets/providers._name-DpU324kz.js +1 -0
  7. package/dist/client/assets/providers._name.functions._token-DXrP4mSE.js +1 -0
  8. package/dist/client/assets/providers._name.functions._token-bvc3vrG2.js +1 -0
  9. package/dist/client/assets/providers._name.index-CtKE1x7S.js +1 -0
  10. package/dist/client/assets/providers._name.index-tTXQzRr7.js +1 -0
  11. package/dist/client/assets/providers._name.resources._token--BZM8RBv.js +1 -0
  12. package/dist/client/assets/providers._name.resources._token-CbZkRkaE.js +1 -0
  13. package/dist/client/assets/routes-GAgiQcBX.js +1 -0
  14. package/dist/client/assets/styles-CWDprFhI.css +1 -0
  15. package/dist/client/assets/token-oQmZfAP3.js +1 -0
  16. package/dist/client/assets/useSelector-BIt42tGI.js +1 -0
  17. package/dist/server/assets/MemberDetail-BiBAI1vw.js +96 -0
  18. package/dist/server/assets/_tanstack-start-manifest_v-Cl1i6HDY.js +62 -0
  19. package/dist/server/assets/api-BV6lykkP.js +20 -0
  20. package/dist/server/assets/api-C55bS4R6.js +64 -0
  21. package/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
  22. package/dist/server/assets/providers._name-DF6sA3DG.js +6 -0
  23. package/dist/server/assets/providers._name.functions._token-C0A7Y90t.js +21 -0
  24. package/dist/server/assets/providers._name.functions._token-D8LsoIum.js +13 -0
  25. package/dist/server/assets/providers._name.index-BEwt-7U0.js +13 -0
  26. package/dist/server/assets/providers._name.index-BK_ZRd5l.js +97 -0
  27. package/dist/server/assets/providers._name.resources._token-CbvRkms4.js +22 -0
  28. package/dist/server/assets/providers._name.resources._token-DJPF8nMg.js +13 -0
  29. package/dist/server/assets/router-DZSocx9m.js +562 -0
  30. package/dist/server/assets/routes-B-G_iLTo.js +82 -0
  31. package/dist/server/assets/schema.server-B-VvU1qb.js +122 -0
  32. package/dist/server/assets/start-5Z2QO8AU.js +4 -0
  33. package/dist/server/server.js +2003 -0
  34. package/package.json +67 -0
  35. package/src/components/CodeExample.tsx +14 -0
  36. package/src/components/MemberDetail.tsx +89 -0
  37. package/src/components/PropertyTable.tsx +49 -0
  38. package/src/components/PulumiLogo.tsx +86 -0
  39. package/src/lib/pulumi/api.ts +77 -0
  40. package/src/lib/pulumi/description.test.ts +54 -0
  41. package/src/lib/pulumi/description.ts +35 -0
  42. package/src/lib/pulumi/discovery.server.test.ts +117 -0
  43. package/src/lib/pulumi/discovery.server.ts +77 -0
  44. package/src/lib/pulumi/examples.test.ts +97 -0
  45. package/src/lib/pulumi/examples.ts +55 -0
  46. package/src/lib/pulumi/format.test.ts +51 -0
  47. package/src/lib/pulumi/format.ts +11 -0
  48. package/src/lib/pulumi/mcp.server.ts +343 -0
  49. package/src/lib/pulumi/naming.test.ts +27 -0
  50. package/src/lib/pulumi/naming.ts +31 -0
  51. package/src/lib/pulumi/schema.server.ts +78 -0
  52. package/src/lib/pulumi/token.test.ts +21 -0
  53. package/src/lib/pulumi/token.ts +10 -0
  54. package/src/lib/pulumi/types.ts +62 -0
  55. package/src/routeTree.gen.ts +187 -0
  56. package/src/router.tsx +19 -0
  57. package/src/routes/__root.tsx +59 -0
  58. package/src/routes/index.tsx +78 -0
  59. package/src/routes/mcp.ts +30 -0
  60. package/src/routes/providers.$name.functions.$token.tsx +47 -0
  61. package/src/routes/providers.$name.index.tsx +129 -0
  62. package/src/routes/providers.$name.resources.$token.tsx +48 -0
  63. package/src/routes/providers.$name.tsx +5 -0
  64. package/src/styles.css +478 -0
  65. package/tsconfig.json +29 -0
@@ -0,0 +1,77 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+
4
+ import { parse } from 'yaml'
5
+
6
+ import type { LocalPackageRef, PulumiProject } from '@/lib/pulumi/types'
7
+
8
+ const PROJECT_FILE_NAMES = ['Pulumi.yaml', 'Pulumi.yml']
9
+
10
+ /**
11
+ * The directory of the Pulumi program to inspect. `bin/cli.js` sets this to the
12
+ * directory the user ran `npx pulumi-package-docs` from, since the dev/preview
13
+ * server itself runs with this package's own directory as its cwd.
14
+ */
15
+ export function getTargetDir(): string {
16
+ return process.env.PULUMI_LOCAL_DOCS_DIR || process.cwd()
17
+ }
18
+
19
+ function findProjectFile(startDir: string): string | null {
20
+ let dir = startDir
21
+ for (;;) {
22
+ for (const fileName of PROJECT_FILE_NAMES) {
23
+ const candidate = join(dir, fileName)
24
+ if (existsSync(candidate)) return candidate
25
+ }
26
+ const parent = dirname(dir)
27
+ if (parent === dir) return null
28
+ dir = parent
29
+ }
30
+ }
31
+
32
+ function toLocalPackageRefs(packages: unknown): LocalPackageRef[] {
33
+ if (!packages || typeof packages !== 'object') return []
34
+
35
+ return Object.entries(packages as Record<string, unknown>).map(
36
+ ([name, value]) => {
37
+ const entry = (value ?? {}) as Record<string, unknown>
38
+ const parameters = Array.isArray(entry.parameters)
39
+ ? entry.parameters.filter((p): p is string => typeof p === 'string')
40
+ : undefined
41
+
42
+ return {
43
+ name,
44
+ source: typeof entry.source === 'string' ? entry.source : name,
45
+ version: typeof entry.version === 'string' ? entry.version : undefined,
46
+ parameters,
47
+ }
48
+ },
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Walks up from `startDir` looking for a Pulumi.yaml/Pulumi.yml, then reads its
54
+ * `packages` section to find the local/parameterized providers used by the project.
55
+ */
56
+ export function loadPulumiProject(
57
+ startDir: string = getTargetDir(),
58
+ ): PulumiProject | null {
59
+ const projectFile = findProjectFile(startDir)
60
+ if (!projectFile) return null
61
+
62
+ const raw = readFileSync(projectFile, 'utf-8')
63
+ const doc = parse(raw) as Record<string, unknown> | null
64
+
65
+ return {
66
+ root: dirname(projectFile),
67
+ name: typeof doc?.name === 'string' ? doc.name : undefined,
68
+ runtime:
69
+ typeof doc?.runtime === 'string'
70
+ ? doc.runtime
71
+ : typeof (doc?.runtime as Record<string, unknown> | undefined)?.name ===
72
+ 'string'
73
+ ? ((doc!.runtime as Record<string, unknown>).name as string)
74
+ : undefined,
75
+ packages: toLocalPackageRefs(doc?.packages),
76
+ }
77
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ extractExamples,
5
+ pickExample,
6
+ stripExamples,
7
+ summarizeDescription,
8
+ } from '@/lib/pulumi/examples'
9
+
10
+ const DESCRIPTION = `Generates random bytes.
11
+
12
+ ## Example Usage
13
+
14
+ \`\`\`typescript
15
+ const bytes = new random.RandomBytes("bytes", {length: 32});
16
+ \`\`\`
17
+ \`\`\`python
18
+ bytes = random.RandomBytes("bytes", length=32)
19
+ \`\`\`
20
+ \`\`\`go
21
+ bytes, err := random.NewRandomBytes(ctx, "bytes", &random.RandomBytesArgs{Length: 32})
22
+ \`\`\`
23
+ `
24
+
25
+ describe('extractExamples', () => {
26
+ it('returns an empty list for no description', () => {
27
+ expect(extractExamples(undefined)).toEqual([])
28
+ })
29
+
30
+ it('returns an empty list when there are no fenced code blocks', () => {
31
+ expect(extractExamples('just some prose')).toEqual([])
32
+ })
33
+
34
+ it('extracts each labeled code block', () => {
35
+ expect(extractExamples(DESCRIPTION)).toEqual([
36
+ {
37
+ language: 'typescript',
38
+ code: 'const bytes = new random.RandomBytes("bytes", {length: 32});',
39
+ },
40
+ {
41
+ language: 'python',
42
+ code: 'bytes = random.RandomBytes("bytes", length=32)',
43
+ },
44
+ {
45
+ language: 'go',
46
+ code: 'bytes, err := random.NewRandomBytes(ctx, "bytes", &random.RandomBytesArgs{Length: 32})',
47
+ },
48
+ ])
49
+ })
50
+ })
51
+
52
+ describe('stripExamples', () => {
53
+ it('removes fenced code blocks, leaving the prose', () => {
54
+ expect(stripExamples(DESCRIPTION)).toBe(
55
+ 'Generates random bytes.\n\n## Example Usage',
56
+ )
57
+ })
58
+
59
+ it('returns undefined when nothing but code blocks remain', () => {
60
+ expect(stripExamples('```typescript\ncode\n```')).toBeUndefined()
61
+ })
62
+ })
63
+
64
+ describe('summarizeDescription', () => {
65
+ it('returns the first paragraph, examples excluded', () => {
66
+ expect(summarizeDescription(DESCRIPTION)).toBe('Generates random bytes.')
67
+ })
68
+
69
+ it('returns undefined for an empty description', () => {
70
+ expect(summarizeDescription(undefined)).toBeUndefined()
71
+ })
72
+ })
73
+
74
+ describe('pickExample', () => {
75
+ const examples = extractExamples(DESCRIPTION)
76
+
77
+ it('picks the example matching the runtime', () => {
78
+ expect(pickExample(examples, 'python')?.language).toBe('python')
79
+ expect(pickExample(examples, 'go')?.language).toBe('go')
80
+ })
81
+
82
+ it('maps nodejs to typescript', () => {
83
+ expect(pickExample(examples, 'nodejs')?.language).toBe('typescript')
84
+ })
85
+
86
+ it('falls back to the first example when the runtime has none', () => {
87
+ expect(pickExample(examples, 'dotnet')?.language).toBe('typescript')
88
+ })
89
+
90
+ it('falls back to the first example when no runtime is given', () => {
91
+ expect(pickExample(examples)?.language).toBe('typescript')
92
+ })
93
+
94
+ it('returns undefined for an empty example list', () => {
95
+ expect(pickExample([], 'python')).toBeUndefined()
96
+ })
97
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The Pulumi package schema has no dedicated "examples" field — usage
3
+ * examples live as fenced, language-tagged code blocks embedded directly in
4
+ * a resource/function's markdown `description` (e.g. a "## Example Usage"
5
+ * section with one ```typescript/```python/```go/... block per language).
6
+ */
7
+ export interface SchemaExample {
8
+ language: string
9
+ code: string
10
+ }
11
+
12
+ const CODE_FENCE_RE = /```([a-zA-Z0-9+#-]+)\n([\s\S]*?)```/g
13
+
14
+ export function extractExamples(text?: string): SchemaExample[] {
15
+ if (!text) return []
16
+ return [...text.matchAll(CODE_FENCE_RE)].map((match) => ({
17
+ language: match[1].toLowerCase(),
18
+ code: match[2].trimEnd(),
19
+ }))
20
+ }
21
+
22
+ /** The description text with any fenced code examples removed. */
23
+ export function stripExamples(text?: string): string | undefined {
24
+ if (!text) return text
25
+ const stripped = text.replace(CODE_FENCE_RE, '').trim()
26
+ return stripped || undefined
27
+ }
28
+
29
+ /** A short summary for list views: the first prose paragraph, examples excluded. */
30
+ export function summarizeDescription(text?: string): string | undefined {
31
+ const stripped = stripExamples(text)
32
+ if (!stripped) return undefined
33
+ return stripped.split(/\n{2,}/)[0]?.trim() || undefined
34
+ }
35
+
36
+ const RUNTIME_LANGUAGES: Record<string, string[]> = {
37
+ nodejs: ['typescript', 'javascript'],
38
+ python: ['python'],
39
+ go: ['go', 'golang'],
40
+ dotnet: ['csharp'],
41
+ java: ['java'],
42
+ yaml: ['yaml'],
43
+ }
44
+
45
+ /** Picks the example matching the project's runtime, falling back to the first available one. */
46
+ export function pickExample(
47
+ examples: SchemaExample[],
48
+ runtime?: string,
49
+ ): SchemaExample | undefined {
50
+ const languages = runtime ? RUNTIME_LANGUAGES[runtime] : undefined
51
+ const match = languages
52
+ ? examples.find((example) => languages.includes(example.language))
53
+ : undefined
54
+ return match ?? examples[0]
55
+ }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { formatType } from '@/lib/pulumi/format'
4
+
5
+ describe('formatType', () => {
6
+ it('returns "any" for an undefined property', () => {
7
+ expect(formatType(undefined)).toBe('any')
8
+ })
9
+
10
+ it('returns "any" for a property with neither a type nor a $ref', () => {
11
+ expect(formatType({})).toBe('any')
12
+ })
13
+
14
+ it('returns the plain type for a scalar property', () => {
15
+ expect(formatType({ type: 'string' })).toBe('string')
16
+ expect(formatType({ type: 'boolean' })).toBe('boolean')
17
+ })
18
+
19
+ it('shortens a $ref to its last path segment', () => {
20
+ expect(
21
+ formatType({ $ref: '#/types/random:index/randomString:RandomString' }),
22
+ ).toBe('randomString:RandomString')
23
+ })
24
+
25
+ it('url-decodes the shortened $ref', () => {
26
+ expect(
27
+ formatType({
28
+ $ref: '#/resources/pulumi:providers:random%2FterraformConfig',
29
+ }),
30
+ ).toBe('pulumi:providers:random/terraformConfig')
31
+ })
32
+
33
+ it('formats an array of a scalar type', () => {
34
+ expect(formatType({ type: 'array', items: { type: 'string' } })).toBe(
35
+ 'string[]',
36
+ )
37
+ })
38
+
39
+ it('formats an array of $ref items', () => {
40
+ expect(
41
+ formatType({
42
+ type: 'array',
43
+ items: { $ref: '#/types/random:index/randomString:RandomString' },
44
+ }),
45
+ ).toBe('randomString:RandomString[]')
46
+ })
47
+
48
+ it('falls back to "any" for an array with no items', () => {
49
+ expect(formatType({ type: 'array' })).toBe('array')
50
+ })
51
+ })
@@ -0,0 +1,11 @@
1
+ import type { SchemaProperty } from '@/lib/pulumi/types'
2
+
3
+ export function formatType(prop?: SchemaProperty): string {
4
+ if (!prop) return 'any'
5
+ if (prop.$ref) {
6
+ const short = prop.$ref.split('/').pop() ?? prop.$ref
7
+ return decodeURIComponent(short)
8
+ }
9
+ if (prop.type === 'array' && prop.items) return `${formatType(prop.items)}[]`
10
+ return prop.type ?? 'any'
11
+ }
@@ -0,0 +1,343 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
3
+ import { z } from 'zod'
4
+
5
+ import { getProvider, listProviders } from '@/lib/pulumi/api'
6
+ import type { ProviderDetail } from '@/lib/pulumi/api'
7
+ import { resolveDescription } from '@/lib/pulumi/description'
8
+ import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
9
+ import {
10
+ extractExamples,
11
+ pickExample,
12
+ stripExamples,
13
+ summarizeDescription,
14
+ } from '@/lib/pulumi/examples'
15
+ import { formatType } from '@/lib/pulumi/format'
16
+ import { formatPropertyName } from '@/lib/pulumi/naming'
17
+ import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
18
+ import { tokenDisplayName } from '@/lib/pulumi/token'
19
+ import type {
20
+ SchemaFunction,
21
+ SchemaProperty,
22
+ SchemaResource,
23
+ } from '@/lib/pulumi/types'
24
+
25
+ function textResult(data: unknown): CallToolResult {
26
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
27
+ }
28
+
29
+ /** Loads a local provider's resolved schema, throwing a descriptive error a tool call can surface to the client. */
30
+ async function requireProvider(
31
+ providerName: string,
32
+ ): Promise<ProviderDetail & { schema: NonNullable<ProviderDetail['schema']> }> {
33
+ const detail = await getProvider({ data: providerName })
34
+ if (!detail) {
35
+ throw new Error(
36
+ `No local provider named "${providerName}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
37
+ )
38
+ }
39
+ if (!detail.schema) {
40
+ throw new Error(
41
+ `Failed to load the schema for provider "${providerName}": ${detail.error ?? 'unknown error'}`,
42
+ )
43
+ }
44
+ return { ...detail, schema: detail.schema }
45
+ }
46
+
47
+ function summarizeMember(
48
+ token: string,
49
+ member: SchemaResource | SchemaFunction,
50
+ runtime?: string,
51
+ ) {
52
+ return {
53
+ token,
54
+ name: tokenDisplayName(token),
55
+ deprecated: Boolean(
56
+ 'deprecationMessage' in member && member.deprecationMessage,
57
+ ),
58
+ summary: summarizeDescription(
59
+ resolveDescription(member.description, runtime),
60
+ ),
61
+ }
62
+ }
63
+
64
+ function formatProperties(
65
+ properties: Record<string, SchemaProperty> | undefined,
66
+ required: string[] | undefined,
67
+ runtime: string | undefined,
68
+ ) {
69
+ const requiredSet = new Set(required ?? [])
70
+ return Object.entries(properties ?? {}).map(([name, prop]) => ({
71
+ name: formatPropertyName(name, runtime),
72
+ type: formatType(prop),
73
+ required: requiredSet.has(name),
74
+ description: resolveDescription(prop.description, runtime),
75
+ }))
76
+ }
77
+
78
+ function memberDetail(
79
+ provider: string,
80
+ token: string,
81
+ description: string | undefined,
82
+ runtime: string | undefined,
83
+ inputs: ReturnType<typeof formatProperties>,
84
+ outputs: ReturnType<typeof formatProperties>,
85
+ deprecationMessage?: string,
86
+ ) {
87
+ const resolved = resolveDescription(description, runtime)
88
+ const example = pickExample(extractExamples(resolved), runtime)
89
+
90
+ return {
91
+ provider,
92
+ token,
93
+ name: tokenDisplayName(token),
94
+ deprecationMessage,
95
+ description: stripExamples(resolved),
96
+ example: example ?? null,
97
+ inputs,
98
+ outputs,
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Builds a fresh MCP server with the local-provider-docs tools registered. Called once
104
+ * per HTTP request (see `src/routes/mcp.ts`) so concurrent requests never share state.
105
+ */
106
+ export function createMcpServer(): McpServer {
107
+ const server = new McpServer({
108
+ name: 'pulumi-package-docs',
109
+ version: '0.1.0',
110
+ })
111
+
112
+ server.registerTool(
113
+ 'list_providers',
114
+ {
115
+ title: 'List local providers',
116
+ description:
117
+ "List the local/parameterized Pulumi providers declared in this project's Pulumi.yaml (added with `pulumi package add`), with their source, version, and resource/function counts.",
118
+ },
119
+ async () => textResult(await listProviders()),
120
+ )
121
+
122
+ server.registerTool(
123
+ 'list_resources',
124
+ {
125
+ title: 'List a provider’s resources',
126
+ description:
127
+ 'List the resource tokens for a local provider, each with a display name, a one-line summary, and whether it is deprecated. Use get_resource for full details (inputs/outputs/example) on one of them.',
128
+ inputSchema: {
129
+ provider: z
130
+ .string()
131
+ .describe(
132
+ 'The provider name as it appears in Pulumi.yaml and list_providers, e.g. "random".',
133
+ ),
134
+ },
135
+ },
136
+ async ({ provider }) => {
137
+ const detail = await requireProvider(provider)
138
+ const resources = Object.entries(detail.schema.resources ?? {}).map(
139
+ ([token, resource]) => summarizeMember(token, resource, detail.runtime),
140
+ )
141
+ return textResult({
142
+ provider,
143
+ resourceCount: resources.length,
144
+ resources,
145
+ })
146
+ },
147
+ )
148
+
149
+ server.registerTool(
150
+ 'list_functions',
151
+ {
152
+ title: 'List a provider’s functions',
153
+ description:
154
+ 'List the function (data source) tokens for a local provider, each with a display name, a one-line summary, and whether it is deprecated. Use get_function for full details (inputs/outputs/example) on one of them.',
155
+ inputSchema: {
156
+ provider: z
157
+ .string()
158
+ .describe(
159
+ 'The provider name as it appears in Pulumi.yaml and list_providers, e.g. "random".',
160
+ ),
161
+ },
162
+ },
163
+ async ({ provider }) => {
164
+ const detail = await requireProvider(provider)
165
+ const functions = Object.entries(detail.schema.functions ?? {}).map(
166
+ ([token, fn]) => summarizeMember(token, fn, detail.runtime),
167
+ )
168
+ return textResult({
169
+ provider,
170
+ functionCount: functions.length,
171
+ functions,
172
+ })
173
+ },
174
+ )
175
+
176
+ server.registerTool(
177
+ 'get_resource',
178
+ {
179
+ title: 'Get resource details',
180
+ description:
181
+ 'Get the full documentation for one resource of a local provider: its description, a usage example (in the Pulumi project’s own language), and its input and output properties (name, type, required, description). Get the exact token from list_resources first.',
182
+ inputSchema: {
183
+ provider: z.string().describe('The provider name, e.g. "random".'),
184
+ token: z
185
+ .string()
186
+ .describe(
187
+ 'The resource’s full schema token from list_resources, e.g. "random:index/randomString:RandomString".',
188
+ ),
189
+ },
190
+ },
191
+ async ({ provider, token }) => {
192
+ const detail = await requireProvider(provider)
193
+ const resource = detail.schema.resources?.[token]
194
+ if (!resource) {
195
+ throw new Error(
196
+ `No resource with token "${token}" in provider "${provider}". Call list_resources to find the exact token.`,
197
+ )
198
+ }
199
+ return textResult(
200
+ memberDetail(
201
+ provider,
202
+ token,
203
+ resource.description,
204
+ detail.runtime,
205
+ formatProperties(
206
+ resource.inputProperties,
207
+ resource.requiredInputs,
208
+ detail.runtime,
209
+ ),
210
+ formatProperties(
211
+ resource.properties,
212
+ resource.required,
213
+ detail.runtime,
214
+ ),
215
+ resource.deprecationMessage,
216
+ ),
217
+ )
218
+ },
219
+ )
220
+
221
+ server.registerTool(
222
+ 'get_function',
223
+ {
224
+ title: 'Get function details',
225
+ description:
226
+ 'Get the full documentation for one function (data source) of a local provider: its description, a usage example (in the Pulumi project’s own language), and its input and output properties. Get the exact token from list_functions first.',
227
+ inputSchema: {
228
+ provider: z.string().describe('The provider name, e.g. "random".'),
229
+ token: z
230
+ .string()
231
+ .describe(
232
+ 'The function’s full schema token from list_functions, e.g. "random:index/randomInteger:getRandomInteger".',
233
+ ),
234
+ },
235
+ },
236
+ async ({ provider, token }) => {
237
+ const detail = await requireProvider(provider)
238
+ const fn = detail.schema.functions?.[token]
239
+ if (!fn) {
240
+ throw new Error(
241
+ `No function with token "${token}" in provider "${provider}". Call list_functions to find the exact token.`,
242
+ )
243
+ }
244
+ return textResult(
245
+ memberDetail(
246
+ provider,
247
+ token,
248
+ fn.description,
249
+ detail.runtime,
250
+ formatProperties(
251
+ fn.inputs?.properties,
252
+ fn.inputs?.required,
253
+ detail.runtime,
254
+ ),
255
+ formatProperties(
256
+ fn.outputs?.properties,
257
+ fn.outputs?.required,
258
+ detail.runtime,
259
+ ),
260
+ ),
261
+ )
262
+ },
263
+ )
264
+
265
+ server.registerTool(
266
+ 'search_members',
267
+ {
268
+ title: 'Search resources and functions',
269
+ description:
270
+ 'Search resource and function tokens/descriptions across one or all local providers for a keyword. Use this instead of list_resources/list_functions when a provider is large or you only know roughly what you’re looking for (e.g. "bucket", "random string").',
271
+ inputSchema: {
272
+ query: z.string().describe('Case-insensitive text to search for.'),
273
+ provider: z
274
+ .string()
275
+ .optional()
276
+ .describe(
277
+ 'Limit the search to this provider name. Omit to search all local providers.',
278
+ ),
279
+ },
280
+ },
281
+ async ({ query, provider }) => {
282
+ const targetDir = getTargetDir()
283
+ const project = loadPulumiProject(targetDir)
284
+ if (!project) return textResult({ query, matchCount: 0, matches: [] })
285
+
286
+ const refs = provider
287
+ ? project.packages.filter((p) => p.name === provider)
288
+ : project.packages
289
+ if (provider && refs.length === 0) {
290
+ throw new Error(
291
+ `No local provider named "${provider}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
292
+ )
293
+ }
294
+
295
+ const needle = query.toLowerCase()
296
+ const matches: Array<{
297
+ provider: string
298
+ kind: 'resource' | 'function'
299
+ token: string
300
+ name: string
301
+ summary?: string
302
+ }> = []
303
+
304
+ for (const ref of refs) {
305
+ const entry = await resolveProviderSchema(project.root, ref)
306
+ if (!entry.schema) continue
307
+
308
+ const groups: Array<
309
+ [
310
+ kind: 'resource' | 'function',
311
+ members:
312
+ | Record<string, SchemaResource | SchemaFunction>
313
+ | undefined,
314
+ ]
315
+ > = [
316
+ ['resource', entry.schema.resources],
317
+ ['function', entry.schema.functions],
318
+ ]
319
+
320
+ for (const [kind, members] of groups) {
321
+ for (const [token, member] of Object.entries(members ?? {})) {
322
+ const name = tokenDisplayName(token)
323
+ const summary = summarizeDescription(
324
+ resolveDescription(member.description, project.runtime),
325
+ )
326
+ const haystack = `${name} ${token} ${summary ?? ''}`.toLowerCase()
327
+ if (haystack.includes(needle)) {
328
+ matches.push({ provider: ref.name, kind, token, name, summary })
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ return textResult({
335
+ query,
336
+ matchCount: matches.length,
337
+ matches: matches.slice(0, 50),
338
+ })
339
+ },
340
+ )
341
+
342
+ return server
343
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { formatPropertyName } from '@/lib/pulumi/naming'
4
+
5
+ describe('formatPropertyName', () => {
6
+ it('leaves the name as-is when no runtime is given', () => {
7
+ expect(formatPropertyName('bucketName')).toBe('bucketName')
8
+ })
9
+
10
+ it('leaves the name as-is for nodejs, java, and yaml', () => {
11
+ expect(formatPropertyName('bucketName', 'nodejs')).toBe('bucketName')
12
+ expect(formatPropertyName('bucketName', 'java')).toBe('bucketName')
13
+ expect(formatPropertyName('bucketName', 'yaml')).toBe('bucketName')
14
+ })
15
+
16
+ it('converts to snake_case for python', () => {
17
+ expect(formatPropertyName('bucketName', 'python')).toBe('bucket_name')
18
+ expect(formatPropertyName('s3BucketName', 'python')).toBe('s3_bucket_name')
19
+ expect(formatPropertyName('id', 'python')).toBe('id')
20
+ })
21
+
22
+ it('converts to PascalCase for go and dotnet', () => {
23
+ expect(formatPropertyName('bucketName', 'go')).toBe('BucketName')
24
+ expect(formatPropertyName('bucketName', 'dotnet')).toBe('BucketName')
25
+ expect(formatPropertyName('s3BucketName', 'go')).toBe('S3BucketName')
26
+ })
27
+ })
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pulumi schema property names are always camelCase; each language's generated
3
+ * SDK renders them in that language's own convention. This approximates that
4
+ * per-runtime rendering for display purposes (it doesn't reproduce Pulumi's
5
+ * codegen exactly, e.g. its acronym handling, but matches the common case).
6
+ */
7
+ function toSnakeCase(name: string): string {
8
+ return name
9
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
10
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
11
+ .toLowerCase()
12
+ }
13
+
14
+ function toPascalCase(name: string): string {
15
+ if (name.length === 0) return name
16
+ return name.charAt(0).toUpperCase() + name.slice(1)
17
+ }
18
+
19
+ /** Renders a schema property name the way it would appear in the given Pulumi runtime's generated SDK. */
20
+ export function formatPropertyName(name: string, runtime?: string): string {
21
+ switch (runtime) {
22
+ case 'python':
23
+ return toSnakeCase(name)
24
+ case 'go':
25
+ case 'dotnet':
26
+ return toPascalCase(name)
27
+ // nodejs, java, and yaml all use the schema's camelCase names as-is.
28
+ default:
29
+ return name
30
+ }
31
+ }