@forinda/kickjs-cli 2.2.4 → 2.3.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.
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-cli v2.2.4
2
+ * @forinda/kickjs-cli v2.3.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -9,12 +9,13 @@
9
9
  * @license MIT
10
10
  */
11
11
  import { Command } from "commander";
12
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
12
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
14
14
  import { fileURLToPath, pathToFileURL } from "node:url";
15
15
  import { createInterface } from "node:readline";
16
- import { execSync, fork } from "node:child_process";
16
+ import { execSync, fork, spawn } from "node:child_process";
17
17
  import { access, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
18
+ import pkg from "pluralize";
18
19
  import { arch, platform, release } from "node:os";
19
20
  //#region \0rolldown/runtime.js
20
21
  var __defProp = Object.defineProperty;
@@ -742,18 +743,30 @@ export class UserService {
742
743
 
743
744
  ### Modules
744
745
 
745
- Register controllers and providers in modules:
746
+ Modules implement \`AppModule\` and wire controllers via \`buildRoutes()\`:
746
747
 
747
748
  \`\`\`ts
748
- import { Module } from '@forinda/kickjs'
749
+ import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'
749
750
  import { UserController } from './user.controller'
750
- import { UserService } from './user.service'
751
751
 
752
- @Module({
753
- controllers: [UserController],
754
- providers: [UserService],
755
- })
756
- export class UserModule {}
752
+ export class UserModule implements AppModule {
753
+ routes(): ModuleRoutes {
754
+ return {
755
+ path: '/users',
756
+ router: buildRoutes(UserController),
757
+ controller: UserController,
758
+ }
759
+ }
760
+ }
761
+ \`\`\`
762
+
763
+ Register all modules in \`src/modules/index.ts\`:
764
+
765
+ \`\`\`ts
766
+ import type { AppModuleClass } from '@forinda/kickjs'
767
+ import { UserModule } from './user/user.module'
768
+
769
+ export const modules: AppModuleClass[] = [UserModule]
757
770
  \`\`\`
758
771
 
759
772
  ### RequestContext
@@ -864,6 +877,86 @@ Hot-reload of \`.env\` changes during dev is wired up automatically via
864
877
  \`envWatchPlugin()\` in \`vite.config.ts\` — edit \`.env\`, the dev server
865
878
  reloads, and the next \`config.get()\` re-parses with the new values.
866
879
 
880
+ ### Standalone Env Utilities (No DI Required)
881
+
882
+ These functions work anywhere — scripts, CLI tools, plain files, outside \`@Service\`/\`@Controller\`:
883
+
884
+ \`\`\`ts
885
+ import { defineEnv, loadEnv, getEnv, reloadEnv, resetEnvCache, baseEnvSchema } from '@forinda/kickjs/config'
886
+ import { z } from 'zod'
887
+
888
+ // Define and parse schema
889
+ const schema = defineEnv((base) =>
890
+ base.extend({ DATABASE_URL: z.string().url() })
891
+ )
892
+ const env = loadEnv(schema) // Parse + validate process.env
893
+ console.log(env.PORT) // 3000 (coerced to number)
894
+ console.log(env.DATABASE_URL) // validated URL string
895
+
896
+ // Get single value
897
+ const port = getEnv('PORT') // typed after kick typegen
898
+
899
+ // Reload after .env changes (HMR calls this automatically)
900
+ reloadEnv()
901
+
902
+ // Reset cache in tests that swap schemas
903
+ resetEnvCache()
904
+ \`\`\`
905
+
906
+ | Function | Purpose |
907
+ |----------|---------|
908
+ | \`defineEnv(fn)\` | Extend base schema with custom Zod keys |
909
+ | \`loadEnv(schema?)\` | Parse \`process.env\`, validate, cache, return typed object |
910
+ | \`getEnv(key, schema?)\` | Get single validated env value |
911
+ | \`reloadEnv()\` | Re-read \`.env\` from disk, re-parse with same schema |
912
+ | \`resetEnvCache()\` | Clear parsed cache AND registered schema (for tests) |
913
+ | \`baseEnvSchema\` | Base Zod schema: \`PORT\`, \`NODE_ENV\`, \`LOG_LEVEL\` |
914
+
915
+ ## Standalone Utilities (No DI Required)
916
+
917
+ These utilities work outside decorated classes:
918
+
919
+ ### Logger
920
+
921
+ \`\`\`ts
922
+ import { Logger, createLogger } from '@forinda/kickjs'
923
+
924
+ const log = Logger.for('MyScript') // Static factory
925
+ log.info('Processing started')
926
+ log.error('Something failed')
927
+
928
+ const log2 = createLogger('Worker') // Function form
929
+ \`\`\`
930
+
931
+ ### Injection Tokens
932
+
933
+ \`\`\`ts
934
+ import { createToken } from '@forinda/kickjs'
935
+
936
+ // Type-safe DI tokens for factory/interface binding
937
+ const DB_URL = createToken<string>('config.database.url')
938
+ const FEATURE_FLAGS = createToken<FeatureFlags>('app.features')
939
+ \`\`\`
940
+
941
+ ### Reactivity
942
+
943
+ \`\`\`ts
944
+ import { ref, computed, watch, reactive } from '@forinda/kickjs'
945
+
946
+ const count = ref(0)
947
+ const doubled = computed(() => count.value * 2)
948
+ const stop = watch(() => count.value, (val) => console.log(val))
949
+ count.value++ // logs 1
950
+ \`\`\`
951
+
952
+ ### HTTP Errors
953
+
954
+ \`\`\`ts
955
+ import { HttpException, HttpStatus } from '@forinda/kickjs'
956
+
957
+ throw new HttpException(HttpStatus.NOT_FOUND, 'User not found')
958
+ \`\`\`
959
+
867
960
  ## Testing
868
961
 
869
962
  Tests live in \`src/**/*.test.ts\`:
@@ -898,7 +991,6 @@ Run tests:
898
991
  - \`@Roles('admin', 'user')\` — role-based access control
899
992
 
900
993
  ### DI Decorators
901
- - \`@Module({ controllers, providers, imports })\` — define module
902
994
  - \`@Service()\` — singleton service (DI-registered)
903
995
  - \`@Repository()\` — repository (semantic alias for @Service)
904
996
  - \`@Autowired()\` — property injection
@@ -976,7 +1068,7 @@ ${template === "ddd" ? `\`\`\`
976
1068
  ├── <name>.repository.ts # Data access (@Repository)
977
1069
  ├── <name>.dto.ts # Request/response schemas (Zod)
978
1070
  ├── <name>.entity.ts # Domain entity (optional)
979
- └── <name>.module.ts # Module definition (@Module)
1071
+ └── <name>.module.ts # Module definition (implements AppModule)
980
1072
  \`\`\`
981
1073
  ` : template === "cqrs" ? `\`\`\`
982
1074
  <name>/
@@ -990,7 +1082,7 @@ ${template === "ddd" ? `\`\`\`
990
1082
  │ └── <name>-created.event.ts
991
1083
  ├── <name>.controller.ts # HTTP routes
992
1084
  ├── <name>.repository.ts # Data access
993
- └── <name>.module.ts # Module definition
1085
+ └── <name>.module.ts # Module definition (implements AppModule)
994
1086
  \`\`\`
995
1087
  ` : template === "graphql" ? `\`\`\`
996
1088
  resolvers/
@@ -1003,7 +1095,7 @@ resolvers/
1003
1095
  ├── <name>.controller.ts # HTTP routes (@Controller)
1004
1096
  ├── <name>.service.ts # Business logic (@Service)
1005
1097
  ├── <name>.dto.ts # Request/response schemas (Zod)
1006
- └── <name>.module.ts # Module definition (@Module)
1098
+ └── <name>.module.ts # Module definition (implements AppModule)
1007
1099
  \`\`\`
1008
1100
  ` : `\`\`\`
1009
1101
  src/
@@ -1039,8 +1131,8 @@ If not using generators:
1039
1131
  - [ ] Create \`src/modules/<name>/<name>.controller.ts\`
1040
1132
  - [ ] Add \`@Controller('/path')\` decorator
1041
1133
  - [ ] Add route handlers with \`@Get()\`, \`@Post()\`, etc.
1042
- - [ ] Create module file with \`@Module({ controllers: [NameController] })\`
1043
- - [ ] Register module in \`src/modules/index.ts\`
1134
+ - [ ] Create module file implementing \`AppModule\` with \`routes()\` returning \`{ path, router: buildRoutes(Controller), controller }\`
1135
+ - [ ] Register module in \`src/modules/index.ts\` (\`AppModuleClass[]\` array)
1044
1136
  - [ ] Test with \`kick dev\`
1045
1137
 
1046
1138
  ### Manual Service
@@ -1048,7 +1140,7 @@ If not using generators:
1048
1140
  - [ ] Create \`src/modules/<name>/<name>.service.ts\`
1049
1141
  - [ ] Add \`@Service()\` decorator
1050
1142
  - [ ] Inject dependencies with \`@Autowired()\`
1051
- - [ ] Register in module \`providers\` array
1143
+ - [ ] Inject via \`@Autowired()\` where needed
1052
1144
  - [ ] Write unit tests
1053
1145
 
1054
1146
  ### New Middleware
@@ -1073,9 +1165,12 @@ Use \`kick add\` to install KickJS packages with correct peer dependencies:
1073
1165
  ### Generate CRUD Module
1074
1166
 
1075
1167
  \`\`\`bash
1076
- kick g scaffold user name:string email:string age:number
1168
+ kick g scaffold user name:string email:string:optional age:number
1077
1169
  \`\`\`
1078
1170
 
1171
+ Append \`:optional\` for optional fields (shell-safe, no quoting needed).
1172
+ Quoted \`?\` syntax also works: \`"email:string?"\` or \`"email?:string"\`.
1173
+
1079
1174
  This creates a full CRUD module with:
1080
1175
  - Controller with GET, POST, PUT, DELETE routes
1081
1176
  - Service with business logic
@@ -1191,7 +1286,17 @@ private config!: ConfigService
1191
1286
  const port = this.config.get('PORT') // typed: number
1192
1287
  \`\`\`
1193
1288
 
1194
- 3. **Direct \`process.env\`**avoid in app code; bypasses Zod
1289
+ 3. **Standalone utilities** (no DI works in scripts, CLI, plain files):
1290
+ \`\`\`ts
1291
+ import { loadEnv, getEnv, reloadEnv, resetEnvCache } from '@forinda/kickjs/config'
1292
+
1293
+ const env = loadEnv(schema) // Parse + validate all vars
1294
+ const port = getEnv('PORT') // Single value lookup
1295
+ reloadEnv() // Re-read .env from disk
1296
+ resetEnvCache() // Full reset (for tests)
1297
+ \`\`\`
1298
+
1299
+ 4. **Direct \`process.env\`** — avoid in app code; bypasses Zod
1195
1300
  coercion and the typed \`KickEnv\` registry.
1196
1301
 
1197
1302
  > **Pitfall**: never delete \`import './config'\` from \`src/index.ts\`.
@@ -1200,6 +1305,22 @@ const port = this.config.get('PORT') // typed: number
1200
1305
  > \`@Value()\` only works because of its raw \`process.env\` fallback —
1201
1306
  > Zod coercion + schema defaults are silently skipped.
1202
1307
 
1308
+ ## Standalone Utilities (No DI Required)
1309
+
1310
+ These work anywhere — scripts, plain files, outside \`@Service\`/\`@Controller\`:
1311
+
1312
+ | Utility | Import | Example |
1313
+ |---------|--------|---------|
1314
+ | \`Logger.for(name)\` | \`@forinda/kickjs\` | \`const log = Logger.for('MyScript')\` |
1315
+ | \`createLogger(name)\` | \`@forinda/kickjs\` | \`const log = createLogger('Worker')\` |
1316
+ | \`createToken<T>(name)\` | \`@forinda/kickjs\` | \`const TOKEN = createToken<string>('db.url')\` |
1317
+ | \`ref(value)\` | \`@forinda/kickjs\` | \`const count = ref(0)\` |
1318
+ | \`computed(fn)\` | \`@forinda/kickjs\` | \`const doubled = computed(() => count.value * 2)\` |
1319
+ | \`watch(source, cb)\` | \`@forinda/kickjs\` | \`watch(() => count.value, (v) => log(v))\` |
1320
+ | \`reactive(obj)\` | \`@forinda/kickjs\` | \`const state = reactive({ count: 0 })\` |
1321
+ | \`HttpException\` | \`@forinda/kickjs\` | \`throw new HttpException(404, 'Not found')\` |
1322
+ | \`HttpStatus\` | \`@forinda/kickjs\` | \`HttpStatus.NOT_FOUND // 404\` |
1323
+
1203
1324
  ## Key Decorators
1204
1325
 
1205
1326
  ### HTTP Routes
@@ -1214,7 +1335,7 @@ const port = this.config.get('PORT') // typed: number
1214
1335
  ### Dependency Injection
1215
1336
  | Decorator | Purpose |
1216
1337
  |-----------|---------|
1217
- | \`@Module({})\` | Define feature module |
1338
+ | \`AppModule\` interface | Define feature module (implements \`routes()\`) |
1218
1339
  | \`@Service()\` | Register singleton service |
1219
1340
  | \`@Repository()\` | Register repository |
1220
1341
  | \`@Autowired()\` | Property injection |
@@ -1310,6 +1431,26 @@ async function initProject(options) {
1310
1431
  await writeFileSafe(join(dir, "README.md"), generateReadme(name, template, packageManager));
1311
1432
  await writeFileSafe(join(dir, "CLAUDE.md"), generateClaude(name, template, packageManager));
1312
1433
  await writeFileSafe(join(dir, "AGENTS.md"), generateAgents(name, template, packageManager));
1434
+ if (options.installDeps) {
1435
+ console.log(`\n Installing dependencies with ${packageManager}...\n`);
1436
+ try {
1437
+ execSync(`${packageManager} install`, {
1438
+ cwd: dir,
1439
+ stdio: "inherit"
1440
+ });
1441
+ console.log("\n Dependencies installed successfully!");
1442
+ } catch {
1443
+ console.log(`\n Warning: ${packageManager} install failed. Run it manually.`);
1444
+ }
1445
+ }
1446
+ try {
1447
+ const { runTypegen } = await Promise.resolve().then(() => typegen_exports);
1448
+ await runTypegen({
1449
+ cwd: dir,
1450
+ allowDuplicates: true,
1451
+ silent: true
1452
+ });
1453
+ } catch {}
1313
1454
  if (options.initGit) try {
1314
1455
  execSync("git init", {
1315
1456
  cwd: dir,
@@ -1331,26 +1472,6 @@ async function initProject(options) {
1331
1472
  } catch {
1332
1473
  log("Warning: git init failed (git may not be installed)");
1333
1474
  }
1334
- if (options.installDeps) {
1335
- console.log(`\n Installing dependencies with ${packageManager}...\n`);
1336
- try {
1337
- execSync(`${packageManager} install`, {
1338
- cwd: dir,
1339
- stdio: "inherit"
1340
- });
1341
- console.log("\n Dependencies installed successfully!");
1342
- } catch {
1343
- console.log(`\n Warning: ${packageManager} install failed. Run it manually.`);
1344
- }
1345
- }
1346
- try {
1347
- const { runTypegen } = await Promise.resolve().then(() => typegen_exports);
1348
- await runTypegen({
1349
- cwd: dir,
1350
- allowDuplicates: true,
1351
- silent: true
1352
- });
1353
- } catch {}
1354
1475
  console.log("\n Project scaffolded successfully!");
1355
1476
  console.log();
1356
1477
  const needsCd = dir !== process.cwd();
@@ -1522,26 +1643,18 @@ function toKebabCase(name) {
1522
1643
  }
1523
1644
  /**
1524
1645
  * Pluralize a kebab-case name for directory/file names.
1525
- * If already plural (ends in 's'), returns as-is.
1646
+ * Uses the `pluralize` npm package for correct English pluralization
1647
+ * including irregulars (person → people, status → statuses, child → children).
1526
1648
  */
1527
1649
  function pluralize(name) {
1528
- if (name.endsWith("s")) return name;
1529
- if (name.endsWith("x") || name.endsWith("z")) return name + "es";
1530
- if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
1531
- if (name.endsWith("y") && !/[aeiou]y$/.test(name)) return name.slice(0, -1) + "ies";
1532
- return name + "s";
1650
+ return pkg.plural(name);
1533
1651
  }
1534
1652
  /**
1535
1653
  * Pluralize a PascalCase name for class identifiers.
1536
- * If already plural (ends in 's'), returns as-is.
1537
1654
  * Used for `List${pluralPascal}UseCase` to avoid `ListUserssUseCase`.
1538
1655
  */
1539
1656
  function pluralizePascal(name) {
1540
- if (name.endsWith("s")) return name;
1541
- if (name.endsWith("x") || name.endsWith("z")) return name + "es";
1542
- if (name.endsWith("sh") || name.endsWith("ch")) return name + "es";
1543
- if (name.endsWith("y") && !/[aeiou]y$/i.test(name)) return name.slice(0, -1) + "ies";
1544
- return name + "s";
1657
+ return pkg.plural(name);
1545
1658
  }
1546
1659
  //#endregion
1547
1660
  //#region src/generators/templates/module-index.ts
@@ -3541,6 +3654,140 @@ export class ${pascal}Adapter implements AppAdapter {
3541
3654
  return files;
3542
3655
  }
3543
3656
  //#endregion
3657
+ //#region src/generators/plugin.ts
3658
+ /**
3659
+ * Scaffold a `KickPlugin` under `src/plugins/<name>.plugin.ts`.
3660
+ *
3661
+ * Plugins are the canonical place to wire DI bindings, load extra
3662
+ * modules, add middleware, or attach startup hooks without writing a
3663
+ * full adapter. The generated template implements every optional
3664
+ * `KickPlugin` hook with commented examples so users can uncomment
3665
+ * the ones they need and delete the rest.
3666
+ */
3667
+ async function generatePlugin(options) {
3668
+ const { name, outDir } = options;
3669
+ const kebab = toKebabCase(name);
3670
+ const pascal = toPascalCase(name);
3671
+ const factoryName = `${toCamelCase(name)}Plugin`;
3672
+ const files = [];
3673
+ const filePath = join(outDir, `${kebab}.plugin.ts`);
3674
+ await writeFileSafe(filePath, `import type { KickPlugin, Container, AppAdapter, AppModuleClass } from '@forinda/kickjs'
3675
+
3676
+ /**
3677
+ * Options for the ${pascal} plugin.
3678
+ *
3679
+ * Plugins typically take a small options object in their factory so
3680
+ * callers can configure them inline at bootstrap time. Keep the
3681
+ * shape narrow — anything derived from the environment should be
3682
+ * read via \`getEnv\` inside the plugin itself, not forced onto the
3683
+ * caller.
3684
+ */
3685
+ export interface ${pascal}PluginOptions {
3686
+ // Add your plugin options here, for example:
3687
+ // enabled?: boolean
3688
+ // apiKey?: string
3689
+ }
3690
+
3691
+ /**
3692
+ * ${pascal} plugin.
3693
+ *
3694
+ * A \`KickPlugin\` bundles DI bindings, modules, adapters, and
3695
+ * middleware into one object that can be added to \`bootstrap({ plugins })\`.
3696
+ * Every hook is optional — delete the ones you don't need and keep
3697
+ * only the surface your plugin actually uses.
3698
+ *
3699
+ * Lifecycle order:
3700
+ *
3701
+ * 1. \`register(container)\` — runs before user modules load. Use
3702
+ * it to bind services that modules depend on.
3703
+ * 2. \`modules()\` — plugin modules load before user modules.
3704
+ * 3. \`adapters()\` — plugin adapters are added before user adapters.
3705
+ * 4. \`middleware()\` — plugin middleware runs before user middleware.
3706
+ * 5. \`onReady(container)\` — runs after the app has fully bootstrapped.
3707
+ * 6. \`shutdown()\` — runs on graceful shutdown.
3708
+ *
3709
+ * @example
3710
+ * \`\`\`ts
3711
+ * import { bootstrap } from '@forinda/kickjs'
3712
+ * import { ${factoryName} } from './plugins/${kebab}.plugin'
3713
+ *
3714
+ * export const app = await bootstrap({
3715
+ * modules,
3716
+ * plugins: [${factoryName}({ /* options */ })],
3717
+ * })
3718
+ * \`\`\`
3719
+ */
3720
+ export function ${factoryName}(options: ${pascal}PluginOptions = {}): KickPlugin {
3721
+ return {
3722
+ name: '${kebab}',
3723
+
3724
+ /**
3725
+ * Register DI bindings before modules load.
3726
+ * Use \`container.registerInstance(TOKEN, value)\` for singletons
3727
+ * and \`container.registerFactory(TOKEN, () => ...)\` for lazy
3728
+ * constructions.
3729
+ */
3730
+ register(container: Container): void {
3731
+ // Example: bind a configured service to a DI token
3732
+ // container.registerInstance(MY_TOKEN, new MyService(options))
3733
+ },
3734
+
3735
+ /**
3736
+ * Return module classes this plugin contributes to the app.
3737
+ * These load before user modules, so plugin controllers and
3738
+ * services are available for user code to \`@Autowired\`.
3739
+ */
3740
+ modules(): AppModuleClass[] {
3741
+ return [
3742
+ // ExampleModule,
3743
+ ]
3744
+ },
3745
+
3746
+ /**
3747
+ * Return adapter instances to be added to the application.
3748
+ * Plugin adapters are added before user adapters.
3749
+ */
3750
+ adapters(): AppAdapter[] {
3751
+ return [
3752
+ // new MyAdapter({ ... }),
3753
+ ]
3754
+ },
3755
+
3756
+ /**
3757
+ * Return Express middleware entries to be added to the global
3758
+ * pipeline. Plugin middleware runs before user-defined middleware.
3759
+ */
3760
+ middleware(): any[] {
3761
+ return [
3762
+ // helmet(),
3763
+ // myCustomMiddleware(options),
3764
+ ]
3765
+ },
3766
+
3767
+ /**
3768
+ * Called after the application has fully bootstrapped. Use this
3769
+ * for post-startup work like logging, health checks, or warming
3770
+ * a cache. Runs once per process.
3771
+ */
3772
+ async onReady(container: Container): Promise<void> {
3773
+ // const logger = container.resolve(Logger)
3774
+ // logger.info('${pascal} plugin ready')
3775
+ },
3776
+
3777
+ /**
3778
+ * Called during graceful shutdown. Clean up any long-lived
3779
+ * resources this plugin owns (connections, timers, subscriptions).
3780
+ */
3781
+ async shutdown(): Promise<void> {
3782
+ // await this.connection?.close()
3783
+ },
3784
+ }
3785
+ }
3786
+ `);
3787
+ files.push(filePath);
3788
+ return files;
3789
+ }
3790
+ //#endregion
3544
3791
  //#region src/utils/resolve-out-dir.ts
