@pikku/core 0.12.20 → 0.12.21

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/middleware/index.d.ts +2 -2
  9. package/dist/middleware/index.js +2 -2
  10. package/dist/middleware/telemetry.d.ts +2 -2
  11. package/dist/middleware/telemetry.js +2 -2
  12. package/dist/middleware-runner.d.ts +15 -27
  13. package/dist/middleware-runner.js +25 -30
  14. package/dist/permissions.d.ts +15 -22
  15. package/dist/permissions.js +30 -25
  16. package/dist/pikku-request.js +1 -1
  17. package/dist/pikku-state.js +2 -3
  18. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  19. package/dist/services/in-memory-queue-service.js +1 -1
  20. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  21. package/dist/services/in-memory-workflow-service.js +31 -13
  22. package/dist/services/local-content.js +8 -1
  23. package/dist/services/user-session-service.d.ts +1 -1
  24. package/dist/testing/service-tests.js +24 -0
  25. package/dist/types/core.types.d.ts +4 -0
  26. package/dist/types/state.types.d.ts +2 -18
  27. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  28. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  29. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  30. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  31. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  32. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  35. package/dist/wirings/channel/channel-handler.js +1 -1
  36. package/dist/wirings/channel/channel-store.d.ts +13 -0
  37. package/dist/wirings/channel/channel.types.d.ts +3 -0
  38. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  39. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  40. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  41. package/dist/wirings/cli/cli-runner.js +24 -5
  42. package/dist/wirings/cli/command-parser.js +19 -4
  43. package/dist/wirings/http/http-runner.js +72 -36
  44. package/dist/wirings/http/http.types.d.ts +1 -0
  45. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  46. package/dist/wirings/http/web-request.js +32 -0
  47. package/dist/wirings/rpc/rpc-runner.js +13 -3
  48. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  49. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  50. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  51. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  52. package/dist/wirings/workflow/index.d.ts +1 -1
  53. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  54. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  55. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  56. package/package.json +1 -1
  57. package/run-tests.sh +4 -1
  58. package/src/dev/hot-reload.test.ts +62 -11
  59. package/src/dev/hot-reload.ts +28 -7
  60. package/src/errors/error-handler.ts +8 -2
  61. package/src/errors/error.test.ts +2 -0
  62. package/src/function/function-runner.test.ts +500 -10
  63. package/src/function/functions.types.ts +3 -2
  64. package/src/index.ts +12 -2
  65. package/src/middleware/index.ts +11 -2
  66. package/src/middleware/telemetry.ts +2 -2
  67. package/src/middleware-runner.test.ts +16 -16
  68. package/src/middleware-runner.ts +42 -30
  69. package/src/permissions.test.ts +27 -24
  70. package/src/permissions.ts +41 -25
  71. package/src/pikku-request.test.ts +35 -0
  72. package/src/pikku-request.ts +1 -1
  73. package/src/pikku-state.ts +2 -3
  74. package/src/run-tests-script.test.ts +18 -0
  75. package/src/services/ai-agent-runner-service.ts +1 -0
  76. package/src/services/in-memory-queue-service.ts +1 -1
  77. package/src/services/in-memory-session-store.ts +1 -2
  78. package/src/services/in-memory-workflow-service.test.ts +33 -11
  79. package/src/services/in-memory-workflow-service.ts +49 -13
  80. package/src/services/local-content.test.ts +54 -0
  81. package/src/services/local-content.ts +12 -1
  82. package/src/services/typed-credential-service.ts +3 -3
  83. package/src/services/typed-secret-service.ts +3 -3
  84. package/src/services/typed-variables-service.ts +3 -3
  85. package/src/services/user-session-service.ts +4 -4
  86. package/src/testing/service-tests.ts +30 -0
  87. package/src/types/core.types.ts +6 -2
  88. package/src/types/state.types.ts +2 -13
  89. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  90. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  91. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  92. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  93. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  94. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  95. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  96. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  97. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  98. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  99. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  100. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  101. package/src/wirings/channel/channel-handler.test.ts +272 -0
  102. package/src/wirings/channel/channel-handler.ts +1 -1
  103. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  104. package/src/wirings/channel/channel-store.ts +19 -0
  105. package/src/wirings/channel/channel.types.ts +4 -0
  106. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  107. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  108. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  109. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  110. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  111. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  112. package/src/wirings/cli/cli-runner.test.ts +255 -2
  113. package/src/wirings/cli/cli-runner.ts +31 -10
  114. package/src/wirings/cli/cli.types.ts +4 -8
  115. package/src/wirings/cli/command-parser.test.ts +83 -0
  116. package/src/wirings/cli/command-parser.ts +23 -4
  117. package/src/wirings/http/http-runner.test.ts +296 -1
  118. package/src/wirings/http/http-runner.ts +87 -57
  119. package/src/wirings/http/http.types.ts +11 -26
  120. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  121. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  122. package/src/wirings/http/web-request.test.ts +115 -0
  123. package/src/wirings/http/web-request.ts +43 -5
  124. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  125. package/src/wirings/queue/queue.types.ts +2 -5
  126. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  127. package/src/wirings/rpc/rpc-runner.ts +15 -3
  128. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  129. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  130. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  131. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  132. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  133. package/src/wirings/workflow/index.ts +1 -0
  134. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  135. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  136. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  137. package/src/wirings/workflow/workflow.types.ts +68 -5
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -2,7 +2,13 @@ import { test, describe, beforeEach, afterEach } from 'node:test'
2
2
  import * as assert from 'assert'
