@pikku/core 0.12.64 → 0.12.67

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 (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. package/tsconfig.tsbuildinfo +1 -1
@@ -1105,6 +1105,109 @@ describe('streamAIAgent', () => {
1105
1105
  },
1106
1106
  ])
1107
1107
  })
1108
+ /**
1109
+ * `send` is synchronous, so the persisting channel cannot await its flush and
1110
+ * calls it fire-and-forget. Any rejection from storage — a dropped connection,
1111
+ * or a model reusing a toolCallId, which is a primary key in AI storage — then
1112
+ * surfaces as an unhandled rejection and takes the whole server process down
1113
+ * with it. A failed persist must fail (or degrade) the run, not the process.
1114
+ */
1115
+ test('a storage failure while streaming does not become an unhandled rejection', async () => {
1116
+ addTestAgent('storage-failure-agent')
1117
+
1118
+ const events: AIStreamEvent[] = []
1119
+ const loggedErrors: unknown[] = []
1120
+
1121
+ const mockServices = {
1122
+ logger: {
1123
+ info: () => {},
1124
+ warn: () => {},
1125
+ error: (...args: unknown[]) => {
1126
+ loggedErrors.push(args)
1127
+ },
1128
+ debug: () => {},
1129
+ },
1130
+ aiAgentRunner: {
1131
+ // Emits through the channel the way the real runner does: the persisting
1132
+ // channel flushes fire-and-forget on `usage`, which is the path that
1133
+ // cannot await and so cannot catch.
1134
+ stream: async (
1135
+ _params: unknown,
1136
+ channel: AIStreamChannel
1137
+ ): Promise<AIAgentStepResult> => {
1138
+ channel.send({
1139
+ type: 'text-delta',
1140
+ text: 'persisted text',
1141
+ } as AIStreamEvent)
1142
+ channel.send({
1143
+ type: 'usage',
1144
+ tokens: { input: 1, output: 1 },
1145
+ model: 'test/test-model',
1146
+ } as AIStreamEvent)
1147
+ return makeStepResult({ text: 'persisted text' })
1148
+ },
1149
+ },
1150
+ aiRunState: {
1151
+ createRun: async () => 'run-storage-failure',
1152
+ updateRun: async () => {},
1153
+ },
1154
+ aiStorage: {
1155
+ getThread: async () => undefined,
1156
+ createThread: async () => {},
1157
+ getMessages: async () => [],
1158
+ // Fails only on the assistant/tool write the persisting channel makes,
1159
+ // the way a repeated toolCallId collides on its primary key — the
1160
+ // initial user-message save is awaited and must still succeed.
1161
+ saveMessages: async (_threadId: string, messages: AIMessage[]) => {
1162
+ if (messages.some((m) => m.role !== 'user')) {
1163
+ throw new Error('UNIQUE constraint failed: ai_tool_call.id')
1164
+ }
1165
+ },
1166
+ },
1167
+ } as any
1168
+
1169
+ pikkuState(null, 'package', 'singletonServices', mockServices)
1170
+
1171
+ const unhandled: unknown[] = []
1172
+ const onUnhandled = (reason: unknown) => unhandled.push(reason)
1173
+ process.on('unhandledRejection', onUnhandled)
1174
+
1175
+ try {
1176
+ await streamAIAgent(
1177
+ 'storage-failure-agent',
1178
+ {
1179
+ message: 'hello',
1180
+ threadId: 'thread-storage-failure',
1181
+ resourceId: 'resource-storage-failure',
1182
+ },
1183
+ {
1184
+ channelId: 'channel-storage-failure',
1185
+ openingData: undefined,
1186
+ state: 'open',
1187
+ send: (event: AIStreamEvent) => {
1188
+ events.push(event)
1189
+ },
1190
+ close: () => {},
1191
+ },
1192
+ {}
1193
+ )
1194
+ // unhandledRejection is emitted on a later macrotask, so the floating
1195
+ // flush promise needs a real timer to settle before asserting.
1196
+ await new Promise((resolve) => setTimeout(resolve, 50))
1197
+ } finally {
1198
+ process.off('unhandledRejection', onUnhandled)
1199
+ }
1200
+
1201
+ assert.deepEqual(
1202
+ unhandled,
1203
+ [],
1204
+ 'a storage failure must not escape as an unhandled rejection'
1205
+ )
1206
+ assert.ok(
1207
+ loggedErrors.length > 0,
1208
+ 'the storage failure should be reported rather than swallowed'
1209
+ )
1210
+ })
1108
1211
  })