3545
3792
  /**
3546
3793
  * DDD folder mapping — nested layered architecture.
@@ -3585,13 +3832,14 @@ const CQRS_FOLDER_MAP = {
3585
3832
  * 3. Standalone default directory
3586
3833
  */
3587
3834
  function resolveOutDir(options) {
3588
- const { type, outDir, moduleName, modulesDir = "src/modules", defaultDir, pattern = "ddd" } = options;
3835
+ const { type, outDir, moduleName, modulesDir = "src/modules", defaultDir, pattern = "ddd", shouldPluralize = true } = options;
3589
3836
  if (outDir) return resolve(outDir);
3590
3837
  if (moduleName) {
3591
3838
  const folderMap = pattern === "ddd" ? DDD_FOLDER_MAP : pattern === "cqrs" ? CQRS_FOLDER_MAP : FLAT_FOLDER_MAP;
3592
- const plural = pluralize(toKebabCase(moduleName));
3839
+ const kebab = toKebabCase(moduleName);
3840
+ const folder = shouldPluralize ? pluralize(kebab) : kebab;
3593
3841
  const subfolder = folderMap[type] ?? "";
3594
- const base = join(modulesDir, plural);
3842
+ const base = join(modulesDir, folder);
3595
3843
  return resolve(subfolder ? join(base, subfolder) : base);
3596
3844
  }
3597
3845
  return resolve(defaultDir);
@@ -3606,7 +3854,8 @@ async function generateMiddleware(options) {
3606
3854
  moduleName,
3607
3855
  modulesDir,
3608
3856
  defaultDir: "src/middleware",
3609
- pattern
3857
+ pattern,
3858
+ shouldPluralize: options.pluralize ?? true
3610
3859
  });