3
3
  import { NotFoundError } from '../../errors/errors.js'
4
4
  import type { CorePikkuMiddleware } from '../../types/core.types.js'
5
- import { wireCLI, runCLICommand, pikkuCLIRender } from './cli-runner.js'
5
+ import {
6
+ CLIError,
7
+ executeCLI,
8
+ wireCLI,
9
+ runCLICommand,
10
+ pikkuCLIRender,
11
+ } from './cli-runner.js'
6
12
  import { pikkuState, resetPikkuState } from '../../pikku-state.js'
7
13
  import { addFunction } from '../../function/function-runner.js'
8
14
 
@@ -49,7 +55,14 @@ describe('CLI Runner', () => {
49
55
  programs: {
50
56
  'test-cli': {
51
57
  program: 'test-cli',
52
- commands: {},
58
+ commands: {
59
+ greet: {
60
+ command: 'greet <name>',
61
+ pikkuFuncId: 'greetFunc',
62
+ positionals: [{ name: 'name', required: true }],
63
+ options: {},
64
+ },
65
+ },
53
66
  options: {},
54
67
  },
55
68
  },
@@ -401,4 +414,244 @@ describe('CLI Runner', () => {
401
414
  assert.deepStrictEqual(receivedData, { test: 'value' })
402
415
  })
403
416
  })
