@codori/client 0.0.6 → 0.0.8

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.
@@ -0,0 +1,374 @@
1
+ import type { CodexRpcServerRequest } from './codex-rpc'
2
+
3
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === 'object' && value !== null && !Array.isArray(value)
5
+
6
+ const asTrimmedString = (value: unknown) => {
7
+ if (typeof value !== 'string') {
8
+ return null
9
+ }
10
+
11
+ const trimmed = value.trim()
12
+ return trimmed.length > 0 ? trimmed : null
13
+ }
14
+
15
+ const asBoolean = (value: unknown) => value === true
16
+
17
+ const asStringArray = (value: unknown) =>
18
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
19
+
20
+ export type PendingRequestUserInputOption = {
21
+ label: string
22
+ description: string | null
23
+ }
24
+
25
+ export type PendingRequestUserInputQuestion = {
26
+ header: string | null
27
+ id: string
28
+ question: string
29
+ options: PendingRequestUserInputOption[]
30
+ isOther: boolean
31
+ isSecret: boolean
32
+ }
33
+
34
+ export type PendingRequestUserInput = {
35
+ kind: 'requestUserInput'
36
+ requestId: number
37
+ threadId: string | null
38
+ turnId: string | null
39
+ itemId: string | null
40
+ questions: PendingRequestUserInputQuestion[]
41
+ }
42
+
43
+ type BaseElicitationField = {
44
+ key: string
45
+ label: string
46
+ description: string | null
47
+ required: boolean
48
+ }
49
+
50
+ export type PendingElicitationStringField = BaseElicitationField & {
51
+ kind: 'string'
52
+ minLength: number | null
53
+ maxLength: number | null
54
+ format: 'email' | 'uri' | 'date' | 'date-time' | null
55
+ defaultValue: string | null
56
+ }
57
+
58
+ export type PendingElicitationNumberField = BaseElicitationField & {
59
+ kind: 'number'
60
+ numericType: 'number' | 'integer'
61
+ minimum: number | null
62
+ maximum: number | null
63
+ defaultValue: number | null
64
+ }
65
+
66
+ export type PendingElicitationBooleanField = BaseElicitationField & {
67
+ kind: 'boolean'
68
+ defaultValue: boolean
69
+ }
70
+
71
+ export type PendingElicitationEnumField = BaseElicitationField & {
72
+ kind: 'enum'
73
+ valueType: 'string' | 'number' | 'integer' | 'boolean'
74
+ options: Array<{
75
+ label: string
76
+ value: string | number | boolean
77
+ }>
78
+ defaultValue: string | number | boolean | null
79
+ }
80
+
81
+ export type PendingElicitationField =
82
+ | PendingElicitationStringField
83
+ | PendingElicitationNumberField
84
+ | PendingElicitationBooleanField
85
+ | PendingElicitationEnumField
86
+
87
+ export type PendingMcpElicitationForm = {
88
+ kind: 'mcpElicitationForm'
89
+ requestId: number
90
+ threadId: string | null
91
+ message: string | null
92
+ fields: PendingElicitationField[]
93
+ }
94
+
95
+ export type PendingMcpElicitationUrl = {
96
+ kind: 'mcpElicitationUrl'
97
+ requestId: number
98
+ threadId: string | null
99
+ message: string | null
100
+ url: string
101
+ elicitationId: string | null
102
+ }
103
+
104
+ export type PendingUserRequest =
105
+ | PendingRequestUserInput
106
+ | PendingMcpElicitationForm
107
+ | PendingMcpElicitationUrl
108
+
109
+ const parseRequestUserInputOption = (value: unknown): PendingRequestUserInputOption | null => {
110
+ if (!isObjectRecord(value)) {
111
+ return null
112
+ }
113
+
114
+ const label = asTrimmedString(value.label)
115
+ if (!label) {
116
+ return null
117
+ }
118
+
119
+ return {
120
+ label,
121
+ description: asTrimmedString(value.description)
122
+ }
123
+ }
124
+
125
+ const parseRequestUserInputQuestion = (value: unknown): PendingRequestUserInputQuestion | null => {
126
+ if (!isObjectRecord(value)) {
127
+ return null
128
+ }
129
+
130
+ const id = asTrimmedString(value.id)
131
+ const question = asTrimmedString(value.question)
132
+ if (!id || !question) {
133
+ return null
134
+ }
135
+
136
+ const options = Array.isArray(value.options)
137
+ ? value.options
138
+ .map(parseRequestUserInputOption)
139
+ .filter((option): option is PendingRequestUserInputOption => option !== null)
140
+ : []
141
+
142
+ return {
143
+ header: asTrimmedString(value.header),
144
+ id,
145
+ question,
146
+ options,
147
+ isOther: asBoolean(value.isOther),
148
+ isSecret: asBoolean(value.isSecret)
149
+ }
150
+ }
151
+
152
+ const parseNumber = (value: unknown) =>
153
+ typeof value === 'number' && Number.isFinite(value) ? value : null
154
+
155
+ const parseEnumValueType = (values: Array<string | number | boolean>) => {
156
+ if (values.every(value => typeof value === 'string')) {
157
+ return 'string'
158
+ }
159
+
160
+ if (values.every(value => typeof value === 'boolean')) {
161
+ return 'boolean'
162
+ }
163
+
164
+ if (values.every(value => typeof value === 'number' && Number.isInteger(value))) {
165
+ return 'integer'
166
+ }
167
+
168
+ if (values.every(value => typeof value === 'number')) {
169
+ return 'number'
170
+ }
171
+
172
+ return null
173
+ }
174
+
175
+ const parseElicitationField = (
176
+ key: string,
177
+ value: unknown,
178
+ requiredKeys: Set<string>
179
+ ): PendingElicitationField | null => {
180
+ if (!isObjectRecord(value)) {
181
+ return null
182
+ }
183
+
184
+ const label = asTrimmedString(value.title) ?? key
185
+ const description = asTrimmedString(value.description)
186
+ const required = requiredKeys.has(key)
187
+ const enumValues = Array.isArray(value.enum)
188
+ ? value.enum.filter((entry): entry is string | number | boolean =>
189
+ typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean')
190
+ : []
191
+
192
+ if (enumValues.length > 0) {
193
+ const valueType = parseEnumValueType(enumValues)
194
+ if (!valueType) {
195
+ return null
196
+ }
197
+
198
+ const defaultValue = enumValues.includes(value.default as never)
199
+ ? value.default as string | number | boolean
200
+ : null
201
+
202
+ return {
203
+ kind: 'enum',
204
+ key,
205
+ label,
206
+ description,
207
+ required,
208
+ valueType,
209
+ defaultValue,
210
+ options: enumValues.map(option => ({
211
+ label: String(option),
212
+ value: option
213
+ }))
214
+ }
215
+ }
216
+
217
+ if (value.type === 'string') {
218
+ const format = value.format === 'email'
219
+ || value.format === 'uri'
220
+ || value.format === 'date'
221
+ || value.format === 'date-time'
222
+ ? value.format
223
+ : null
224
+
225
+ return {
226
+ kind: 'string',
227
+ key,
228
+ label,
229
+ description,
230
+ required,
231
+ minLength: parseNumber(value.minLength),
232
+ maxLength: parseNumber(value.maxLength),
233
+ format,
234
+ defaultValue: typeof value.default === 'string' ? value.default : null
235
+ }
236
+ }
237
+
238
+ if (value.type === 'number' || value.type === 'integer') {
239
+ return {
240
+ kind: 'number',
241
+ key,
242
+ label,
243
+ description,
244
+ required,
245
+ numericType: value.type,
246
+ minimum: parseNumber(value.minimum),
247
+ maximum: parseNumber(value.maximum),
248
+ defaultValue: typeof value.default === 'number' ? value.default : null
249
+ }
250
+ }
251
+
252
+ if (value.type === 'boolean') {
253
+ return {
254
+ kind: 'boolean',
255
+ key,
256
+ label,
257
+ description,
258
+ required,
259
+ defaultValue: value.default === true
260
+ }
261
+ }
262
+
263
+ return null
264
+ }
265
+
266
+ export const parsePendingUserRequest = (request: CodexRpcServerRequest): PendingUserRequest | null => {
267
+ const params = isObjectRecord(request.params) ? request.params : null
268
+
269
+ switch (request.method) {
270
+ case 'item/tool/requestUserInput': {
271
+ const questions = Array.isArray(params?.questions)
272
+ ? params.questions
273
+ .map(parseRequestUserInputQuestion)
274
+ .filter((question): question is PendingRequestUserInputQuestion => question !== null)
275
+ : []
276
+ if (questions.length === 0) {
277
+ return null
278
+ }
279
+
280
+ return {
281
+ kind: 'requestUserInput',
282
+ requestId: request.id,
283
+ threadId: asTrimmedString(params?.threadId),
284
+ turnId: asTrimmedString(params?.turnId),
285
+ itemId: asTrimmedString(params?.itemId),
286
+ questions
287
+ }
288
+ }
289
+ case 'mcpServer/elicitation/request': {
290
+ const mode = asTrimmedString(params?.mode)
291
+ if (mode === 'url') {
292
+ const url = asTrimmedString(params?.url)
293
+ if (!url) {
294
+ return null
295
+ }
296
+
297
+ return {
298
+ kind: 'mcpElicitationUrl',
299
+ requestId: request.id,
300
+ threadId: asTrimmedString(params?.threadId),
301
+ message: asTrimmedString(params?.message),
302
+ url,
303
+ elicitationId: asTrimmedString(params?.elicitationId)
304
+ }
305
+ }
306
+
307
+ if (mode !== 'form') {
308
+ return null
309
+ }
310
+
311
+ const requestedSchema = isObjectRecord(params?.requestedSchema) ? params.requestedSchema : null
312
+ if (!requestedSchema || requestedSchema.type !== 'object') {
313
+ return null
314
+ }
315
+
316
+ const properties = isObjectRecord(requestedSchema.properties) ? requestedSchema.properties : null
317
+ if (!properties) {
318
+ return null
319
+ }
320
+
321
+ const requiredKeys = new Set(asStringArray(requestedSchema.required))
322
+ const fields = Object.entries(properties)
323
+ .map(([key, value]) => parseElicitationField(key, value, requiredKeys))
324
+ .filter((field): field is PendingElicitationField => field !== null)
325
+ if (fields.length === 0) {
326
+ return null
327
+ }
328
+
329
+ return {
330
+ kind: 'mcpElicitationForm',
331
+ requestId: request.id,
332
+ threadId: asTrimmedString(params?.threadId),
333
+ message: asTrimmedString(params?.message),
334
+ fields
335
+ }
336
+ }
337
+ default:
338
+ return null
339
+ }
340
+ }
341
+
342
+ const sanitizeAnswerList = (answers: string[]) =>
343
+ answers
344
+ .map(answer => answer.trim())
345
+ .filter(answer => answer.length > 0)
346
+
347
+ export const buildRequestUserInputResponse = (
348
+ answers: Record<string, string[]>
349
+ ) => ({
350
+ answers: Object.fromEntries(
351
+ Object.entries(answers).map(([questionId, questionAnswers]) => [questionId, sanitizeAnswerList(questionAnswers)])
352
+ )
353
+ })
354
+
355
+ export const buildMcpElicitationResponse = (
356
+ action: 'accept' | 'decline' | 'cancel',
357
+ content?: Record<string, string | number | boolean>
358
+ ) => {
359
+ if (action !== 'accept') {
360
+ return { action }
361
+ }
362
+
363
+ return content ? { action, content } : { action }
364
+ }
365
+
366
+ export const buildPendingUserRequestDismissResponse = (request: PendingUserRequest) => {
367
+ switch (request.kind) {
368
+ case 'requestUserInput':
369
+ return buildRequestUserInputResponse({})
370
+ case 'mcpElicitationForm':
371
+ case 'mcpElicitationUrl':
372
+ return buildMcpElicitationResponse('cancel')
373
+ }
374
+ }
@@ -0,0 +1,85 @@
1
+ export type SlashCommandName = 'review'
2
+
3
+ export type SlashCommandDefinition = {
4
+ name: SlashCommandName
5
+ description: string
6
+ supportsInlineArgs: boolean
7
+ completeOnSpace: boolean
8
+ executeOnEnter: boolean
9
+ }
10
+
11
+ export type ActiveSlashCommandMatch = {
12
+ start: number
13
+ end: number
14
+ raw: string
15
+ query: string
16
+ }
17
+
18
+ export type SubmittedSlashCommand = {
19
+ name: string
20
+ args: string
21
+ isBare: boolean
22
+ }
23
+
24
+ export const SLASH_COMMANDS: SlashCommandDefinition[] = [{
25
+ name: 'review',
26
+ description: 'Review current changes or compare against a base branch.',
27
+ supportsInlineArgs: false,
28
+ completeOnSpace: true,
29
+ executeOnEnter: true
30
+ }]
31
+
32
+ export const findActiveSlashCommand = (
33
+ input: string,
34
+ selectionStart: number | null | undefined,
35
+ selectionEnd: number | null | undefined
36
+ ): ActiveSlashCommandMatch | null => {
37
+ if (selectionStart == null || selectionEnd == null || selectionStart !== selectionEnd) {
38
+ return null
39
+ }
40
+
41
+ const caret = selectionStart
42
+ const lineStart = input.lastIndexOf('\n', Math.max(0, caret - 1)) + 1
43
+ const linePrefix = input.slice(lineStart, caret)
44
+ const match = /(?:^|\s)(\/[a-z-]*)$/i.exec(linePrefix)
45
+ if (!match) {
46
+ return null
47
+ }
48
+
49
+ const raw = match[1] ?? ''
50
+ if (!raw.startsWith('/')) {
51
+ return null
52
+ }
53
+
54
+ return {
55
+ start: caret - raw.length,
56
+ end: caret,
57
+ raw,
58
+ query: raw.slice(1).toLowerCase()
59
+ }
60
+ }
61
+
62
+ export const filterSlashCommands = (
63
+ commands: SlashCommandDefinition[],
64
+ query: string
65
+ ) => {
66
+ const normalizedQuery = query.trim().toLowerCase()
67
+ return commands.filter(command => command.name.startsWith(normalizedQuery))
68
+ }
69
+
70
+ export const parseSubmittedSlashCommand = (input: string): SubmittedSlashCommand | null => {
71
+ const trimmed = input.trim()
72
+ const match = /^\/([a-z-]+)(?:\s+(.*))?$/i.exec(trimmed)
73
+ if (!match) {
74
+ return null
75
+ }
76
+
77
+ return {
78
+ name: match[1]?.toLowerCase() ?? '',
79
+ args: match[2]?.trim() ?? '',
80
+ isBare: !(match[2]?.trim())
81
+ }
82
+ }
83
+
84
+ export const toSlashCommandCompletion = (command: Pick<SlashCommandDefinition, 'name'>) =>
85
+ `/${command.name} `