3611
3860
  const kebab = toKebabCase(name);
3612
3861
  const camel = toCamelCase(name);
@@ -3650,7 +3899,8 @@ async function generateGuard(options) {
3650
3899
  moduleName,
3651
3900
  modulesDir,
3652
3901
  defaultDir: "src/guards",
3653
- pattern
3902
+ pattern,
3903
+ shouldPluralize: options.pluralize ?? true
3654
3904
  });
3655
3905
  const kebab = toKebabCase(name);
3656
3906
  const camel = toCamelCase(name);
@@ -3707,7 +3957,8 @@ async function generateService(options) {
3707
3957
  moduleName,
3708
3958
  modulesDir,
3709
3959
  defaultDir: "src/services",
3710
- pattern
3960
+ pattern,
3961
+ shouldPluralize: options.pluralize ?? true
3711
3962
  });
3712
3963
  const kebab = toKebabCase(name);
3713
3964
  const pascal = toPascalCase(name);
@@ -3736,7 +3987,8 @@ async function generateController(options) {
3736
3987
  moduleName,
3737
3988
  modulesDir,
3738
3989
  defaultDir: "src/controllers",
3739
- pattern
3990
+ pattern,
3991
+ shouldPluralize: options.pluralize ?? true
3740
3992
  });
3741
3993
  const kebab = toKebabCase(name);
3742
3994
  const pascal = toPascalCase(name);
@@ -3777,7 +4029,8 @@ async function generateDto(options) {
3777
4029
  moduleName,
3778
4030
  modulesDir,
3779
4031
  defaultDir: "src/dtos",
3780
- pattern
4032
+ pattern,
4033
+ shouldPluralize: options.pluralize ?? true
3781
4034
  });
3782
4035
  const kebab = toKebabCase(name);
3783
4036
  const pascal = toPascalCase(name);