417
+
418
+ describe('executeCLI', () => {
419
+ test('should require args to be provided', async () => {
420
+ await assert.rejects(
421
+ () =>
422
+ executeCLI({
423
+ programName: 'test-cli',
424
+ createSingletonServices: async () => singletonServices,
425
+ }),
426
+ /CLI arguments are required/
427
+ )
428
+ })
429
+
430
+ test('should throw CLIError when the program is missing from cli metadata', async () => {
431
+ await assert.rejects(
432
+ () =>
433
+ executeCLI({
434
+ programName: 'test-cli',
435
+ args: [],
436
+ createSingletonServices: async () => singletonServices,
437
+ }),
438
+ /CLI program "test-cli" not found/
439
+ )
440
+ })
441
+
442
+ test('should print help and return when --help is requested', async () => {
443
+ const logs: string[] = []
444
+ const originalLog = console.log
445
+ console.log = (message?: any) => {
446
+ logs.push(String(message))
447
+ }
448
+
449
+ pikkuState(null, 'cli', 'meta', {
450
+ programs: {
451
+ 'test-cli': {
452
+ program: 'test-cli',
453
+ description: 'Test CLI',
454
+ commands: {
455
+ greet: {
456
+ command: 'greet <name>',
457
+ description: 'Greets someone',
458
+ pikkuFuncId: 'greetFunc',
459
+ positionals: [{ name: 'name', required: true }],
460
+ options: {},
461
+ },
462
+ },
463
+ options: {},
464
+ },
465
+ },
466
+ renderers: {},
467
+ })
468
+
469
+ try {
470
+ await executeCLI({
471
+ programName: 'test-cli',
472
+ args: ['--help'],
473
+ createSingletonServices: async () => singletonServices,
474
+ })
475
+ } finally {
476
+ console.log = originalLog
477
+ }
478
+
479
+ assert.ok(logs[0]?.includes('test-cli'))
480
+ assert.ok(logs[0]?.includes('greet'))
481
+ })
482
+
483
+ test('should show help and throw CLIError for unknown commands', async () => {
484
+ const logs: string[] = []
485
+ const originalLog = console.log
486
+ console.log = (message?: any) => {
487
+ logs.push(String(message))
488
+ }
489
+
490
+ pikkuState(null, 'cli', 'meta', {
491
+ programs: {
492
+ 'test-cli': {
493
+ program: 'test-cli',
494
+ commands: {
495
+ greet: {
496
+ command: 'greet <name>',
497
+ description: 'Greets someone',
498
+ pikkuFuncId: 'greetFunc',
499
+ positionals: [{ name: 'name', required: true }],
500
+ options: {},
501
+ },
502
+ },
503
+ options: {},
504
+ },
505
+ },
506
+ renderers: {},
507
+ })
508
+
509
+ try {
510
+ await assert.rejects(
511
+ () =>
512
+ executeCLI({
513
+ programName: 'test-cli',
514
+ args: ['unknown'],
515
+ createSingletonServices: async () => singletonServices,
516
+ }),
517
+ (error: any) => {
518
+ assert.ok(error instanceof CLIError)
519
+ assert.strictEqual(error.message, 'Unknown command')
520
+ assert.strictEqual(error.exitCode, 1)
521
+ return true
522
+ }
523
+ )
524
+ } finally {
525
+ console.log = originalLog
526
+ }
527
+
528
+ assert.ok(logs[0]?.includes('test-cli'))
529
+ })
530
+
531
+ test('should print parse errors and throw CLIError', async () => {
532
+ const errors: string[] = []
533
+ const originalError = console.error
534
+ console.error = (message?: any) => {
535
+ errors.push(String(message))
536
+ }
537
+
538
+ pikkuState(null, 'cli', 'meta', {
539
+ programs: {
540
+ 'test-cli': {
541
+ program: 'test-cli',
542
+ commands: {
543
+ greet: {
544
+ command: 'greet <name>',
545
+ pikkuFuncId: 'greetFunc',
546
+ positionals: [{ name: 'name', required: true }],
547
+ options: {},
548
+ },
549
+ },
550
+ options: {},
551
+ },
552
+ },
553
+ renderers: {},
554
+ })
555
+
556
+ try {
557
+ await assert.rejects(
558
+ () =>
559
+ executeCLI({
560
+ programName: 'test-cli',
561
+ args: ['greet'],
562
+ createSingletonServices: async () => singletonServices,
563
+ }),
564
+ (error: any) => {
565
+ assert.ok(error instanceof CLIError)
566
+ assert.ok(error.message.includes('Missing required argument: name'))
567
+ return true
568
+ }
569
+ )
570
+ } finally {
571
+ console.error = originalError
572
+ }
573
+
574
+ assert.strictEqual(errors[0], 'Errors:')
575
+ assert.ok(errors[1]?.includes('Missing required argument: name'))
576
+ })
577
+
578
+ test('should create config and singleton services with parsed data and execute the command', async () => {
579
+ let receivedConfigData: any
580
+ let receivedConfig: any
581
+ let executed = false
582
+
583
+ pikkuState(null, 'cli', 'meta', {
584
+ programs: {
585
+ 'test-cli': {
586
+ program: 'test-cli',
587
+ commands: {
588
+ greet: {
589
+ command: 'greet <name>',
590
+ pikkuFuncId: 'greetFunc',
591
+ positionals: [{ name: 'name', required: true }],
592
+ options: {
593
+ loud: {
594
+ description: 'Use loud greeting',
595
+ short: 'l',
596
+ default: false,
597
+ },
598
+ },
599
+ },
600
+ },
601
+ options: {},
602
+ },
603
+ },
604
+ renderers: {},
605
+ })
606
+
607
+ pikkuState(null, 'cli', 'programs', {
608
+ 'test-cli': {
609
+ defaultRenderer: undefined,
610
+ middleware: [],
611
+ renderers: {},
612
+ },
613
+ })
614
+
615
+ pikkuState(null, 'function', 'meta', {
616
+ greetFunc: {
617
+ pikkuFuncId: 'greetFunc',
618
+ inputSchemaName: null,
619
+ outputSchemaName: null,
620
+ sessionless: true,
621
+ },
622
+ })
623
+
624
+ addFunction('greetFunc', {
625
+ func: async () => {
626
+ executed = true
627
+ return undefined
628
+ },
629
+ auth: false,
630
+ })
631
+
632
+ await executeCLI({
633
+ programName: 'test-cli',
634
+ args: ['greet', 'Alice', '--loud'],
635
+ createConfig: async (_variables, data) => {
636
+ receivedConfigData = data
637
+ return { config: true }
638
+ },
639
+ createSingletonServices: async (config) => {
640
+ receivedConfig = config
641
+ return singletonServices
642
+ },
643
+ })
644
+
645
+ assert.deepStrictEqual(receivedConfigData, {
646
+ name: 'Alice',
647
+ loud: true,
648
+ })
649
+ assert.deepStrictEqual(receivedConfig, { config: true })
650
+ assert.strictEqual(executed, true)
651
+ assert.strictEqual(
652
+ pikkuState(null, 'package', 'singletonServices'),
653
+ singletonServices
654
+ )
655
+ })
656
+ })
404
657
  })
