ts-procedures 5.9.1 → 5.10.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 (80) hide show
  1. package/README.md +1 -1
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +46 -101
  3. package/agent_config/claude-code/skills/guide/SKILL.md +49 -34
  4. package/agent_config/claude-code/skills/guide/anti-patterns.md +6 -5
  5. package/agent_config/claude-code/skills/guide/api-reference.md +60 -49
  6. package/agent_config/claude-code/skills/review/SKILL.md +12 -17
  7. package/agent_config/claude-code/skills/scaffold/SKILL.md +18 -23
  8. package/agent_config/claude-code/skills/scaffold/templates/client.md +115 -0
  9. package/agent_config/lib/install-claude.mjs +22 -22
  10. package/docs/core.md +5 -9
  11. package/docs/streaming.md +9 -9
  12. package/package.json +2 -13
  13. package/src/client/call.test.ts +162 -0
  14. package/src/client/errors.test.ts +43 -0
  15. package/src/client/fetch-adapter.test.ts +340 -0
  16. package/src/client/hooks.test.ts +191 -0
  17. package/src/client/index.test.ts +290 -0
  18. package/src/client/request-builder.test.ts +184 -0
  19. package/src/client/stream.test.ts +331 -0
  20. package/src/codegen/bin/cli.test.ts +260 -0
  21. package/src/codegen/bin/cli.ts +282 -0
  22. package/src/codegen/constants.ts +1 -0
  23. package/src/codegen/e2e.test.ts +565 -0
  24. package/src/codegen/emit-client-runtime.test.ts +93 -0
  25. package/src/codegen/emit-client-runtime.ts +114 -0
  26. package/src/codegen/emit-client-types.test.ts +39 -0
  27. package/src/codegen/emit-client-types.ts +27 -0
  28. package/src/codegen/emit-errors.test.ts +202 -0
  29. package/src/codegen/emit-errors.ts +80 -0
  30. package/src/codegen/emit-index.test.ts +127 -0
  31. package/src/codegen/emit-index.ts +58 -0
  32. package/src/codegen/emit-scope.test.ts +624 -0
  33. package/src/codegen/emit-scope.ts +389 -0
  34. package/src/codegen/emit-types.test.ts +205 -0
  35. package/src/codegen/emit-types.ts +158 -0
  36. package/src/codegen/group-routes.test.ts +159 -0
  37. package/src/codegen/group-routes.ts +61 -0
  38. package/src/codegen/index.ts +30 -0
  39. package/src/codegen/naming.test.ts +50 -0
  40. package/src/codegen/naming.ts +25 -0
  41. package/src/codegen/pipeline.test.ts +316 -0
  42. package/src/codegen/pipeline.ts +108 -0
  43. package/src/codegen/resolve-envelope.test.ts +76 -0
  44. package/src/codegen/resolve-envelope.ts +61 -0
  45. package/src/errors.test.ts +163 -0
  46. package/src/errors.ts +107 -0
  47. package/src/exports.ts +7 -0
  48. package/src/implementations/http/doc-registry.test.ts +415 -0
  49. package/src/implementations/http/doc-registry.ts +143 -0
  50. package/src/implementations/http/express-rpc/README.md +6 -6
  51. package/src/implementations/http/express-rpc/index.test.ts +957 -0
  52. package/src/implementations/http/express-rpc/index.ts +266 -0
  53. package/src/implementations/http/express-rpc/types.ts +16 -0
  54. package/src/implementations/http/hono-api/index.test.ts +1341 -0
  55. package/src/implementations/http/hono-api/index.ts +463 -0
  56. package/src/implementations/http/hono-api/types.ts +16 -0
  57. package/src/implementations/http/hono-rpc/README.md +6 -6
  58. package/src/implementations/http/hono-rpc/index.test.ts +1075 -0
  59. package/src/implementations/http/hono-rpc/index.ts +238 -0
  60. package/src/implementations/http/hono-rpc/types.ts +16 -0
  61. package/src/implementations/http/hono-stream/README.md +12 -12
  62. package/src/implementations/http/hono-stream/index.test.ts +1768 -0
  63. package/src/implementations/http/hono-stream/index.ts +456 -0
  64. package/src/implementations/http/hono-stream/types.ts +20 -0
  65. package/src/implementations/types.ts +174 -0
  66. package/src/index.test.ts +1185 -0
  67. package/src/index.ts +522 -0
  68. package/src/schema/compute-schema.test.ts +128 -0
  69. package/src/schema/compute-schema.ts +88 -0
  70. package/src/schema/extract-json-schema.test.ts +25 -0
  71. package/src/schema/extract-json-schema.ts +15 -0
  72. package/src/schema/parser.test.ts +182 -0
  73. package/src/schema/parser.ts +215 -0
  74. package/src/schema/resolve-schema-lib.test.ts +19 -0
  75. package/src/schema/resolve-schema-lib.ts +29 -0
  76. package/src/schema/types.ts +20 -0
  77. package/src/stack-utils.test.ts +94 -0
  78. package/src/stack-utils.ts +129 -0
  79. package/docs/superpowers/plans/2026-03-30-client-codegen.md +0 -2833
  80. package/docs/superpowers/specs/2026-03-30-client-codegen-design.md +0 -632