1109
1212
 
1110
1213
  describe('ai-agent-stream helpers', () => {
@@ -37,6 +37,7 @@ import {
37
37
  resolveOwnerResourceId,
38
38
  agentSessionScope,
39
39
  assertResourceOwner,
40
+ assertAgentAuthorized,
40
41
  ToolApprovalRequired,
41
42
  ToolCredentialRequired,
42
43
  APPROVAL_REQUIRED,
@@ -57,7 +58,8 @@ type PersistingChannel = AIStreamChannel & {
57
58
  function createPersistingChannel(
58
59
  parent: AIStreamChannel,
59
60
  storage: AIStorageService | undefined,
60
- threadId: string
61
+ threadId: string,
62
+ logger?: { error: (...args: any[]) => void }
61
63
  ): PersistingChannel {
62
64
  let fullText = ''
63
65
  let stepText = ''
@@ -110,6 +112,23 @@ function createPersistingChannel(
110
112
  }
111
113
  }
112
114
 
115
+ /**
116
+ * `send` is synchronous and so cannot await the flush. A rejection would have
117
+ * nothing to propagate to and would take the process down as an unhandled
118
+ * rejection — a model reusing a toolCallId, which is a primary key in AI
119
+ * storage, is enough to trigger it. Persistence from inside `send` is
120
+ * therefore best-effort: the run carries on, and the awaited `flush()` on the
121
+ * suspend paths still surfaces failures to its caller.
122
+ */
123
+ const flushDetached = () => {
124
+ void flushStep().catch((error) => {
125
+ logger?.error('Failed to persist agent messages', {
126
+ threadId,
127
+ error,
128
+ })
129
+ })
130
+ }
131
+
113
132
  const channel: PersistingChannel = {
114
133
  channelId: parent.channelId,
115
134
  openingData: parent.openingData,
@@ -156,10 +175,10 @@ function createPersistingChannel(
156
175
  totalUsage.inputTokens += event.tokens.input
157
176
  totalUsage.outputTokens += event.tokens.output
158
177
  if (event.model) totalUsage.model = event.model
159
- flushStep()
178
+ flushDetached()
160
179
  break
161
180
  case 'done':
162
- flushStep()
181
+ flushDetached()
163
182
  break
164
183
  }
165
184
  }
@@ -730,7 +749,12 @@ export async function streamAIAgent(
730
749
  }
731
750
  )
732
751
 
733
- const persistingChannel = createPersistingChannel(channel, storage, threadId)
752
+ const persistingChannel = createPersistingChannel(
753
+ channel,
754
+ storage,
755
+ threadId,
756
+ singletonServices.logger
757
+ )
734
758
 
735
759
  const wrappedChannel =
736
760
  allChannelMiddleware.length > 0
@@ -898,12 +922,19 @@ export async function resumeAIAgent(
898
922
  )
899
923
  }
900
924
 
925
+ const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
926
+
927
+ // Gate before resolving the approval: recording it is a persisted side
928
+ // effect, so an unauthorized caller must not reach it. Run ownership alone is
929
+ // not enough — a grant revoked while the run was suspended must stop the
930
+ // caller from approving its pending tool calls.
931
+ await assertAgentAuthorized(agent, params, packageName)
932
+
901
933
  await aiRunState.resolveApproval(
902
934
  input.toolCallId,
903
935
  input.approved ? 'approved' : 'denied'
904
936
  )
905
937
 
906
- const { agent, packageName, resolvedName } = resolveAgent(run.agentName)
907
938
  const { storage } = resolveMemoryServices(agent, singletonServices)
908
939
  const memoryConfig = agent.memory
909
940
  const agentRunner = singletonServices.aiAgentRunner
@@ -1229,7 +1260,8 @@ async function continueAfterToolResult(
1229
1260
  const persistingChannel = createPersistingChannel(
1230
1261
  channel,
1231
1262
  storage,
1232
- run.threadId
1263
+ run.threadId,
1264
+ singletonServices.logger
1233
1265
  )
1234
1266
 
1235
1267
  const wrappedChannel =
@@ -226,6 +226,7 @@ export type AIAgentMemoryConfig = {
226
226
  export type CoreAIAgent<
227
227
  PikkuPermission = CorePikkuPermission<any, any>,
228
228
  PikkuMiddleware = CorePikkuMiddleware<any>,
229
+ Scope extends string = string,
229
230
  > = {
230
231
  name: string
231
232
  description: string
@@ -259,6 +260,23 @@ export type CoreAIAgent<
259
260
  middleware?: PikkuMiddleware[]
260
261
  channelMiddleware?: CorePikkuChannelMiddleware<any, any>[]
261
262
  aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[]
263
+ /**
264
+ * Whether a session is required to run this agent. Defaults to `false`, since
265
+ * agents are commonly invoked from an already-authenticated `pikkuFunc` or
266
+ * from genuinely sessionless contexts (crons, queue workers). Set `true` to
267
+ * require a session at the agent itself. `scopes` and `permissions` are
268
+ * enforced either way.
269
+ */
270
+ auth?: boolean
271
+ /**
272
+ * Scopes the session must hold to run this agent. All of them are required
273
+ * (AND), and they are checked before `permissions` — unlike permissions,
274
+ * which OR together, a scope can only narrow access.
275
+ *
276
+ * Narrowed to the generated `ScopeId` union in a project's own
277
+ * `pikku-types.gen.ts`, so an undeclared scope is a compile error.
278
+ */
279
+ scopes?: Scope[]
262
280
  permissions?: CorePermissionGroup<PikkuPermission>
263
281
  }
264
282
 
@@ -421,6 +439,17 @@ export interface AgentRunService {
421
439
  listThreads(options?: {
422
440
  agentName?: string
423
441
  resourceId?: string
442
+ /**
443
+ * Restrict results to threads owned by one of these session principals. A
444
+ * thread matches when its `resourceId` is the principal itself or one of its
445
+ * `principal:` sub-partitions, mirroring the composition
446
+ * `resolveOwnerResourceId` writes.
447
+ *
448
+ * Unlike `resourceId`, which is an optional exact-match filter, this is an
449
+ * authorization constraint: an empty array matches nothing. Callers exposing
450
+ * threads over the wire must derive it from the session, never from input.
451
+ */
452
+ owners?: string[]
424
453
  limit?: number
425
454
  offset?: number
426
455
  }): Promise<AIThread[]>
@@ -14,6 +14,10 @@ export {
14
14
  type StreamAIAgentOptions,
15
15
  ToolApprovalRequired,
16
16
  ToolCredentialRequired,
17
+ canAccessThread,
18
+ isOwnedByPrincipal,
19
+ sessionPrincipals,
20
+ threadOwnerConstraint,
17
21
  } from './ai-agent-prepare.js'
18
22
  export {
19
23
  addAIAgent,
@@ -0,0 +1,90 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { voiceInput } from './voice-input.js'
5
+ import type { AIContentPart, AIMessage } from './ai-agent.types.js'
6
+
7
+ /**
8
+ * A runner whose `transcribe` reads instance state through `this`, mirroring the
9
+ * real `VercelAIAgentRunner` where `transcribe` calls `this.getModel(...)`. If
10
+ * the middleware grabs the method as a bare reference the receiver is lost and
11
+ * `this` is undefined — which is exactly the regression this guards.
12
+ */
13
+ class ThisDependentRunner {
14
+ private readonly transcript = 'the transcribed spoken words'
15
+
16
+ async transcribe() {
17
+ return {
18
+ text: this.transcript,
19
+ segments: [],
20
+ language: 'en',
21
+ durationInSeconds: 1,
22
+ warnings: [],
23
+ }
24
+ }
25
+ }
26
+
27
+ const audioMessage = (): AIMessage => ({
28
+ id: 'm1',
29
+ role: 'user',
30
+ content: [
31
+ { type: 'file', mediaType: 'audio/wav', data: 'AAAA' },
32
+ ] as AIContentPart[],
33
+ createdAt: new Date(0),
34
+ })
35
+
36
+ describe('voiceInput', () => {
37
+ test('transcribes an audio part by calling the runner with its receiver intact', async () => {
38
+ const mw = voiceInput({ model: 'mock/whisper' })
39
+ const services = { aiAgentRunner: new ThisDependentRunner() }
40
+
41
+ const result = await mw.modifyInput!(services as any, {
42
+ messages: [audioMessage()],
43
+ instructions: 'sys',
44
+ })
45
+
46
+ const last = result.messages[result.messages.length - 1]!
47
+ const parts = last.content as AIContentPart[]
48
+ assert.equal(parts.length, 1)
49
+ assert.equal(parts[0]!.type, 'text')
50
+ assert.equal(
51
+ (parts[0] as { text: string }).text,
52
+ 'the transcribed spoken words'
53
+ )
54
+ assert.ok(!parts.some((p) => p.type === 'file'))
55
+ })
56
+
57
+ test('leaves the message untouched when there is no audio part', async () => {
58
+ const mw = voiceInput({ model: 'mock/whisper' })
59
+ const services = { aiAgentRunner: new ThisDependentRunner() }
60
+ const messages: AIMessage[] = [
61
+ {
62
+ id: 'm1',
63
+ role: 'user',
64
+ content: 'just text',
65
+ createdAt: new Date(0),
66
+ },
67
+ ]
68
+
69
+ const result = await mw.modifyInput!(services as any, {
70
+ messages,
71
+ instructions: 'sys',
72
+ })
73
+
74
+ assert.equal(result.messages[0]!.content, 'just text')
75
+ })
76
+
77
+ test('throws when an audio part is present but no model is configured', async () => {
78
+ const mw = voiceInput({})
79
+ const services = { aiAgentRunner: new ThisDependentRunner() }
80
+
81
+ await assert.rejects(
82
+ () =>
83
+ mw.modifyInput!(services as any, {
84
+ messages: [audioMessage()],
85
+ instructions: 'sys',
86
+ }),
87
+ /voiceInput requires a transcription model/
88
+ )
89
+ })
90
+ })
@@ -18,11 +18,7 @@ async function fetchAsUint8Array(
18
18
  url: string,
19
19
  allowedAudioHosts?: string[]
20
20
  ): Promise<Uint8Array> {
21
- const response = await safeFetch(
22
- url,
23
- {},
24
- { allowedHosts: allowedAudioHosts }
25
- )
21
+ const response = await safeFetch(url, {}, { allowedHosts: allowedAudioHosts })
26
22
  const contentLength = response.headers.get('content-length')
27
23
  if (contentLength && parseInt(contentLength, 10) > MAX_AUDIO_SIZE) {
28
24
  throw new Error('Audio file exceeds maximum size')
@@ -41,10 +37,12 @@ export const voiceInput = (config?: {
41
37
  }) =>
42
38
  pikkuAIMiddleware({
43
39
  modifyInput: async (services, { messages, instructions }) => {
44
- const transcribeAudio = (services as {
45
- aiAgentRunner?: AIAgentRunnerService
46
- }).aiAgentRunner?.transcribe
47
- if (!transcribeAudio) return { messages, instructions }
40
+ const aiAgentRunner = (
41
+ services as {
42
+ aiAgentRunner?: AIAgentRunnerService
43
+ }
44
+ ).aiAgentRunner
45
+ if (!aiAgentRunner?.transcribe) return { messages, instructions }
48
46
 
49
47
  const last = messages[messages.length - 1]
50
48
  if (!last || last.role !== 'user' || typeof last.content === 'string') {
@@ -76,7 +74,7 @@ export const voiceInput = (config?: {
76
74
  const audioData = p.data
77
75
  ? base64ToUint8Array(p.data)
78
76
  : await fetchAsUint8Array(p.url!, config.allowedAudioHosts)
79
- const result = await transcribeAudio({
77
+ const result = await aiAgentRunner.transcribe({
80
78
  model: config.model,
81
79
  audio: audioData,
82
80
  ...(config.language
@@ -490,6 +490,10 @@ export async function executeCLI({
490
490
  return
491
491
  }
492
492
 
493
+ // Non-fatal diagnostics (unknown options are still accepted) go to stderr
494
+ // so they never pollute a command's machine-readable stdout.
495
+ parsed.warnings.forEach((warning) => console.error(`Warning: ${warning}`))
496
+
493
497
  if (parsed.errors.length > 0) {
494
498
  // Check if any error is about an unknown command
495
499
  const hasUnknownCommand = parsed.errors.some(
@@ -270,6 +270,7 @@ describe('Command Parser', () => {
270
270
  // Unknown options are allowed (for forward compatibility)
271
271
  // They just won't have defaults or validation
272
272
  assert.strictEqual(result.options.unknown, true)
273
+ assert.strictEqual(result.errors.length, 0)
273
274
  })
274
275
 
275
276
  test('should report error for unknown short flag', () => {
@@ -525,4 +526,133 @@ describe('Command Parser', () => {
525
526
  )
526
527
  })
527
528
  })
529
+
530
+ describe('unknown long options', () => {
531
+ const listMeta: CLIMeta = {
532
+ programs: {
533
+ 'test-cli': {
534
+ program: 'test-cli',
535
+ options: {},
536
+ commands: {
537
+ list: {
538
+ pikkuFuncId: 'listFunc',
539
+ positionals: [],
540
+ options: {
541
+ section: { description: 'Section to list', default: 'all' },
542
+ autoApply: {
543
+ description: 'Apply automatically',
544
+ default: false,
545
+ },
546
+ },
547
+ },
548
+ },
549
+ },
550
+ },
551
+ renderers: {},
552
+ }
553
+
554
+ test('warns (but does not error) on an unknown --opt value option', () => {
555
+ const result = parseCLIArguments(
556
+ ['list', '--sektion', 'functions'],
557
+ 'test-cli',
558
+ listMeta
559
+ )
560
+
561
+ assert.strictEqual(result.errors.length, 0, 'should stay non-fatal')
562
+ assert.strictEqual(result.warnings.length, 1)
563
+ assert.ok(
564
+ result.warnings[0].startsWith('Unknown option: --sektion (ignored)'),
565
+ `unexpected warning: ${result.warnings[0]}`
566
+ )
567
+ // Forward compatibility: the value is still parsed through
568
+ assert.strictEqual(result.options.sektion, 'functions')
569
+ // ...and the real option keeps its default
570
+ assert.strictEqual(result.options.section, 'all')
571
+ })
572
+
573
+ test('warns on an unknown --opt=value option', () => {
574
+ const result = parseCLIArguments(
575
+ ['list', '--sektion=functions'],
576
+ 'test-cli',
577
+ listMeta
578
+ )
579
+
580
+ assert.strictEqual(result.errors.length, 0)
581
+ assert.strictEqual(result.warnings.length, 1)
582
+ assert.ok(
583
+ result.warnings[0].startsWith('Unknown option: --sektion (ignored)'),
584
+ `unexpected warning: ${result.warnings[0]}`
585
+ )
586
+ assert.strictEqual(result.options.sektion, 'functions')
587
+ })
588
+
589
+ test('suggests a near-miss option name', () => {
590
+ const result = parseCLIArguments(
591
+ ['list', '--sektion'],
592
+ 'test-cli',
593
+ listMeta
594
+ )
595
+
596
+ assert.ok(
597
+ result.warnings[0].includes('Did you mean --section?'),
598
+ `expected a suggestion, got: ${result.warnings[0]}`
599
+ )
600
+ })
601
+
602
+ test('suggests the kebab-case rendering of a camelCase option', () => {
603
+ const result = parseCLIArguments(
604
+ ['list', '--auto-aply'],
605
+ 'test-cli',
606
+ listMeta
607
+ )
608
+
609
+ assert.ok(
610
+ result.warnings[0].includes('Did you mean --auto-apply?'),
611
+ `expected a suggestion, got: ${result.warnings[0]}`
612
+ )
613
+ })
614
+
615
+ test('omits the suggestion when nothing is close', () => {
616
+ const result = parseCLIArguments(
617
+ ['list', '--completely-different'],
618
+ 'test-cli',
619
+ listMeta
620
+ )
621
+
622
+ assert.strictEqual(
623
+ result.warnings[0],
624
+ 'Unknown option: --completely-different (ignored)'
625
+ )
626
+ })
627
+
628
+ test('does not warn for a known option (either casing)', () => {
629
+ const kebab = parseCLIArguments(
630
+ ['list', '--section', 'functions', '--auto-apply'],
631
+ 'test-cli',
632
+ listMeta
633
+ )
634
+ assert.deepStrictEqual(kebab.warnings, [])
635
+ assert.strictEqual(kebab.options.section, 'functions')
636
+
637
+ const camel = parseCLIArguments(
638
+ ['list', '--autoApply'],
639
+ 'test-cli',
640
+ listMeta
641
+ )
642
+ assert.deepStrictEqual(camel.warnings, [])
643
+ })
644
+
645
+ test('does not warn for --help', () => {
646
+ const result = parseCLIArguments(['list', '--help'], 'test-cli', listMeta)
647
+
648
+ assert.deepStrictEqual(result.warnings, [])
649
+ })
650
+
651
+ test('unknown short flags still error, not warn', () => {
652
+ const result = parseCLIArguments(['list', '-x'], 'test-cli', listMeta)
653
+
654
+ assert.ok(result.errors.some((e) => e.includes('Unknown option: -x')))
655
+ assert.deepStrictEqual(result.warnings, [])
656
+ })
657
+ })
528
658
  })
@@ -18,6 +18,71 @@ function toKebabCase(str: string): string {
18
18
  return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
19
19
  }
20
20
 
21
+ /** Options the runner handles itself — never reported as unknown. */
22
+ const RESERVED_OPTIONS = new Set(['help'])
23
+
24
+ /** Levenshtein distance, capped-free and dependency-free. Used only to suggest
25
+ * a near-miss option name, so the naive O(n*m) implementation is fine. */
26
+ function levenshtein(a: string, b: string): number {
27
+ if (a === b) return 0
28
+ if (a.length === 0) return b.length
29
+ if (b.length === 0) return a.length
30
+
31
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
32
+ for (let i = 1; i <= a.length; i++) {
33
+ const row = [i]
34
+ for (let j = 1; j <= b.length; j++) {
35
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
36
+ row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
37
+ }
38
+ prev = row
39
+ }
40
+ return prev[b.length]
41
+ }
42
+
43
+ /** Finds the closest declared option (distance <= 2) to what the user typed.
44
+ * Compares against the kebab-case rendering, since that is what is typed. */
45
+ function suggestOption(
46
+ typed: string,
47
+ availableOptions: Record<string, CLIOption>
48
+ ): string | null {
49
+ let best: string | null = null
50
+ let bestDistance = 3
51
+
52
+ for (const name of Object.keys(availableOptions)) {
53
+ const kebab = toKebabCase(name)
54
+ const distance = Math.min(
55
+ levenshtein(typed, kebab),
56
+ levenshtein(typed, name)
57
+ )
58
+ if (distance < bestDistance) {
59
+ bestDistance = distance
60
+ best = kebab
61
+ }
62
+ }
63
+
64
+ return best
65
+ }
66
+
67
+ /** Records a warning that an unknown long option was accepted but ignored.
68
+ * Unknown options stay non-fatal for forward compatibility (a newer command
69
+ * version may understand them) — they are just no longer silent. */
70
+ function warnUnknownOption(
71
+ typed: string,
72
+ availableOptions: Record<string, CLIOption>,
73
+ result: ParsedCommand
74
+ ) {
75
+ if (RESERVED_OPTIONS.has(toCamelCase(typed))) {
76
+ return
77
+ }
78
+
79
+ const suggestion = suggestOption(typed, availableOptions)
80
+ result.warnings.push(
81
+ `Unknown option: --${typed} (ignored)` +
82
+ (suggestion ? ` Did you mean --${suggestion}?` : '')
83
+ )
84
+ }
85
+
21
86
  /**
22
87
  * Result of parsing CLI arguments
23
88
  */
@@ -27,6 +92,8 @@ export interface ParsedCommand {
27
92
  positionals: Record<string, any>
28
93
  options: Record<string, any>
29
94
  errors: string[]
95
+ /** Non-fatal diagnostics (e.g. unknown options that were accepted+ignored) */
96
+ warnings: string[]
30
97
  }
31
98
 
32
99
  /**
@@ -43,6 +110,7 @@ export function parseCLIArguments(
43
110
  positionals: {},
44
111
  options: {},
45
112
  errors: [],
113
+ warnings: [],
46
114
  }
47
115
 
48
116
  const meta = allMeta.programs[programName]
@@ -148,7 +216,12 @@ export function parseCLIArguments(
148
216
  const key = toCamelCase(arg.slice(2, equalIndex))
149
217
  const optionDef = availableOptions[key]
150
218
 
151
- // Unknown options are allowed for forward compatibility
219
+ // Unknown options are allowed for forward compatibility, but warned
220
+ // about so they are not silently dropped by the input schema.
221
+ if (!optionDef) {
222
+ warnUnknownOption(arg.slice(2, equalIndex), availableOptions, result)
223
+ }
224
+
152
225
  const value = arg.slice(equalIndex + 1)
153
226
  optionArgs[key] = parseOptionValue(value, optionDef)
154
227
  } else {
@@ -156,7 +229,12 @@ export function parseCLIArguments(
156
229
  const key = toCamelCase(arg.slice(2))
157
230
  const optionDef = availableOptions[key]
158
231
 
159
- // Unknown options are allowed for forward compatibility
232
+ // Unknown options are allowed for forward compatibility, but warned
233
+ // about so they are not silently dropped by the input schema.
234
+ if (!optionDef) {
235
+ warnUnknownOption(arg.slice(2), availableOptions, result)
236
+ }
237
+
160
238
  if (optionDef && optionDef.array) {
161
239
  // Array option - collect all following non-flag values
162
240
  currentIndex++
@@ -6,6 +6,13 @@ export type CoreCredential<T = unknown> = {
6
6
  description?: string
7
7
  type: 'singleton' | 'wire'
8
8
  schema: T
9
+ /**
10
+ * Link to documentation explaining how to obtain this value — a provider's
11
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
12
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
13
+ * an opaque identifier.
14
+ */
15
+ docsUrl?: string
9
16
  oauth2?: OAuth2CredentialConfig & {
10
17
  appCredentialSecretId: string
11
18
  }
@@ -17,6 +24,13 @@ export type CredentialDefinitionMeta = {
17
24
  description?: string
18
25
  type: 'singleton' | 'wire'
19
26
  schema?: Record<string, unknown> | string
27
+ /**
28
+ * Link to documentation explaining how to obtain this value — a provider's
29
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
30
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
31
+ * an opaque identifier.
32
+ */
33
+ docsUrl?: string
20
34
  oauth2?: OAuth2CredentialConfig & {
21
35
  appCredentialSecretId: string
22
36
  }