Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/config-middleware.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@kidd-cli/core': minor
---

Extract config loading from core runtime into an opt-in middleware (`@kidd-cli/core/config`) with support for layered resolution (global > project > local). Config is no longer baked into `CommandContext` — it is added via module augmentation when the middleware is imported, keeping builds lean for CLIs that don't need config.

**Breaking:** `ctx.config` is no longer available by default. Use the config middleware:

```ts
import { config } from '@kidd-cli/core/config'

cli({
middleware: [config({ schema: mySchema, layers: true })],
})
```
96 changes: 77 additions & 19 deletions docs/concepts/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,47 +35,101 @@ export default defineConfig({
})
```

## CLI Config Options
## Config Middleware

The `config` option in `cli()` controls how runtime configuration is loaded and validated for your CLI's users.
Configuration is purely opt-in via the `config()` middleware from `@kidd-cli/core/config`. Register it in the middleware array to make `ctx.config` available in handlers.

### Lazy loading (default)

By default, config is loaded lazily -- nothing is read from disk until the handler calls `ctx.config.load()`:

```ts
import { cli } from '@kidd-cli/core'
import { config } from '@kidd-cli/core/config'
import { configSchema } from './config.js'

cli({
name: 'my-app',
version: '1.0.0',
config: {
schema: MyConfigSchema,
name: 'myapp',
},
middleware: [config({ schema: configSchema })],
commands: { deploy },
})
```

| Field | Type | Default | Description |
| -------- | --------- | ------------------- | ------------------------------------------------------------------- |
| `schema` | `ZodType` | -- | Zod schema to validate the loaded config. Infers `ctx.config` type. |
| `name` | `string` | Derived from `name` | Override the config file name for file discovery |
### Eager loading

Pass `eager: true` to load and validate config during the middleware pass, before the handler runs:

```ts
config({ schema: configSchema, eager: true })
```

### Middleware options

| Field | Type | Default | Description |
| -------- | --------- | ------------------- | -------------------------------------------------------------------- |
| `schema` | `ZodType` | -- | Zod schema to validate the loaded config. Infers `ctx.config` type. |
| `eager` | `boolean` | `false` | Load config during middleware pass instead of on first `load()` call |
| `layers` | `boolean` | `false` | Enable layered resolution when eager loading. For lazy mode, pass `{ layers: true }` to `load()` instead. |
| `dirs` | `object` | From `ctx.meta` | Override layer directories: `{ global?: string, local?: string }`. Only applies when layered resolution is used. |
| `name` | `string` | Derived from `name` | Override the config file name for file discovery |

## Using `ctx.config`

The middleware decorates `ctx.config` as a `ConfigHandle` with a `load()` method. It returns the load result or `null` on error:

```ts
export default command({
async handler(ctx) {
const result = await ctx.config.load()
if (!result) return
result.config.apiUrl // string
result.config.org // string
},
})
```

Pass `{ exitOnError: true }` to call `ctx.fail()` on error, guaranteeing a non-null return:

```ts
export default command({
async handler(ctx) {
const { config } = await ctx.config.load({ exitOnError: true })
config.apiUrl // string — guaranteed
},
})
```

### Loading with layers

Pass `{ layers: true }` to `load()` to include layer metadata in the result:

```ts
const result = await ctx.config.load({ layers: true, exitOnError: true })
result.config.apiUrl // string
result.layers // ConfigLayer[]
```

## Typing `ctx.config`

The Zod schema validates config at runtime, but TypeScript cannot automatically propagate the schema type to `ctx.config` in command handlers (commands are defined in separate files and dynamically imported). Use `ConfigType` with module augmentation to get compile-time safety:
The Zod schema validates config at runtime, but TypeScript cannot automatically propagate the schema type to `ctx.config` in command handlers (commands are defined in separate files and dynamically imported). Use `ConfigType` with module augmentation on `@kidd-cli/core/config` to get compile-time safety:

```ts
// src/config.ts
import type { ConfigType } from '@kidd-cli/core'
import type { ConfigType } from '@kidd-cli/core/config'
import { z } from 'zod'

export const configSchema = z.object({
apiUrl: z.string().url(),
org: z.string().min(1),
})