@@ -3996,7 +4249,10 @@ export class ${pascal}Job {
3996
4249
  * json → z.any()
3997
4250
  * enum:a,b → z.enum(['a','b'])
3998
4251
  *
3999
- * Append ? for optional: title:string body:text? published:boolean?
4252
+ * Mark optional fields three equivalent syntaxes:
4253
+ * body:text:optional ← recommended (shell-safe, no quoting needed)
4254
+ * body?:text ← needs quoting in bash/zsh ("body?:text")
4255
+ * body:text? ← needs quoting in bash/zsh ("body:text?")
4000
4256
  */
4001
4257
  const TYPE_MAP = {
4002
4258
  string: {
@@ -4048,11 +4304,23 @@ function parseFields(raw) {
4048
4304
  return raw.map((f) => {
4049
4305
  const colonIdx = f.indexOf(":");
4050
4306
  if (colonIdx === -1) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
4051
- const namePart = f.slice(0, colonIdx);
4052
- const typePart = f.slice(colonIdx + 1);
4307
+ let namePart = f.slice(0, colonIdx);
4308
+ let typePart = f.slice(colonIdx + 1);
4053
4309
  if (!namePart || !typePart) throw new Error(`Invalid field: "${f}". Use format: name:type (e.g. title:string)`);
4054
- const optional = typePart.endsWith("?");
4055
- const cleanType = optional ? typePart.slice(0, -1) : typePart;
4310
+ let optional = false;
4311
+ if (typePart.endsWith(":optional")) {
4312
+ typePart = typePart.slice(0, -9);
4313
+ optional = true;
4314
+ }
4315
+ if (namePart.endsWith("?")) {
4316
+ namePart = namePart.slice(0, -1);
4317
+ optional = true;
4318
+ }
4319
+ if (typePart.endsWith("?")) {
4320
+ typePart = typePart.slice(0, -1);
4321
+ optional = true;
4322
+ }
4323
+ const cleanType = typePart;
4056
4324
  if (cleanType.startsWith("enum:")) {
4057
4325
  const values = cleanType.slice(5).split(",");
4058
4326
  return {
@@ -4517,14 +4785,16 @@ async function autoRegisterModule(modulesDir, pascal, plural) {
4517
4785
  //#region src/generators/test.ts
4518
4786
  async function generateTest(options) {
4519
4787
  const { name, moduleName, modulesDir } = options;
4788
+ const shouldPluralize = options.pluralize ?? true;
4520
4789
  const kebab = toKebabCase(name);
4521
4790
  const pascal = toPascalCase(name);
4522
4791
  const files = [];
4523
4792
  let outDir;
4524
4793
  if (options.outDir) outDir = resolve(options.outDir);
4525
4794
  else if (moduleName) {
4526
- const modPlural = pluralize(toKebabCase(moduleName));
4527
- outDir = resolve(join(modulesDir ?? "src/modules", modPlural, "__tests__"));
4795
+ const modKebab = toKebabCase(moduleName);
4796
+ const modFolder = shouldPluralize ? pluralize(modKebab) : modKebab;
4797
+ outDir = resolve(join(modulesDir ?? "src/modules", modFolder, "__tests__"));
4528
4798
  } else outDir = resolve("src/__tests__");
4529
4799
  const filePath = join(outDir, `${kebab}.test.ts`);
4530
4800
  await writeFileSafe(filePath, `import { describe, it, expect, beforeEach } from 'vitest'
@@ -5699,56 +5969,72 @@ function registerGenerateCommand(program) {
5699
5969
  outDir: resolve(opts.out)
5700
5970
  }), dryRun);
5701
5971
  });
5972
+ gen.command("plugin <name>").description("Generate a KickPlugin with DI, modules, adapters, middleware, and lifecycle hooks").option("-o, --out <dir>", "Output directory", "src/plugins").action(async (name, opts, cmd) => {
5973
+ const dryRun = isDryRun(cmd);
5974
+ setDryRun(dryRun);
5975
+ printGenerated(await generatePlugin({
5976
+ name,
5977
+ outDir: resolve(opts.out)
5978
+ }), dryRun);
5979
+ });
5702
5980
  gen.command("middleware <name>").description("Generate an Express middleware function\n Use -m to scope it to a module: kick g middleware auth -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
5703
5981
  const dryRun = isDryRun(cmd);
5704
5982
  setDryRun(dryRun);
5705
5983
  const config = await loadKickConfig(process.cwd());
5706
- const modulesDir = resolveModuleConfig(config).dir ?? "src/modules";
5984
+ const mc = resolveModuleConfig(config);
5985
+ const modulesDir = mc.dir ?? "src/modules";
5707
5986
  printGenerated(await generateMiddleware({
5708
5987
  name,
5709
5988
  outDir: opts.out,
5710
5989
  moduleName: opts.module,
5711
5990
  modulesDir,
5712
- pattern: config?.pattern
5991
+ pattern: config?.pattern,
5992
+ pluralize: mc.pluralize ?? true
5713
5993
  }), dryRun);
5714
5994
  });
5715
5995
  gen.command("guard <name>").description("Generate a route guard (auth, roles, etc.)\n Use -m to scope it to a module: kick g guard admin -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
5716
5996
  const dryRun = isDryRun(cmd);
5717
5997
  setDryRun(dryRun);
5718
5998
  const config = await loadKickConfig(process.cwd());
5719
- const modulesDir = resolveModuleConfig(config).dir ?? "src/modules";
5999
+ const mc = resolveModuleConfig(config);
6000
+ const modulesDir = mc.dir ?? "src/modules";
5720
6001
  printGenerated(await generateGuard({
5721
6002
  name,
5722
6003
  outDir: opts.out,
5723
6004
  moduleName: opts.module,
5724
6005
  modulesDir,
5725
- pattern: config?.pattern
6006
+ pattern: config?.pattern,
6007
+ pluralize: mc.pluralize ?? true
5726
6008
  }), dryRun);
5727
6009
  });
5728
6010
  gen.command("service <name>").description("Generate a @Service() class\n Use -m to scope it to a module: kick g service payment -m orders").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
5729
6011
  const dryRun = isDryRun(cmd);
5730
6012
  setDryRun(dryRun);
5731
6013
  const config = await loadKickConfig(process.cwd());
5732
- const modulesDir = resolveModuleConfig(config).dir ?? "src/modules";
6014
+ const mc = resolveModuleConfig(config);
6015
+ const modulesDir = mc.dir ?? "src/modules";
5733
6016
  printGenerated(await generateService({
5734
6017
  name,
5735
6018
  outDir: opts.out,
5736
6019
  moduleName: opts.module,
5737
6020
  modulesDir,
5738
- pattern: config?.pattern
6021
+ pattern: config?.pattern,
6022
+ pluralize: mc.pluralize ?? true
5739
6023
  }), dryRun);
5740
6024
  });
5741
6025
  gen.command("controller <name>").description("Generate a @Controller() class with basic routes\n Use -m to scope it to a module: kick g controller auth -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module folder").action(async (name, opts, cmd) => {
5742
6026
  const dryRun = isDryRun(cmd);
5743
6027
  setDryRun(dryRun);
5744
6028
  const config = await loadKickConfig(process.cwd());
5745
- const modulesDir = resolveModuleConfig(config).dir ?? "src/modules";
6029
+ const mc = resolveModuleConfig(config);
6030
+ const modulesDir = mc.dir ?? "src/modules";
5746
6031
  printGenerated(await generateController({
5747
6032
  name,
5748
6033
  outDir: opts.out,
5749
6034
  moduleName: opts.module,
5750
6035
  modulesDir,
5751
- pattern: config?.pattern
6036
+ pattern: config?.pattern,
6037
+ pluralize: mc.pluralize ?? true
5752
6038
  }), dryRun);
5753
6039
  await runPostTypegen(dryRun);
5754
6040
  });
@@ -5756,24 +6042,28 @@ function registerGenerateCommand(program) {
5756
6042
  const dryRun = isDryRun(cmd);
5757
6043
  setDryRun(dryRun);
5758
6044
  const config = await loadKickConfig(process.cwd());
5759
- const modulesDir = resolveModuleConfig(config).dir ?? "src/modules";
6045
+ const mc = resolveModuleConfig(config);
6046
+ const modulesDir = mc.dir ?? "src/modules";
5760
6047
  printGenerated(await generateDto({
5761
6048
  name,
5762
6049
  outDir: opts.out,
5763
6050
  moduleName: opts.module,
5764
6051
  modulesDir,
5765
- pattern: config?.pattern
6052
+ pattern: config?.pattern,
6053
+ pluralize: mc.pluralize ?? true
5766
6054
  }), dryRun);
5767
6055
  });
5768
6056
  gen.command("test <name>").description("Generate a Vitest test scaffold\n Use -m to scope it to a module: kick g test user-service -m users").option("-o, --out <dir>", "Output directory (overrides --module)").option("-m, --module <module>", "Place inside a module's __tests__/ folder").action(async (name, opts, cmd) => {
5769
6057
  const dryRun = isDryRun(cmd);
5770
6058
  setDryRun(dryRun);
5771
- const modulesDir = resolveModuleConfig(await loadKickConfig(process.cwd())).dir ?? "src/modules";
6059
+ const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
6060
+ const modulesDir = mc.dir ?? "src/modules";
5772
6061
  printGenerated(await generateTest({
5773
6062
  name,
5774
6063
  outDir: opts.out,
5775
6064
  moduleName: opts.module,
5776
- modulesDir
6065
+ modulesDir,
6066
+ pluralize: mc.pluralize ?? true
5777
6067
  }), dryRun);
5778
6068
  });
5779
6069
  gen.command("resolver <name>").description("Generate a GraphQL @Resolver class with @Query and @Mutation methods").option("-o, --out <dir>", "Output directory", "src/resolvers").action(async (name, opts, cmd) => {
@@ -5793,11 +6083,11 @@ function registerGenerateCommand(program) {
5793
6083
  queue: opts.queue
5794
6084
  }), dryRun);
5795
6085
  });
5796
- gen.command("scaffold <name> [fields...]").description("Generate a full CRUD module from field definitions\n Example: kick g scaffold Post title:string body:text published:boolean?\n Types: string, text, number, int, float, boolean, date, email, url, uuid, json, enum:a,b,c\n Append ? for optional fields: description:text?").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("--modules-dir <dir>", "Modules directory").action(async (name, rawFields, opts, cmd) => {
6086
+ gen.command("scaffold <name> [fields...]").description("Generate a full CRUD module from field definitions\n Example: kick g scaffold Post title:string body:text:optional published:boolean:optional\n Types: string, text, number, int, float, boolean, date, email, url, uuid, json, enum:a,b,c\n Optional: append :optional (shell-safe): description:text:optional\n or use ? with quoting: \"description:text?\" or \"description?:text\"").option("--no-entity", "Skip entity and value object generation").option("--no-tests", "Skip test file generation").option("--no-pluralize", "Use singular names (skip auto-pluralization)").option("--modules-dir <dir>", "Modules directory").action(async (name, rawFields, opts, cmd) => {
5797
6087
  const dryRun = isDryRun(cmd);
5798
6088
  setDryRun(dryRun);
5799
6089
  if (rawFields.length === 0) {
5800
- console.error("\n Error: At least one field is required.\n Usage: kick g scaffold <name> <field:type> [field:type...]\n Example: kick g scaffold Post title:string body:text published:boolean\n");
6090
+ console.error("\n Error: At least one field is required.\n Usage: kick g scaffold <name> <field:type> [field:type...]\n Example: kick g scaffold Post title:string body:text:optional published:boolean:optional\n Optional: append :optional (shell-safe, no quoting needed)\n");
5801
6091
  process.exit(1);
5802
6092
  }
5803
6093
  const mc = resolveModuleConfig(await loadKickConfig(process.cwd()));
@@ -6292,6 +6582,11 @@ const PACKAGE_REGISTRY = {
6292
6582
  peers: [],
6293
6583
  description: "Multi-channel notifications — email, Slack, Discord, webhook"
6294
6584
  },
6585
+ mcp: {
6586
+ pkg: "@forinda/kickjs-mcp",
6587
+ peers: ["@modelcontextprotocol/sdk"],
6588
+ description: "Model Context Protocol server — expose @Controller endpoints as AI tools"
6589
+ },
6295
6590
  testing: {
6296
6591
  pkg: "@forinda/kickjs-testing",
6297
6592
  peers: [],
@@ -6375,6 +6670,616 @@ function registerAddCommand(program) {
6375
6670
  });
6376
6671
  }
6377
6672
  //#endregion
6673
+ //#region src/explain/known-issues.ts
6674
+ function includesAll(haystack, needles) {
6675
+ const lower = haystack.toLowerCase();
6676
+ return needles.every((n) => lower.includes(n.toLowerCase()));
6677
+ }
6678
+ function includesAny(haystack, needles) {
6679
+ const lower = haystack.toLowerCase();
6680
+ return needles.some((n) => lower.includes(n.toLowerCase()));
6681
+ }
6682
+ const KNOWN_ISSUES = [
6683
+ { match(input, _ctx) {
6684
+ const hasConfigGetUndefined = includesAll(input, ["config", "get"]) && includesAny(input, ["undefined", "null"]);
6685
+ const hasValueUndefined = input.includes("@Value") && includesAny(input, ["undefined", "is not defined"]);
6686
+ if (!hasConfigGetUndefined && !hasValueUndefined) return null;
6687
+ return {
6688
+ confidence: hasConfigGetUndefined && hasValueUndefined ? 90 : 75,
6689
+ diagnosis: {
6690
+ id: "env-schema-not-registered",
6691
+ title: "ConfigService.get() returns undefined for user-defined keys",
6692
+ explanation: "Your src/index.ts is missing `import \"./config\"`. That side-effect import\nregisters the env schema with kickjs at module-load time. Without it,\nConfigService falls back to the base schema (PORT/NODE_ENV/LOG_LEVEL only)\nand every user-defined key reads as undefined. @Value() may *appear* to\nwork via a raw process.env fallback, but Zod coercion and schema defaults\nare silently skipped.",
6693
+ fix: "Add this line to src/index.ts near the top, before bootstrap() runs:",
6694
+ codeBefore: "import 'reflect-metadata'\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
6695
+ codeAfter: "import 'reflect-metadata'\nimport './config' // ← add this — registers env schema\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n",
6696
+ docs: "https://forinda.github.io/kick-js/guide/configuration.html#wiring-the-schema-at-startup"
6697
+ }
6698
+ };
6699
+ } },
6700
+ { match(input, _ctx) {
6701
+ const hasTestContext = includesAny(input, [
6702
+ "vitest",
6703
+ "test",
6704
+ "spec",
6705
+ "__tests__",
6706
+ ".test."
6707
+ ]);
6708
+ if (!includesAny(input, [
6709
+ "already registered",
6710
+ "already exists",
6711
+ "duplicate",
6712
+ "has been registered"
6713
+ ])) return null;
6714
+ return {
6715
+ confidence: hasTestContext ? 85 : 60,
6716
+ diagnosis: {
6717
+ id: "container-not-reset-in-tests",
6718
+ title: "DI container leaks between test cases",
6719
+ explanation: "KickJS decorators register classes on the global Container at import time.\nWhen vitest re-imports your modules across tests, the same class can be\nregistered twice and the container throws. The fix is to wipe the\ncontainer between tests so each case starts fresh.",
6720
+ fix: "Add Container.reset() to a beforeEach hook in the failing test file:",
6721
+ codeAfter: "import { describe, it, beforeEach } from 'vitest'\nimport { Container } from '@forinda/kickjs'\n\ndescribe('UserController', () => {\n beforeEach(() => Container.reset())\n\n it('does the thing', async () => { /* ... */ })\n})",
6722
+ docs: "https://forinda.github.io/kick-js/guide/testing.html"
6723
+ }
6724
+ };
6725
+ } },
6726
+ { match(input, _ctx) {
6727
+ if (!(input.includes("@Module") || includesAll(input, ["Module", "is not a function"]) || includesAll(input, ["Module", "no exported member"]))) return null;
6728
+ return {
6729
+ confidence: 80,
6730
+ diagnosis: {
6731
+ id: "module-decorator-not-found",
6732
+ title: "KickJS does not have a @Module decorator (different pattern from NestJS)",
6733
+ explanation: "NestJS uses @Module({ controllers, providers }). KickJS uses an interface\npattern instead: a class implements AppModule and exposes routes() that\nreturns the controller wiring. This was a deliberate choice — modules\nbecome explicit values rather than metadata, which makes them easier to\ncompose, test, and serialize.",
6734
+ fix: "Replace the @Module decorator with an AppModule class:",
6735
+ codeBefore: "import { Module } from '@forinda/kickjs' // ← does not exist\nimport { UserController } from './user.controller'\n\n@Module({\n controllers: [UserController],\n})\nexport class UserModule {}",
6736
+ codeAfter: "import { type AppModule, type ModuleRoutes, buildRoutes } from '@forinda/kickjs'\nimport { UserController } from './user.controller'\n\nexport class UserModule implements AppModule {\n routes(): ModuleRoutes {\n return {\n path: '/users',\n router: buildRoutes(UserController),\n controller: UserController,\n }\n }\n}",
6737
+ docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
6738
+ }
6739
+ };
6740
+ } },
6741
+ { match(input, _ctx) {
6742
+ if (!/KickRoutes\s*\[\s*['"](GET|POST|PUT|PATCH|DELETE)/i.test(input)) return null;
6743
+ return {
6744
+ confidence: 95,
6745
+ diagnosis: {
6746
+ id: "legacy-kick-routes-bracket-syntax",
6747
+ title: "KickRoutes['POST /users'] is the legacy v1 syntax",
6748
+ explanation: "KickJS v2 changed the typegen output from a flat string-keyed map to a\nnamespaced shape: KickRoutes.UserController[\"create\"] instead of\nKickRoutes[\"POST /users\"]. The new form is per-controller, per-method,\nand matches the actual class names so refactors propagate via\nrename-symbol instead of grep.",
6749
+ fix: "Update the Ctx<...> type parameter to use the namespace form:",
6750
+ codeBefore: "@Post('/', { body: createUserSchema })\ncreate(ctx: Ctx<KickRoutes['POST /users']>) { /* ... */ }",
6751
+ codeAfter: "@Post('/', { body: createUserSchema, name: 'CreateUser' })\ncreate(ctx: Ctx<KickRoutes.UserController['create']>) { /* ... */ }",
6752
+ docs: "https://forinda.github.io/kick-js/guide/typegen.html"
6753
+ }
6754
+ };
6755
+ } },
6756
+ { match(input, _ctx) {
6757
+ const hasCluster = includesAny(input, [
6758
+ "cluster",
6759
+ "workers",
6760
+ "two ports",
6761
+ "duplicate server"
6762
+ ]);
6763
+ const hasDevSignal = includesAny(input, [
6764
+ "kick dev",
6765
+ "vite",
6766
+ "eaddrinuse",
6767
+ "5173",
6768
+ "5174",
6769
+ "two servers"
6770
+ ]);
6771
+ if (!hasCluster || !hasDevSignal) return null;
6772
+ return {
6773
+ confidence: 85,
6774
+ diagnosis: {
6775
+ id: "cluster-in-vite-dev",
6776
+ title: "Cluster mode is incompatible with `kick dev` (Vite owns the server)",
6777
+ explanation: "In dev mode, Vite owns the HTTP server. If your bootstrap passes\ncluster: { workers: N }, the framework forks N workers, each of which\nspins up its own Vite instance on a separate port. The fix landed in\nv2.2.5: McpAdapter (and bootstrap()) now detects Vite dev mode and\nsilently skips cluster, with a warning. If you see this on an older\nversion, upgrade or guard the cluster option behind NODE_ENV.",
6778
+ fix: "Either upgrade to v2.2.5+ or gate cluster mode on production:",
6779
+ codeAfter: "export const app = await bootstrap({\n modules,\n cluster: process.env.NODE_ENV === 'production' ? { workers: 4 } : false,\n})",
6780
+ docs: "https://forinda.github.io/kick-js/guide/cluster.html"
6781
+ }
6782
+ };
6783
+ } },
6784
+ { match(input, _ctx) {
6785
+ if (!includesAny(input, [
6786
+ "reflect-metadata",
6787
+ "Reflect.getMetadata is not a function",
6788
+ "Reflect.defineMetadata",
6789
+ "design:type",
6790
+ "design:paramtypes"
6791
+ ])) return null;
6792
+ return {
6793
+ confidence: 90,
6794
+ diagnosis: {
6795
+ id: "reflect-metadata-missing",
6796
+ title: "reflect-metadata is not loaded — DI cannot read decorator types",
6797
+ explanation: "The DI container reads constructor parameter types via the\nreflect-metadata polyfill. The polyfill must be imported once,\nbefore any decorator runs. Most projects do this at the top of\nsrc/index.ts; missing the import causes obscure \"design:paramtypes\"\nor \"Reflect.getMetadata is not a function\" errors at runtime.",
6798
+ fix: "Add the import at the very top of src/index.ts:",
6799
+ codeAfter: "import 'reflect-metadata' // ← must be the FIRST import\nimport './config'\nimport { bootstrap } from '@forinda/kickjs'\nimport { modules } from './modules'\n\nexport const app = await bootstrap({ modules })",
6800
+ docs: "https://forinda.github.io/kick-js/guide/dependency-injection.html"
6801
+ }
6802
+ };
6803
+ } },
6804
+ { match(input, _ctx) {
6805
+ if (!includesAny(input, [
6806
+ "404",
6807
+ "cannot get",
6808
+ "cannot post",
6809
+ "no route"
6810
+ ])) return null;
6811
+ return {
6812
+ confidence: 50,
6813
+ diagnosis: {
6814
+ id: "module-not-registered",
6815
+ title: "A 404 may indicate a module is not in the modules array",
6816
+ explanation: "KickJS only mounts modules listed in `src/modules/index.ts`. If you\ngenerated a module via `kick g module foo` but the routes don't appear,\nthe most likely cause is that the module is missing from the exported\narray. The CLI usually wires this automatically, but a hand-edit can\ndrop the entry.",
6817
+ fix: "Open src/modules/index.ts and verify the module is in the array:",
6818
+ codeAfter: "import type { AppModuleClass } from '@forinda/kickjs'\nimport { UserModule } from './users/user.module'\nimport { TaskModule } from './tasks/task.module' // ← was this missing?\n\nexport const modules: AppModuleClass[] = [UserModule, TaskModule]",
6819
+ docs: "https://forinda.github.io/kick-js/guide/project-structure.html"
6820
+ }
6821
+ };
6822
+ } }
6823
+ ];
6824
+ /**
6825
+ * Run every matcher against the input and return the highest-confidence
6826
+ * hit, or `null` if no matcher cleared the 40-confidence threshold.
6827
+ */
6828
+ function findBestMatch(input, ctx) {
6829
+ let best = null;
6830
+ for (const issue of KNOWN_ISSUES) {
6831
+ let match = null;
6832
+ try {
6833
+ match = issue.match(input, ctx);
6834
+ } catch {
6835
+ continue;
6836
+ }
6837
+ if (!match || match.confidence < 40) continue;
6838
+ if (!best || match.confidence > best.confidence) best = match;
6839
+ }
6840
+ return best;
6841
+ }
6842
+ //#endregion
6843
+ //#region src/explain/ai-fallback.ts
6844
+ /**
6845
+ * Ask the configured LLM for a diagnosis of `options.input`.
6846
+ *
6847
+ * Returns a discriminated result; callers should never assume the
6848
+ * LLM was reachable or produced valid output. The function catches
6849
+ * every expected failure mode and maps it to a friendly `unavailable`
6850
+ * or `error` result — the CLI can then decide how to present it.
6851
+ */
6852
+ async function askAi(options) {
6853
+ const provider = options.provider ?? "openai";
6854
+ const apiKey = process.env.OPENAI_API_KEY;
6855
+ if (provider === "openai" && !apiKey) return {
6856
+ kind: "unavailable",
6857
+ reason: "OPENAI_API_KEY environment variable is not set",
6858
+ suggestion: "Set OPENAI_API_KEY in your shell, e.g.\n export OPENAI_API_KEY=\"sk-...\"\n\nThen re-run `kick explain --ai \"<your error>\"`."
6859
+ };
6860
+ let aiModule;
6861
+ try {
6862
+ aiModule = await import("@forinda/kickjs-ai");
6863
+ } catch {
6864
+ return {
6865
+ kind: "unavailable",
6866
+ reason: "@forinda/kickjs-ai is not installed",
6867
+ suggestion: "Install the AI package to enable the LLM fallback:\n kick add ai\n\nOr manually:\n pnpm add @forinda/kickjs-ai"
6868
+ };
6869
+ }
6870
+ const { OpenAIProvider } = aiModule;
6871
+ const instance = new OpenAIProvider({
6872
+ apiKey,
6873
+ defaultChatModel: options.model ?? "gpt-4o-mini"
6874
+ });
6875
+ const systemPrompt = buildSystemPrompt(options.cwd);
6876
+ const userPrompt = `Error or stack trace:\n\n${options.input.trim()}`;
6877
+ try {
6878
+ const diagnosis = parseDiagnosisFromResponse((await instance.chat({ messages: [{
6879
+ role: "system",
6880
+ content: systemPrompt
6881
+ }, {
6882
+ role: "user",
6883
+ content: userPrompt
6884
+ }] })).content);
6885
+ if (!diagnosis) return {
6886
+ kind: "error",
6887
+ message: "The LLM responded but the payload was not valid JSON in the expected shape. Try again, or file an issue with the error text."
6888
+ };
6889
+ return {
6890
+ kind: "ok",
6891
+ diagnosis
6892
+ };
6893
+ } catch (err) {
6894
+ return {
6895
+ kind: "error",
6896
+ message: `LLM request failed: ${err instanceof Error ? err.message : String(err)}`
6897
+ };
6898
+ }
6899
+ }
6900
+ /**
6901
+ * Build the system prompt that tells the LLM what KickJS is and how
6902
+ * to structure its response. The prompt is deliberately prescriptive:
6903
+ * the caller needs a JSON payload it can render via the same formatter
6904
+ * the known-issues path uses, so freeform text doesn't work.
6905
+ *
6906
+ * Keep this prompt short — every token counts at inference time and
6907
+ * the CLI is often called interactively.
6908
+ */
6909
+ function buildSystemPrompt(cwd) {
6910
+ return [
6911
+ "You are a diagnostic assistant for KickJS, a decorator-driven Node.js",
6912
+ "framework built on Express 5 and TypeScript. KickJS projects use:",
6913
+ " - @Controller, @Get, @Post, @Autowired, @Service, @Value decorators",
6914
+ " - An AppModule interface with a routes() method (NOT a @Module decorator)",
6915
+ " - Zod schemas as both runtime validators and OpenAPI sources",
6916
+ " - Ctx<KickRoutes.ControllerName['method']> for typed request context",
6917
+ " - src/config/index.ts with defineEnv/loadEnv for env schema",
6918
+ " - A side-effect `import \"./config\"` in src/index.ts to register the schema",
6919
+ " - Container.reset() in beforeEach for DI test isolation",
6920
+ "",
6921
+ "When the user gives you an error message or stack trace, produce a",
6922
+ "structured diagnosis that helps them fix the bug. You MUST respond",
6923
+ "with a single JSON object (no surrounding prose, no markdown fences)",
6924
+ "matching this shape:",
6925
+ "",
6926
+ "{",
6927
+ " \"id\": \"<kebab-case-identifier>\",",
6928
+ " \"title\": \"<one-line problem summary>\",",
6929
+ " \"explanation\": \"<multi-line explanation of what is wrong>\",",
6930
+ " \"fix\": \"<multi-line instructions for fixing the problem>\",",
6931
+ " \"codeBefore\": \"<optional: broken code snippet>\",",
6932
+ " \"codeAfter\": \"<optional: corrected code snippet>\",",
6933
+ " \"docs\": \"<optional: KickJS doc URL that discusses this topic>\"",
6934
+ "}",
6935
+ "",
6936
+ "The KickJS docs live at https://forinda.github.io/kick-js/ — prefer",
6937
+ "that domain for any doc links you suggest.",
6938
+ cwd ? `The project is located at ${cwd}.` : ""
6939
+ ].filter((line) => line.length > 0).join("\n");
6940
+ }
6941
+ /**
6942
+ * Extract a `Diagnosis` object from the LLM response content.
6943
+ *
6944
+ * Tries three strategies in order:
6945
+ * 1. Parse the whole content as JSON directly
6946
+ * 2. Strip a surrounding markdown fence (```json ... ```)
6947
+ * 3. Find the first balanced `{ ... }` block and parse that
6948
+ *
6949
+ * Returns null if none of the strategies produce a valid object with
6950
+ * at least the required fields (id, title, explanation, fix).
6951
+ */
6952
+ function parseDiagnosisFromResponse(content) {
6953
+ const attempts = [
6954
+ content,
6955
+ stripMarkdownFence(content),
6956
+ extractFirstJsonObject(content)
6957
+ ].filter((s) => s !== null);
6958
+ for (const attempt of attempts) try {
6959
+ const parsed = JSON.parse(attempt);
6960
+ if (isValidDiagnosis(parsed)) return parsed;
6961
+ } catch {
6962
+ continue;
6963
+ }
6964
+ return null;
6965
+ }
6966
+ function stripMarkdownFence(text) {
6967
+ const match = text.match(/```(?:json)?\s*\n([\s\S]*?)```/);
6968
+ return match ? match[1]?.trim() ?? null : null;
6969
+ }
6970
+ function extractFirstJsonObject(text) {
6971
+ const start = text.indexOf("{");
6972
+ if (start === -1) return null;
6973
+ let depth = 0;
6974
+ let inString = false;
6975
+ let escape = false;
6976
+ for (let i = start; i < text.length; i++) {
6977
+ const ch = text[i];
6978
+ if (escape) {
6979
+ escape = false;
6980
+ continue;
6981
+ }
6982
+ if (ch === "\\" && inString) {
6983
+ escape = true;
6984
+ continue;
6985
+ }
6986
+ if (ch === "\"") {
6987
+ inString = !inString;
6988
+ continue;
6989
+ }
6990
+ if (inString) continue;
6991
+ if (ch === "{") depth++;
6992
+ if (ch === "}") {
6993
+ depth--;
6994
+ if (depth === 0) return text.slice(start, i + 1);
6995
+ }
6996
+ }
6997
+ return null;
6998
+ }
6999
+ function isValidDiagnosis(value) {
7000
+ if (value === null || typeof value !== "object") return false;
7001
+ const v = value;
7002
+ return typeof v.id === "string" && typeof v.title === "string" && typeof v.explanation === "string" && typeof v.fix === "string";
7003
+ }
7004
+ //#endregion
7005
+ //#region src/commands/explain.ts
7006
+ /**
7007
+ * `kick explain` — explain a KickJS error and suggest a fix.
7008
+ *
7009
+ * The command takes an error message (positional arg, --message flag,
7010
+ * or stdin), runs it through a registry of known KickJS pitfalls, and
7011
+ * prints the highest-confidence diagnosis with a code fix and a doc
7012
+ * link. If no matcher hits, it prints a "no match" message — the
7013
+ * --ai flag (planned) will fall back to an LLM call against the
7014
+ * registered AiProvider.
7015
+ *
7016
+ * The known-issues registry lives in src/explain/known-issues.ts and
7017
+ * is the single source of truth for KickJS-specific advice. Adding a
7018
+ * new entry takes ~30 lines and gives every user a permanent fix path.
7019
+ *
7020
+ * @example
7021
+ * ```bash
7022
+ * # As a positional arg
7023
+ * kick explain "config.get('DATABASE_URL') returned undefined"
7024
+ *
7025
+ * # Via stdin (pipe a stack trace)
7026
+ * pnpm test 2>&1 | kick explain
7027
+ *
7028
+ * # Via --message flag
7029
+ * kick explain --message "Reflect.getMetadata is not a function"
7030
+ * ```
7031
+ */
7032
+ function registerExplainCommand(program) {
7033
+ program.command("explain [message]").description("Explain a KickJS error and suggest a fix").option("-m, --message <text>", "Error message to explain (alternative to positional arg)").option("--ai", "Fall back to LLM if no known-issue matches (requires @forinda/kickjs-ai)").option("--model <name>", "Model name for the --ai fallback", "gpt-4o-mini").option("--json", "Output the diagnosis as JSON for tooling integration").action(async (positional, opts) => {
7034
+ const input = await resolveInput(positional, opts.message);
7035
+ if (!input || input.trim().length === 0) {
7036
+ process.stderr.write("Error: no input provided.\n\nPass a message as a positional arg, --message flag, or pipe via stdin:\n kick explain \"config.get returned undefined\"\n pnpm test 2>&1 | kick explain\n");
7037
+ process.exit(1);
7038
+ }
7039
+ const ctx = buildExplainContext();
7040
+ const match = findBestMatch(input, ctx);
7041
+ if (opts.json && match) {
7042
+ process.stdout.write(JSON.stringify({
7043
+ matched: true,
7044
+ ...match
7045
+ }, null, 2) + "\n");
7046
+ return;
7047
+ }
7048
+ if (match) {
7049
+ printDiagnosis(input, match.diagnosis, match.confidence);
7050
+ return;
7051
+ }
7052
+ if (!opts.ai) {
7053
+ if (opts.json) {
7054
+ process.stdout.write(JSON.stringify({ matched: false }, null, 2) + "\n");
7055
+ process.exit(2);
7056
+ }
7057
+ printNoMatch(input, false);
7058
+ process.exit(2);
7059
+ }
7060
+ const result = await askAi({
7061
+ input,
7062
+ model: opts.model,
7063
+ cwd: ctx.cwd
7064
+ });
7065
+ if (opts.json) {
7066
+ process.stdout.write(JSON.stringify(aiResultToJson(result), null, 2) + "\n");
7067
+ process.exit(result.kind === "ok" ? 0 : 2);
7068
+ }
7069
+ printAiResult(input, result);
7070
+ process.exit(result.kind === "ok" ? 0 : 2);
7071
+ });
7072
+ }
7073
+ /** Serialize an AskAiResult for `--json` output. */
7074
+ function aiResultToJson(result) {
7075
+ if (result.kind === "ok") return {
7076
+ matched: true,
7077
+ source: "ai",
7078
+ diagnosis: result.diagnosis
7079
+ };
7080
+ if (result.kind === "unavailable") return {
7081
+ matched: false,
7082
+ aiUnavailable: true,
7083
+ reason: result.reason
7084
+ };
7085
+ return {
7086
+ matched: false,
7087
+ aiError: true,
7088
+ error: result.message
7089
+ };
7090
+ }
7091
+ /** Render an AskAiResult to stdout using the same formatting as local matches. */
7092
+ function printAiResult(input, result) {
7093
+ if (result.kind === "ok") {
7094
+ printDiagnosis(input, result.diagnosis, -1, true);
7095
+ return;
7096
+ }
7097
+ if (result.kind === "unavailable") {
7098
+ process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
7099
+ process.stdout.write(` AI fallback unavailable: ${result.reason}\n\n`);
7100
+ process.stdout.write(`${indent(result.suggestion, " ")}\n\n`);
7101
+ return;
7102
+ }
7103
+ process.stdout.write(`\n Explaining: ${truncate(input.trim(), 200)}\n\n`);
7104
+ process.stdout.write(` AI fallback error: ${result.message}\n\n`);
7105
+ }
7106
+ /**
7107
+ * Resolve the error text from positional arg, --message flag, or stdin.
7108
+ *
7109
+ * Precedence: positional > flag > stdin. We only read stdin if neither
7110
+ * of the first two were provided AND stdin is not a TTY (i.e. something
7111
+ * is being piped in). Reading from a real TTY would hang waiting for
7112
+ * the user to type, which is never what they want.
7113
+ */
7114
+ async function resolveInput(positional, flag) {
7115
+ if (positional && positional.trim().length > 0) return positional;
7116
+ if (flag && flag.trim().length > 0) return flag;
7117
+ if (process.stdin.isTTY) return "";
7118
+ return readStdinAll();
7119
+ }
7120
+ function readStdinAll() {
7121
+ return new Promise((resolve, reject) => {
7122
+ let buffer = "";
7123
+ process.stdin.setEncoding("utf8");
7124
+ process.stdin.on("data", (chunk) => {
7125
+ buffer += chunk;
7126
+ });
7127
+ process.stdin.on("end", () => resolve(buffer));
7128
+ process.stdin.on("error", reject);
7129
+ });
7130
+ }
7131
+ /**
7132
+ * Build a small context object the matchers can use to check project
7133
+ * state — e.g. "does this project have a src/config/index.ts?".
7134
+ *
7135
+ * Kept intentionally minimal to avoid pulling the full kick.config
7136
+ * loader into a fast-path command. Matchers should treat this as
7137
+ * best-effort and degrade gracefully when ctx is undefined.
7138
+ */
7139
+ function buildExplainContext() {
7140
+ const cwd = process.cwd();
7141
+ return {
7142
+ cwd,
7143
+ hasFile: (path) => existsSync(resolve(cwd, path))
7144
+ };
7145
+ }
7146
+ function printDiagnosis(input, d, confidence, aiLabel = false) {
7147
+ const inputSnippet = truncate(input.trim(), 200);
7148
+ const label = aiLabel ? "AI-generated — verify before applying" : labelConfidence(confidence);
7149
+ process.stdout.write(`\n Explaining: ${inputSnippet}\n`);
7150
+ process.stdout.write(`\n Match: ${d.id} (${label})\n`);
7151
+ process.stdout.write(` Title: ${d.title}\n`);
7152
+ process.stdout.write(`\n Diagnosis:\n${indent(d.explanation, " ")}\n`);
7153
+ process.stdout.write(`\n Fix:\n${indent(d.fix, " ")}\n`);
7154
+ if (d.codeBefore) process.stdout.write(`\n Before:\n${indent(d.codeBefore, " ")}\n`);
7155
+ if (d.codeAfter) process.stdout.write(`\n After:\n${indent(d.codeAfter, " ")}\n`);
7156
+ if (d.docs) process.stdout.write(`\n Docs: ${d.docs}\n`);
7157
+ process.stdout.write("\n");
7158
+ }
7159
+ function printNoMatch(input, aiRequested) {
7160
+ const snippet = truncate(input.trim(), 200);
7161
+ process.stdout.write(`\n Explaining: ${snippet}\n\n`);
7162
+ if (aiRequested) process.stdout.write(" No known-issue matched, and --ai fallback is not yet wired.\n When @forinda/kickjs-ai ships its provider implementations,\n this command will call the configured LLM with the error +\n project context and return a structured fix.\n\n");
7163
+ else process.stdout.write(" No known-issue matched. Things you can try:\n\n 1. Check the framework docs for the error keywords:\n https://forinda.github.io/kick-js/\n\n 2. Re-run with --ai to fall back to an LLM (requires\n @forinda/kickjs-ai with a configured provider):\n kick explain --ai \"<your error>\"\n\n 3. File an issue with the error text:\n https://github.com/forinda/kick-js/issues/new\n\n");
7164
+ }
7165
+ function indent(text, prefix) {
7166
+ return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
7167
+ }
7168
+ function truncate(text, max) {
7169
+ if (text.length <= max) return text;
7170
+ return text.slice(0, max - 1) + "…";
7171
+ }
7172
+ function labelConfidence(score) {
7173
+ if (score >= 90) return "high confidence";
7174
+ if (score >= 70) return "good match";
7175
+ if (score >= 50) return "medium confidence";
7176
+ return "low confidence — verify manually";
7177
+ }
7178
+ //#endregion
7179
+ //#region src/commands/mcp.ts
7180
+ /**
7181
+ * `kick mcp` — Model Context Protocol commands.
7182
+ *
7183
+ * Two subcommands:
7184
+ * - `kick mcp` (default → `start`): runs the built application as an
7185
+ * MCP server over stdio. The user's app must already wire `McpAdapter`
7186
+ * from `@forinda/kickjs-mcp` into its bootstrap. The CLI just spawns
7187
+ * the built entry as a subprocess with `KICK_MCP_STDIO=1`, which the
7188
+ * adapter detects and uses to switch its transport from
7189
+ * StreamableHTTP to stdio. The subprocess inherits stdin/stdout/stderr
7190
+ * so the MCP wire protocol flows directly between the parent process
7191
+ * (the MCP client — Claude Code, Cursor, etc.) and the child app.
7192
+ * - `kick mcp init`: generates a `.mcp.json` config file pointing at
7193
+ * this project, ready to drop into a Claude Code / Cursor workspace.
7194
+ *
7195
+ * Logs MUST go to stderr in stdio mode — anything written to stdout
7196
+ * corrupts the JSON-RPC protocol stream. Pino's default stream is
7197
+ * stderr already, so this works out of the box for KickJS apps using
7198
+ * the framework's bundled logger.
7199
+ */
7200
+ function registerMcpCommand(program) {
7201
+ const mcp = program.command("mcp").description("Model Context Protocol commands (start | init)");
7202
+ mcp.command("start", { isDefault: true }).description("Run the built application as an MCP server over stdio").option("-e, --entry <file>", "Entry file", "dist/index.js").option("--node-arg <arg...>", "Extra arguments to pass to node").action(runMcpServer);
7203
+ mcp.command("init").description("Generate .mcp.json for Claude Code / Cursor / Zed").option("-n, --name <name>", "Server name (defaults to package.json name)").option("-o, --out <file>", "Output file", ".mcp.json").option("-f, --force", "Overwrite an existing entry without prompting").option("--global", "Write to ~/.mcp.json instead of the project root").action(initMcpConfig);
7204
+ }
7205
+ function runMcpServer(opts) {
7206
+ const entry = resolve(opts.entry);
7207
+ if (!existsSync(entry)) {
7208
+ process.stderr.write(`Error: entry file not found: ${entry}\n\nBuild the app first with \`kick build\`, or pass a custom entry:\n kick mcp -e dist/server.js\n`);
7209
+ process.exit(1);
7210
+ }
7211
+ const nodeArgs = [...opts.nodeArg ?? [], entry];
7212
+ const child = spawn(process.execPath, nodeArgs, {
7213
+ stdio: "inherit",
7214
+ env: {
7215
+ ...process.env,
7216
+ KICK_MCP_STDIO: "1",
7217
+ NODE_ENV: process.env.NODE_ENV ?? "production"
7218
+ }
7219
+ });
7220
+ child.on("error", (err) => {
7221
+ process.stderr.write(`Failed to start MCP server: ${err.message}\n`);
7222
+ process.exit(1);
7223
+ });
7224
+ child.on("exit", (code, signal) => {
7225
+ if (signal) {
7226
+ process.kill(process.pid, signal);
7227
+ return;
7228
+ }
7229
+ process.exit(code ?? 0);
7230
+ });
7231
+ const forward = (signal) => {
7232
+ if (!child.killed) child.kill(signal);
7233
+ };
7234
+ process.on("SIGINT", () => forward("SIGINT"));
7235
+ process.on("SIGTERM", () => forward("SIGTERM"));
7236
+ }
7237
+ function initMcpConfig(opts) {
7238
+ const cwd = process.cwd();
7239
+ const projectName = readPackageName(cwd) ?? basename(cwd);
7240
+ const serverName = opts.name ?? projectName;
7241
+ const outPath = opts.global ? resolve(process.env.HOME ?? ".", ".mcp.json") : resolve(cwd, opts.out);
7242
+ const entry = {
7243
+ command: "kick",
7244
+ args: ["mcp"],
7245
+ cwd
7246
+ };
7247
+ let config = { mcpServers: {} };
7248
+ if (existsSync(outPath)) try {
7249
+ const raw = readFileSync(outPath, "utf8");
7250
+ const parsed = JSON.parse(raw);
7251
+ if (parsed && typeof parsed === "object" && parsed.mcpServers) config = { mcpServers: { ...parsed.mcpServers } };
7252
+ } catch (err) {
7253
+ const message = err instanceof Error ? err.message : String(err);
7254
+ process.stderr.write(`Error: existing ${outPath} is not valid JSON (${message}).\nFix the file or pass --force to overwrite the entry.\n`);
7255
+ process.exit(1);
7256
+ }
7257
+ if (config.mcpServers[serverName] && !opts.force) {
7258
+ process.stderr.write(`Error: an entry for "${serverName}" already exists in ${outPath}.\nPass --force to overwrite it, or use --name to pick a different key.\n`);
7259
+ process.exit(1);
7260
+ }
7261
+ config.mcpServers[serverName] = entry;
7262
+ writeFileSync(outPath, JSON.stringify(config, null, 2) + "\n", "utf8");
7263
+ process.stdout.write(`\n ✓ Wrote MCP server entry "${serverName}" to ${outPath}\n\n To activate it:\n 1. Build your app: kick build\n 2. Restart your MCP client (Claude Code, Cursor, Zed)\n 3. The server should appear in the client's tool picker\n\n`);
7264
+ }
7265
+ /**
7266
+ * Read the `name` field from the project's `package.json`. Returns
7267
+ * null if the file is missing or unparseable — callers fall back to
7268
+ * the directory name in that case.
7269
+ */
7270
+ function readPackageName(cwd) {
7271
+ const pkgPath = resolve(cwd, "package.json");
7272
+ if (!existsSync(pkgPath)) return null;
7273
+ try {
7274
+ const raw = readFileSync(pkgPath, "utf8");
7275
+ const parsed = JSON.parse(raw);
7276
+ if (typeof parsed.name === "string") return parsed.name;
7277
+ return null;
7278
+ } catch {
7279
+ return null;
7280
+ }
7281
+ }
7282
+ //#endregion
6378
7283
  //#region src/commands/tinker.ts
6379
7284
  function registerTinkerCommand(program) {
6380
7285
  program.command("tinker").description("Interactive REPL with DI container and services loaded").option("-e, --entry <file>", "Entry file to load", "src/index.ts").action(async (opts) => {
@@ -6613,10 +7518,10 @@ function registerTypegenCommand(program) {
6613
7518
  //#endregion
6614
7519
  //#region src/cli.ts
6615
7520
  const __dirname = dirname(fileURLToPath(import.meta.url));
6616
- const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
7521
+ const pkg$1 = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
6617
7522
  async function main() {
6618
7523
  const program = new Command();
6619
- program.name("kick").description("KickJS — A production-grade, decorator-driven Node.js framework").version(pkg.version);
7524
+ program.name("kick").description("KickJS — A production-grade, decorator-driven Node.js framework").version(pkg$1.version);
6620
7525
  const config = await loadKickConfig(process.cwd());
6621
7526
  registerInitCommand(program);
6622
7527
  registerGenerateCommand(program);
@@ -6625,6 +7530,8 @@ async function main() {
6625
7530
  registerInspectCommand(program);
6626
7531
  registerAddCommand(program);
6627
7532
  registerListCommand(program);
7533
+ registerExplainCommand(program);
7534
+ registerMcpCommand(program);
6628
7535
  registerTinkerCommand(program);
6629
7536
  registerRemoveCommand(program);
6630
7537
  registerTypegenCommand(program);