@@ -324,6 +324,7 @@ export async function runCLICommand({
324
324
  programData?.renderers[commandId] || programData?.defaultRenderer
325
325
 
326
326
  // Create a CLI channel for progressive output
327
+ let cliState: unknown
327
328
  const channel: PikkuChannel<unknown, unknown> = {
328
329
  channelId: `cli:${program}:${commandId}`,
329
330
  openingData: pluckedData,
@@ -341,6 +342,13 @@ export async function runCLICommand({
341
342
  }
342
343
  },
343
344
  state: 'open',
345
+ setState: (s) => {
346
+ cliState = s
347
+ },
348
+ getState: () => cliState as any,
349
+ clearState: () => {
350
+ cliState = undefined
351
+ },
344
352
  }
345
353
 
346
354
  const userSession = new PikkuSessionService<CoreUserSession>(
@@ -374,16 +382,28 @@ export async function runCLICommand({
374
382
  packageName: currentCommand.packageName,
375
383
  })
376
384
 
377
- // Apply renderer one final time with the final output (if renderer exists)
378
- // Skip if result is undefined (void functions handle their own output)
379
- if (renderer && result !== undefined) {
380
- await Promise.resolve(
381
- renderer(
382
- singletonServices,
383
- result,
384
- userSession.get() as CoreUserSession | undefined
385
+ // Render the final result. `--json` / `--output json` routes the result
386
+ // through the global JSON renderer, but only for commands that opted into
387
+ // result-rendering by declaring a `render` — inline-printing commands (no
388
+ // per-command renderer) keep emitting their own output untouched.
389
+ // Skip if result is undefined (void functions handle their own output).
390
+ if (result !== undefined) {
391
+ const commandRenderer = programData?.renderers[commandId]
392
+ const jsonMode =
393
+ (data as any).json === true || (data as any).output === 'json'
394
+ const finalRenderer: CorePikkuCLIRender<any> | undefined =
395
+ jsonMode && commandRenderer !== undefined
396
+ ? defaultJSONRenderer
397
+ : (commandRenderer ?? programData?.defaultRenderer)
398
+ if (finalRenderer !== undefined) {
399
+ await Promise.resolve(
400
+ finalRenderer(
401
+ singletonServices,
402
+ result,
403
+ userSession.get() as CoreUserSession | undefined
404
+ )
385
405
  )
386
- )
406
+ }
387
407
  }
388
408
 
389
409
  return result
@@ -476,7 +496,8 @@ export async function executeCLI({
476
496
  const hasUnknownCommand = parsed.errors.some(
477
497
  (error) =>
478
498
  error.startsWith('Unknown command:') ||
479
- error.startsWith('Command not found:')
499
+ error.startsWith('Command not found:') ||
500
+ error.startsWith('Missing subcommand:')
480
501
  )
481
502
 
482
503
  if (hasUnknownCommand) {
@@ -256,10 +256,8 @@ export interface CoreCLICommand<
256
256
  PikkuPermission extends CorePikkuPermission<any, any, any>,
257
257
  PikkuMiddleware extends CorePikkuMiddleware,
258
258
  Options = any,
259
- Subcommands extends Record<
260
- string,
261
- CoreCLICommandConfig<any, any, any>
262
- > = Record<string, CoreCLICommandConfig<any, any, any>>,
259
+ Subcommands extends Record<string, CoreCLICommandConfig<any, any, any>> =
260
+ Record<string, CoreCLICommandConfig<any, any, any>>,
263
261
  > {
264
262
  parameters?: string
265
263
  func: PikkuFunctionConfig
@@ -301,10 +299,8 @@ export type CLICommandDefinition<
301
299
  PikkuPermission extends CorePikkuPermission<any, any, any>,
302
300
  PikkuMiddleware extends CorePikkuMiddleware,
303
301
  Options = any,
304
- Subcommands extends Record<
305
- string,
306
- CoreCLICommandConfig<any, any, any>
307
- > = Record<string, CoreCLICommandConfig<any, any, any>>,
302
+ Subcommands extends Record<string, CoreCLICommandConfig<any, any, any>> =
303
+ Record<string, CoreCLICommandConfig<any, any, any>>,
308
304
  > =
309
305
  | CoreCLICommand<
310
306
  In,
@@ -172,6 +172,23 @@ describe('Command Parser', () => {
172
172
  })
173
173
  })
174
174
 
175
+ test('should report missing subcommand for a group node with no func', () => {
176
+ const result = parseCLIArguments(['user'], 'test-cli', testMeta)
177
+
178
+ assert.deepStrictEqual(result.commandPath, ['user'])
179
+ assert.ok(
180
+ result.errors.some((error) => error.startsWith('Missing subcommand:')),
181
+ `expected a "Missing subcommand:" error, got: ${JSON.stringify(result.errors)}`
182
+ )
183
+ })
184
+
185
+ test('should not flag a runnable command as a missing subcommand', () => {
186
+ const result = parseCLIArguments(['greet', 'Alice'], 'test-cli', testMeta)
187
+
188
+ assert.deepStrictEqual(result.commandPath, ['greet'])
189
+ assert.strictEqual(result.errors.length, 0)
190
+ })
191
+
175
192
  test('should parse variadic positionals', () => {
176
193
  const result = parseCLIArguments(
177
194
  ['files', 'file1.txt', 'file2.txt', 'file3.txt'],
@@ -442,4 +459,70 @@ describe('Command Parser', () => {
442
459
  assert.ok(help.includes('Unknown command'))
443
460
  })
444
461
  })
462
+
463
+ describe('camelCase ↔ kebab-case options', () => {
464
+ const kebabMeta: CLIMeta = {
465
+ programs: {
466
+ 'test-cli': {
467
+ program: 'test-cli',
468
+ options: {},
469
+ commands: {
470
+ deploy: {
471
+ pikkuFuncId: 'deployFunc',
472
+ positionals: [],
473
+ options: {
474
+ autoApply: {
475
+ description: 'Deploy without the confirmation prompt',
476
+ default: false,
477
+ },
478
+ apiUrl: { description: 'Override the API URL' },
479
+ token: { description: 'Auth token', required: true },
480
+ },
481
+ },
482
+ },
483
+ },
484
+ },
485
+ renderers: {},
486
+ }
487
+
488
+ test('renders camelCase option keys as kebab-case in help', () => {
489
+ const help = generateCommandHelp('test-cli', kebabMeta, ['deploy'])
490
+
491
+ assert.ok(help.includes('--auto-apply'), 'expected --auto-apply')
492
+ assert.ok(help.includes('--api-url'), 'expected --api-url')
493
+ assert.ok(!help.includes('--autoApply'), 'should not show camelCase')
494
+ assert.ok(!help.includes('--apiUrl'), 'should not show camelCase')
495
+ })
496
+
497
+ test('accepts the kebab-case form and maps it to the camelCase field', () => {
498
+ const result = parseCLIArguments(
499
+ ['deploy', '--auto-apply', '--api-url', 'https://x'],
500
+ 'test-cli',
501
+ kebabMeta
502
+ )
503
+
504
+ assert.strictEqual(result.options.autoApply, true)
505
+ assert.strictEqual(result.options.apiUrl, 'https://x')
506
+ })
507
+
508
+ test('still accepts the camelCase form (back-compat)', () => {
509
+ const result = parseCLIArguments(
510
+ ['deploy', '--autoApply', '--apiUrl', 'https://x'],
511
+ 'test-cli',
512
+ kebabMeta
513
+ )
514
+
515
+ assert.strictEqual(result.options.autoApply, true)
516
+ assert.strictEqual(result.options.apiUrl, 'https://x')
517
+ })
518
+
519
+ test('renders missing-required-option errors in kebab-case', () => {
520
+ const result = parseCLIArguments(['deploy'], 'test-cli', kebabMeta)
521
+
522
+ assert.ok(
523
+ result.errors.some((e) => e.includes('--token')),
524
+ 'expected a missing --token error'
525
+ )
526
+ })
527
+ })
445
528
  })
@@ -11,6 +11,13 @@ function toCamelCase(str: string): string {
11
11
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
12
12
  }
13
13
 
14
+ /** Convert camelCase to kebab-case for display: "autoApply" → "auto-apply".
15
+ * Flags are stored camelCase (matching the function input field) but shown
16
+ * kebab; the parser accepts both forms via toCamelCase. */
17
+ function toKebabCase(str: string): string {
18
+ return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
19
+ }
20
+
14
21
  /**
15
22
  * Result of parsing CLI arguments
16
23
  */
@@ -111,6 +118,18 @@ export function parseCLIArguments(
111
118
  return result
112
119
  }
113
120
 
121
+ // A group/parent command — has subcommands but no runnable function of its
122
+ // own — cannot be executed directly. Surface a routable error so the runner
123
+ // shows its subcommand help instead of attempting to run a missing function.
124
+ if (
125
+ !commandMeta.pikkuFuncId &&
126
+ commandMeta.subcommands &&
127
+ Object.keys(commandMeta.subcommands).length > 0
128
+ ) {
129
+ result.errors.push(`Missing subcommand: ${result.commandPath.join(' ')}`)
130
+ return result
131
+ }
132
+
114
133
  // Collect all available options (global + inherited)
115
134
  const availableOptions = collectAvailableOptions(meta, result.commandPath)
116
135
 
@@ -366,7 +385,7 @@ function applyOptionDefaults(
366
385
 
367
386
  // Check required
368
387
  if (def.required && !(name in options)) {
369
- result.errors.push(`Missing required option: --${name}`)
388
+ result.errors.push(`Missing required option: --${toKebabCase(name)}`)
370
389
  }
371
390
 
372
391
  // Validate choices
@@ -376,13 +395,13 @@ function applyOptionDefaults(
376
395
  for (const v of value) {
377
396
  if (!def.choices.includes(v)) {
378
397
  result.errors.push(
379
- `Invalid value for --${name}: ${v}. Valid choices: ${def.choices.join(', ')}`
398
+ `Invalid value for --${toKebabCase(name)}: ${v}. Valid choices: ${def.choices.join(', ')}`
380
399
  )
381
400
  }
382
401
  }
383
402
  } else if (!def.choices.includes(value)) {
384
403
  result.errors.push(
385
- `Invalid value for --${name}: ${value}. Valid choices: ${def.choices.join(', ')}`
404
+ `Invalid value for --${toKebabCase(name)}: ${value}. Valid choices: ${def.choices.join(', ')}`
386
405
  )
387
406
  }
388
407
  }
@@ -487,7 +506,7 @@ function formatOptions(options: Record<string, CLIOption>, lines: string[]) {
487
506
  } else {
488
507
  line += ' '
489
508
  }
490
- line += `--${name}`
509
+ line += `--${toKebabCase(name)}`
491
510
 
492
511
  if (opt.default !== undefined && typeof opt.default !== 'boolean') {
493
512
  line += ` <value>`