declare module '@kidd-cli/core' {
interface CliConfig extends ConfigType<typeof configSchema> {}
declare module '@kidd-cli/core/config' {
interface ConfigRegistry extends ConfigType<typeof configSchema> {}
}
```

This keeps the schema as the single source of truth -- `CliConfig` is always derived from it, so they can never drift apart. Every command handler now sees `ctx.config.apiUrl` and `ctx.config.org` as fully typed properties.
This keeps the schema as the single source of truth -- `ConfigRegistry` is always derived from it, so they can never drift apart. Every command handler now sees typed properties on the `result.config` object returned by `ctx.config.load()`.

You can scaffold this setup automatically:

Expand Down Expand Up @@ -237,13 +291,17 @@ const setupCommand = command({
Use different config file names for different environments:

```ts
import { config } from '@kidd-cli/core/config'

cli({
name: 'my-app',
version: '1.0.0',
config: {
schema: configSchema,
name: process.env['NODE_ENV'] === 'production' ? 'my-app-prod' : 'my-app',
},
middleware: [
config({
schema: configSchema,
name: process.env['NODE_ENV'] === 'production' ? 'my-app-prod' : 'my-app',
}),
],
commands: { deploy },
})
```
Expand Down
92 changes: 54 additions & 38 deletions docs/concepts/context.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ The central API surface threaded through every handler and middleware. Provides

## Properties

| Property | Type | Description |
| --------- | ----------------------------------------- | ---------------------------------------------------------------------------- |
| `args` | `DeepReadonly<Merge<KiddArgs, TArgs>>` | Parsed and validated command args |
| `colors` | `Colors` | Color formatting utilities (picocolors) |
| `config` | `DeepReadonly<Merge<CliConfig, TConfig>>` | Validated runtime config |
| `format` | `Format` | Pure string formatters (no I/O) |
| `log` | `Log` | Logging methods (info, success, error, warn, etc.) |
| `prompts` | `Prompts` | Interactive prompts (confirm, text, select, etc.) |
| `spinner` | `Spinner` | Spinner for long-running operations (start, stop, message) |
| `store` | `Store` | Typed in-memory key-value store |
| `fail` | `(message, options?) => never` | Throw a user-facing error |
| `meta` | `Meta` | CLI metadata |
| `auth` | `AuthContext` | Auth credential and login (when `@kidd-cli/core/auth` middleware registered) |
| Property | Type | Description |
| --------- | -------------------------------------- | ---------------------------------------------------------------------------- |
| `args` | `DeepReadonly<Merge<KiddArgs, TArgs>>` | Parsed and validated command args |
| `colors` | `Colors` | Color formatting utilities (picocolors) |
| `config` | `ConfigHandle` | Lazy config handle with `load()` method (when config middleware registered) |
| `format` | `Format` | Pure string formatters (no I/O) |
| `log` | `Log` | Logging methods (info, success, error, warn, etc.) |
| `prompts` | `Prompts` | Interactive prompts (confirm, text, select, etc.) |
| `spinner` | `Spinner` | Spinner for long-running operations (start, stop, message) |
| `store` | `Store` | Typed in-memory key-value store |
| `fail` | `(message, options?) => never` | Throw a user-facing error |
| `meta` | `Meta` | CLI metadata |
| `auth` | `AuthContext` | Auth credential and login (when `@kidd-cli/core/auth` middleware registered) |

## `ctx.args`

Expand All @@ -35,50 +35,64 @@ const deploy = command({

## `ctx.config`

Deeply readonly validated config loaded from the project's config file. The type is a merge of `CliConfig` (global augmentation) and the schema passed to `cli({ config: { schema } })`.
A `ConfigHandle` decorated by the `config()` middleware from `@kidd-cli/core/config`. Only present when the config middleware is registered. Config loads lazily by default -- call `ctx.config.load()` to read and validate the config file, which returns a `Result` tuple.

Use `ConfigType` with module augmentation to derive `CliConfig` from your Zod schema:
Use `ConfigType` with module augmentation on `@kidd-cli/core/config` to derive `ConfigRegistry` from your Zod schema:

```ts
// src/config.ts
import type { ConfigType } from '@kidd-cli/core'
import type { ConfigType } from '@kidd-cli/core/config'
import { z } from 'zod'

export const configSchema = z.object({
apiUrl: z.string().url(),
org: z.string().min(1),
})

declare module '@kidd-cli/core' {
interface CliConfig extends ConfigType<typeof configSchema> {}
declare module '@kidd-cli/core/config' {
interface ConfigRegistry extends ConfigType<typeof configSchema> {}
}
```

Then pass the schema to `cli()`:
Then register the config middleware:

```ts
import { cli } from '@kidd-cli/core'
import { config } from '@kidd-cli/core/config'
import { configSchema } from './config.js'

cli({
name: 'my-app',
version: '1.0.0',
config: { schema: configSchema },
middleware: [config({ schema: configSchema })],
commands: import.meta.dirname + '/commands',
})
```

Commands can now access typed config properties:
Commands load and access config via the handle:

```ts
export default command({
async handler(ctx) {
ctx.config.apiUrl // string
ctx.config.org // string
const [error, result] = await ctx.config.load()
if (error) {
ctx.fail(error.message)
return
}
result.config.apiUrl // string
result.config.org // string
},
})
```

Pass `{ layers: true }` to `load()` to include layer metadata:

```ts
const [error, result] = await ctx.config.load({ layers: true })
result.config.apiUrl // string
result.layers // ConfigLayer[]
```

Run `kidd add config` to scaffold this setup in an existing project, or pass `--config` to `kidd init` when creating a new project.

## `ctx.log`
Expand Down Expand Up @@ -258,39 +272,41 @@ See [Authentication](./authentication.md) for the full auth system reference.

kidd exposes empty interfaces that consumers extend via TypeScript declaration merging. This adds project-wide type safety without threading generics through every handler.

For `CliConfig`, use the `ConfigType` utility to derive the type from your Zod schema (see [`ctx.config`](#ctxconfig) above). For other interfaces, extend them directly:
For `ConfigRegistry`, use the `ConfigType` utility to derive the type from your Zod schema (see [`ctx.config`](#ctxconfig) above). Note that config augmentation targets `@kidd-cli/core/config`, while other interfaces target `@kidd-cli/core`:

```ts
declare module '@kidd-cli/core' {
interface KiddArgs {
verbose: boolean
}

// Prefer ConfigType<typeof schema> over manual properties -- see ctx.config docs
interface CliConfig extends ConfigType<typeof configSchema> {}

interface KiddStore {
token: string
}
}

// Config augmentation uses a separate module
declare module '@kidd-cli/core/config' {
interface ConfigRegistry extends ConfigType<typeof configSchema> {}
}
```

| Interface | Affects | Description |
| ----------- | ------------ | ------------------------------------------------------------------------------------------------ |
| `KiddArgs` | `ctx.args` | Global args merged into every command's args |
| `CliConfig` | `ctx.config` | Global config merged into every command's config |
| `KiddStore` | `ctx.store` | Global store keys merged into the store type |
| `StoreMap` | `ctx.store` | The store's full key-value shape -- extend this to register typed keys (merges with `KiddStore`) |
| Interface | Module | Affects | Description |
| ---------------- | ----------------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| `KiddArgs` | `@kidd-cli/core` | `ctx.args` | Global args merged into every command's args |
| `ConfigRegistry` | `@kidd-cli/core/config` | `ctx.config.load()` | Typed config returned by `load()` result |
| `KiddStore` | `@kidd-cli/core` | `ctx.store` | Global store keys merged into the store type |
| `StoreMap` | `@kidd-cli/core` | `ctx.store` | The store's full key-value shape -- extend this to register typed keys (merges with `KiddStore`) |

## Context in screen commands

Screen commands defined with `screen()` do not receive a `CommandContext` object. Instead, parsed args are passed directly as props to the React component, and runtime values are accessed via hooks:

| Hook | Returns | Context equivalent |
| ------------- | ------------------- | ------------------ |
| `useConfig()` | `Readonly<TConfig>` | `ctx.config` |
| `useMeta()` | `Readonly<Meta>` | `ctx.meta` |
| `useStore()` | `Store` | `ctx.store` |
| Hook | Returns | Context equivalent |
| ------------- | ---------------- | ------------------ |
| `useConfig()` | `ConfigHandle` | `ctx.config` |
| `useMeta()` | `Readonly<Meta>` | `ctx.meta` |
| `useStore()` | `Store` | `ctx.store` |

See [Screens](./screens.md) for details.

Expand Down
Loading
Loading