@@ -0,0 +1,290 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createClient } from './index.js'
3
+ import { ClientRequestError } from './errors.js'
4
+ import type {
5
+ ClientAdapter,
6
+ AdapterRequest,
7
+ AdapterResponse,
8
+ AdapterStreamResponse,
9
+ ClientHooks,
10
+ CallDescriptor,
11
+ StreamDescriptor,
12
+ ClientInstance,
13
+ } from './types.js'
14
+
15
+ // ── helpers ───────────────────────────────────────────────
16
+
17
+ async function* makeAsyncIterable<T>(items: T[]): AsyncIterable<T> {
18
+ for (const item of items) {
19
+ yield item
20
+ }
21
+ }
22
+
23
+ function makeAdapter(
24
+ responseBody: unknown = { ok: true },
25
+ streamItems: unknown[] = []
26
+ ): ClientAdapter {
27
+ return {
28
+ request: vi.fn(async (_req: AdapterRequest): Promise<AdapterResponse> => ({
29
+ status: 200,
30
+ headers: {},
31
+ body: responseBody,
32
+ })),
33
+ stream: vi.fn(async (_req: AdapterRequest): Promise<AdapterStreamResponse> => ({
34
+ status: 200,
35
+ headers: {},
36
+ body: makeAsyncIterable(streamItems),
37
+ })),
38
+ }
39
+ }
40
+
41
+ function makeCallDescriptor(overrides?: Partial<CallDescriptor>): CallDescriptor {
42
+ return {
43
+ name: 'GetUser',
44
+ scope: 'users',
45
+ path: '/users',
46
+ method: 'GET',
47
+ kind: 'rpc',
48
+ params: { id: '1' },
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ function makeStreamDescriptor(overrides?: Partial<StreamDescriptor>): StreamDescriptor {
54
+ return {
55
+ name: 'WatchUsers',
56
+ scope: 'users',
57
+ path: '/stream/users',
58
+ method: 'POST',
59
+ kind: 'stream',
60
+ streamMode: 'sse',
61
+ params: {},
62
+ ...overrides,
63
+ }
64
+ }
65
+
66
+ // ── createClient ──────────────────────────────────────────
67
+
68
+ describe('createClient', () => {
69
+ it('creates a client with typed scope callables', () => {
70
+ const adapter = makeAdapter()
71
+
72
+ const client = createClient({
73
+ adapter,
74
+ basePath: 'https://api.example.com',
75
+ scopes: (instance: ClientInstance) => ({
76
+ users: {
77
+ getUser: (id: string) =>
78
+ instance.call<{ id: string; name: string }>(
79
+ makeCallDescriptor({ params: { id } }),
80
+ ),
81
+ },
82
+ }),
83
+ })
84
+
85
+ expect(client).toHaveProperty('users')
86
+ expect(client.users).toHaveProperty('getUser')
87
+ expect(typeof client.users.getUser).toBe('function')
88
+ })
89
+
90
+ it('executes procedure calls through the adapter', async () => {
91
+ const adapter = makeAdapter({ id: '1', name: 'Alice' })
92
+
93
+ const client = createClient({
94
+ adapter,
95
+ basePath: 'https://api.example.com',
96
+ scopes: (instance: ClientInstance) => ({
97
+ users: {
98
+ getUser: (id: string) =>
99
+ instance.call<{ id: string; name: string }>(
100
+ makeCallDescriptor({ params: { id } }),
101
+ ),
102
+ },
103
+ }),
104
+ })
105
+
106
+ const result = await client.users.getUser('1')
107
+ expect(adapter.request).toHaveBeenCalledOnce()
108
+ expect(result).toEqual({ id: '1', name: 'Alice' })
109
+ })
110
+
111
+ it('throws ClientRequestError on non-2xx from scope callable', async () => {
112
+ const adapter: ClientAdapter = {
113
+ request: vi.fn(async (): Promise<AdapterResponse> => ({
114
+ status: 404,
115
+ headers: {},
116
+ body: { message: 'Not Found' },
117
+ })),
118
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
119
+ status: 200,
120
+ headers: {},
121
+ body: makeAsyncIterable([]),
122
+ })),
123
+ }
124
+
125
+ const client = createClient({
126
+ adapter,
127
+ basePath: 'https://api.example.com',
128
+ scopes: (instance: ClientInstance) => ({
129
+ users: {
130
+ getUser: (id: string) =>
131
+ instance.call<unknown>(makeCallDescriptor({ params: { id } })),
132
+ },
133
+ }),
134
+ })
135
+
136
+ await expect(client.users.getUser('99')).rejects.toThrow(ClientRequestError)
137
+ })
138
+
139
+ it('applies global hooks to all calls', async () => {
140
+ const capturedHeaders: Record<string, string>[] = []
141
+ const adapter: ClientAdapter = {
142
+ request: vi.fn(async (req: AdapterRequest): Promise<AdapterResponse> => {
143
+ capturedHeaders.push(req.headers ?? {})
144
+ return { status: 200, headers: {}, body: {} }
145
+ }),
146
+ stream: vi.fn(async (): Promise<AdapterStreamResponse> => ({
147
+ status: 200,
148
+ headers: {},
149
+ body: makeAsyncIterable([]),
150
+ })),
151
+ }
152
+
153
+ const globalHooks: ClientHooks = {
154
+ onBeforeRequest: (ctx) => ({
155
+ ...ctx,
156
+ request: { ...ctx.request, headers: { 'x-api-key': 'secret' } },
157
+ }),
158
+ }
159
+
160
+ const client = createClient({
161
+ adapter,
162
+ basePath: 'https://api.example.com',
163
+ hooks: globalHooks,
164
+ scopes: (instance: ClientInstance) => ({
165
+ users: {
166
+ getUser: () => instance.call<unknown>(makeCallDescriptor()),
167
+ },
168
+ }),
169
+ })
170
+
171
+ await client.users.getUser()
172
+ expect(capturedHeaders[0]?.['x-api-key']).toBe('secret')
173
+ })
174
+
175
+ it('streaming works — SSE yield and return via client.stream()', async () => {
176
+ const sseItems = [
177
+ { data: { n: 1 }, event: 'tick' },
178
+ { data: { n: 2 }, event: 'tick' },
179
+ { data: { total: 2 }, event: 'return' },
180
+ ]
181
+ const adapter = makeAdapter({}, sseItems)
182
+
183
+ const client = createClient({
184
+ adapter,
185
+ basePath: 'https://api.example.com',
186
+ scopes: (instance: ClientInstance) => ({
187
+ updates: {
188
+ watch: () => instance.stream<{ n: number }, { total: number }>(
189
+ makeStreamDescriptor({ streamMode: 'sse' })
190
+ ),
191
+ },
192
+ }),
193
+ })
194
+
195
+ const stream = client.updates.watch()
196
+
197
+ const yielded: { n: number }[] = []
198
+ for await (const item of stream) {
199
+ yielded.push(item)
200
+ }
201
+
202
+ expect(yielded).toEqual([{ n: 1 }, { n: 2 }])
203
+ await expect(stream.result).resolves.toEqual({ total: 2 })
204
+ })
205
+
206
+ it('streaming works — text mode yields raw chunks', async () => {
207
+ const chunks = ['line1\n', 'line2\n']
208
+ const adapter = makeAdapter({}, chunks)
209
+
210
+ const client = createClient({
211
+ adapter,
212
+ basePath: 'https://api.example.com',
213
+ scopes: (instance: ClientInstance) => ({
214
+ logs: {
215
+ tail: () => instance.stream<string, void>(
216
+ makeStreamDescriptor({ streamMode: 'text', name: 'TailLogs', scope: 'logs' })
217
+ ),
218
+ },
219
+ }),
220
+ })
221
+
222
+ const stream = client.logs.tail()
223
+ const received: string[] = []
224
+ for await (const chunk of stream) {
225
+ received.push(chunk)
226
+ }
227
+ expect(received).toEqual(chunks)
228
+ await expect(stream.result).resolves.toBeUndefined()
229
+ })
230
+
231
+ it('stream() is synchronous — returns TypedStream immediately (no await needed)', () => {
232
+ const adapter = makeAdapter({}, [])
233
+
234
+ const client = createClient({
235
+ adapter,
236
+ basePath: 'https://api.example.com',
237
+ scopes: (instance: ClientInstance) => ({
238
+ updates: {
239
+ watch: () => instance.stream<string, void>(makeStreamDescriptor()),
240
+ },
241
+ }),
242
+ })
243
+
244
+ // Must be synchronous — no await
245
+ const stream = client.updates.watch()
246
+ expect(typeof stream[Symbol.asyncIterator]).toBe('function')
247
+ expect(stream).toHaveProperty('result')
248
+ expect(stream.result).toBeInstanceOf(Promise)
249
+ })
250
+
251
+ it('applies global hooks to streams', async () => {
252
+ const capturedHeaders: Record<string, string>[] = []
253
+ const adapter: ClientAdapter = {
254
+ request: vi.fn(async (): Promise<never> => { throw new Error('not expected') }),
255
+ stream: vi.fn(async (req: AdapterRequest): Promise<AdapterStreamResponse> => {
256
+ capturedHeaders.push(req.headers ?? {})
257
+ return { status: 200, headers: {}, body: makeAsyncIterable([]) }
258
+ }),
259
+ }
260
+
261
+ const globalHooks: ClientHooks = {
262
+ onBeforeRequest: (ctx) => ({
263
+ ...ctx,
264
+ request: { ...ctx.request, headers: { 'x-stream-key': 'stream-secret' } },
265
+ }),
266
+ }
267
+
268
+ const client = createClient({
269
+ adapter,
270
+ basePath: 'https://api.example.com',
271
+ hooks: globalHooks,
272
+ scopes: (instance: ClientInstance) => ({
273
+ updates: {
274
+ watch: () => instance.stream<string, void>(makeStreamDescriptor()),
275
+ },
276
+ }),
277
+ })
278
+
279
+ const stream = client.updates.watch()
280
+ for await (const _ of stream) { /* drain */ }
281
+
282
+ expect(capturedHeaders[0]?.['x-stream-key']).toBe('stream-secret')
283
+ })
284
+
285
+ it('barrel exports: re-exports ClientRequestError', async () => {
286
+ // This verifies the exports are accessible — the import at the top covers this
287
+ const { ClientRequestError: CRE } = await import('./index.js')
288
+ expect(CRE).toBeDefined()
289
+ })
290
+ })
@@ -0,0 +1,184 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { interpolatePath, buildAdapterRequest } from './request-builder.js'
3
+ import { ClientPathParamError } from './errors.js'
4
+ import type { CallDescriptor } from './types.js'
5
+
6
+ // ── interpolatePath ───────────────────────────────────────
7
+
8
+ describe('interpolatePath', () => {
9
+ it('returns path unchanged when no params are present', () => {
10
+ expect(interpolatePath('/users', {}, 'GetUsers')).toBe('/users')
11
+ })
12
+
13
+ it('replaces a single :param with the provided value', () => {
14
+ expect(interpolatePath('/users/:id', { id: '42' }, 'GetUser')).toBe('/users/42')
15
+ })
16
+
17
+ it('replaces multiple :params in one path', () => {
18
+ expect(
19
+ interpolatePath('/orgs/:orgId/users/:userId', { orgId: 'acme', userId: '7' }, 'GetUser')
20
+ ).toBe('/orgs/acme/users/7')
21
+ })
22
+
23
+ it('URI-encodes param values', () => {
24
+ expect(interpolatePath('/users/:name', { name: 'hello world' }, 'GetUser')).toBe(
25
+ '/users/hello%20world'
26
+ )
27
+ })
28
+
29
+ it('throws ClientPathParamError when a required param is missing', () => {
30
+ expect(() => interpolatePath('/users/:id', {}, 'GetUser')).toThrow(ClientPathParamError)
31
+ })
32
+
33
+ it('throws with descriptive message containing the param name and path', () => {
34
+ expect(() => interpolatePath('/users/:id', {}, 'GetUser')).toThrow(/id/)
35
+ expect(() => interpolatePath('/users/:id', {}, 'GetUser')).toThrow(/\/users\/:id/)
36
+ })
37
+
38
+ it('is a no-op when path has no :params', () => {
39
+ expect(interpolatePath('/static/path', {}, 'Foo')).toBe('/static/path')
40
+ })
41
+ })
42
+
43
+ // ── buildAdapterRequest ───────────────────────────────────
44
+
45
+ describe('buildAdapterRequest', () => {
46
+ const base: Omit<CallDescriptor, 'kind' | 'params'> = {
47
+ name: 'GetUser',
48
+ scope: 'users',
49
+ path: '/users/:id',
50
+ method: 'GET',
51
+ }
52
+
53
+ it('RPC: sends flat params as JSON body', () => {
54
+ const descriptor: CallDescriptor = {
55
+ ...base,
56
+ path: '/rpc/GetUser',
57
+ kind: 'rpc',
58
+ params: { userId: '42', extra: true },
59
+ }
60
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
61
+ expect(req.url).toBe('https://api.example.com/rpc/GetUser')
62
+ expect(req.method).toBe('GET')
63
+ expect(req.body).toEqual({ userId: '42', extra: true })
64
+ expect(req.headers).toBeUndefined()
65
+ })
66
+
67
+ it('stream: sends flat params as JSON body (same as RPC)', () => {
68
+ const descriptor: CallDescriptor = {
69
+ ...base,
70
+ path: '/stream/Updates',
71
+ kind: 'stream',
72
+ params: { filter: 'active' },
73
+ }
74
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
75
+ expect(req.url).toBe('https://api.example.com/stream/Updates')
76
+ expect(req.body).toEqual({ filter: 'active' })
77
+ })
78
+
79
+ it('API: interpolates pathParams into URL', () => {
80
+ const descriptor: CallDescriptor = {
81
+ ...base,
82
+ path: '/users/:id',
83
+ kind: 'api',
84
+ params: { pathParams: { id: '99' } },
85
+ }
86
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
87
+ expect(req.url).toBe('https://api.example.com/users/99')
88
+ expect(req.body).toBeUndefined()
89
+ })
90
+
91
+ it('API: appends query params to the URL', () => {
92
+ const descriptor: CallDescriptor = {
93
+ ...base,
94
+ path: '/users',
95
+ kind: 'api',
96
+ params: { query: { role: 'admin', active: 'true' } },
97
+ }
98
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
99
+ expect(req.url).toContain('role=admin')
100
+ expect(req.url).toContain('active=true')
101
+ })
102
+
103
+ it('API: sets body from body channel', () => {
104
+ const descriptor: CallDescriptor = {
105
+ ...base,
106
+ path: '/users',
107
+ method: 'POST',
108
+ kind: 'api',
109
+ params: { body: { name: 'Alice', age: 30 } },
110
+ }
111
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
112
+ expect(req.body).toEqual({ name: 'Alice', age: 30 })
113
+ })
114
+
115
+ it('API: merges headers channel into request headers', () => {
116
+ const descriptor: CallDescriptor = {
117
+ ...base,
118
+ path: '/users',
119
+ kind: 'api',
120
+ params: { headers: { 'x-tenant-id': 'tenant-123' } },
121
+ }
122
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
123
+ expect(req.headers?.['x-tenant-id']).toBe('tenant-123')
124
+ })
125
+
126
+ it('API GET with query only — no body', () => {
127
+ const descriptor: CallDescriptor = {
128
+ ...base,
129
+ path: '/search',
130
+ method: 'GET',
131
+ kind: 'api',
132
+ params: { query: { q: 'typescript' } },
133
+ }
134
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
135
+ expect(req.url).toContain('q=typescript')
136
+ expect(req.body).toBeUndefined()
137
+ })
138
+
139
+ it('API: combines pathParams, query, body, and headers together', () => {
140
+ const descriptor: CallDescriptor = {
141
+ ...base,
142
+ path: '/orgs/:orgId/users/:userId',
143
+ method: 'PUT',
144
+ kind: 'api',
145
+ params: {
146
+ pathParams: { orgId: 'acme', userId: '7' },
147
+ query: { version: '2' },
148
+ body: { name: 'Bob' },
149
+ headers: { 'x-request-id': 'req-xyz' },
150
+ },
151
+ }
152
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
153
+ expect(req.url).toContain('/orgs/acme/users/7')
154
+ expect(req.url).toContain('version=2')
155
+ expect(req.body).toEqual({ name: 'Bob' })
156
+ expect(req.headers?.['x-request-id']).toBe('req-xyz')
157
+ })
158
+
159
+ it('RPC with keys named "body" and "query" is treated as flat — NOT structured', () => {
160
+ const descriptor: CallDescriptor = {
161
+ ...base,
162
+ path: '/rpc/DoThing',
163
+ kind: 'rpc',
164
+ params: { body: 'literal-body-value', query: 'literal-query-value' },
165
+ }
166
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com')
167
+ // The entire params object goes to body — no query string appended
168
+ expect(req.body).toEqual({ body: 'literal-body-value', query: 'literal-query-value' })
169
+ expect(req.url).toBe('https://api.example.com/rpc/DoThing')
170
+ // Should NOT have a query string derived from params.query
171
+ expect(req.url).not.toContain('?')
172
+ })
173
+
174
+ it('prepends basePath to the URL', () => {
175
+ const descriptor: CallDescriptor = {
176
+ ...base,
177
+ path: '/users',
178
+ kind: 'rpc',
179
+ params: {},
180
+ }
181
+ const req = buildAdapterRequest(descriptor, 'https://api.example.com/v1')
182
+ expect(req.url).toBe('https://api.example.com/v1/users')
183
+ })
184
+ })