Skip to content
Open
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 })],
})
```
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./config": {
"types": "./dist/middleware/config.d.ts",
"default": "./dist/middleware/config.js"
},
"./auth": {
"types": "./dist/middleware/auth.d.ts",
"default": "./dist/middleware/auth.js"
Expand Down
21 changes: 0 additions & 21 deletions packages/core/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,32 +205,11 @@ describe('context properties', () => {
expect(handler).toHaveBeenCalledTimes(1)
const ctx = handler.mock.calls[0]![0] as CommandContext
expect(ctx).toHaveProperty('args')
expect(ctx).toHaveProperty('config')
expect(ctx).toHaveProperty('format')
expect(ctx).toHaveProperty('store')
expect(ctx).toHaveProperty('fail')
expect(ctx).toHaveProperty('meta')
})

it('provides empty config when no config option is given', async () => {
const handler = vi.fn()
const commands: CommandMap = {
run: command({
description: 'Run',
handler,
}),
}

setArgv('run')
await runTestCli({
commands,
name: 'test-cli',
version: '1.0.0',
})

const ctx = handler.mock.calls[0]![0] as CommandContext
expect(ctx.config).toEqual({})
})
})

describe('version resolution', () => {
Expand Down
5 changes: 1 addition & 4 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ import type { ErrorRef, ResolvedRef } from './runtime/index.js'
*
* @param options - CLI configuration including name, version, commands, and middleware.
*/
export async function cli<TSchema extends z.ZodType = z.ZodType>(
options: CliOptions<TSchema>
): Promise<void> {
export async function cli(options: CliOptions): Promise<void> {
registerCrashHandlers(options.name)

const [uncaughtError, result] = await attemptAsync(async () => {
Expand Down Expand Up @@ -104,7 +102,6 @@ export async function cli<TSchema extends z.ZodType = z.ZodType>(

const [runtimeError, runtime] = await createRuntime({
argv: normalizedArgv,
config: options.config,
dirs,
display: options.display,
log: options.log,
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,9 @@ export function isCommandsConfig(value: unknown): value is CommandsConfig {
export function command<
TOptionsDef extends ArgsDef = ArgsDef,
TPositionalsDef extends ArgsDef = ArgsDef,
TConfig extends Record<string, unknown> = Record<string, unknown>,
const TMiddleware extends readonly Middleware<MiddlewareEnv>[] =
readonly Middleware<MiddlewareEnv>[],
>(def: CommandDef<TOptionsDef, TPositionalsDef, TConfig, TMiddleware>): CommandType {
>(def: CommandDef<TOptionsDef, TPositionalsDef, TMiddleware>): CommandType {
const resolved = {
...def,
deprecated: resolveValue(def.deprecated),
Expand Down
11 changes: 1 addition & 10 deletions packages/core/src/context/create-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { isContextError } from './error.js'
function defaultOptions(): {
args: { name: string; verbose: boolean }
argv: readonly string[]
config: { debug: boolean }
meta: {
command: string[]
dirs: { global: string; local: string }
Expand All @@ -18,7 +17,6 @@ function defaultOptions(): {
return {
args: { name: 'test', verbose: true },
argv: ['my-cli', 'deploy', 'preview', '--verbose'],
config: { debug: false },
meta: {
command: ['deploy', 'preview'],
dirs: { global: '.my-cli', local: '.my-cli' },
Expand All @@ -30,7 +28,7 @@ function defaultOptions(): {

describe('createContext()', () => {
// ---------------------------------------------------------------------------
// Args, config
// Args
// ---------------------------------------------------------------------------

describe('args', () => {
Expand All @@ -41,13 +39,6 @@ describe('createContext()', () => {
})
})

describe('config', () => {
it('contains the provided config', () => {
const ctx = createContext(defaultOptions())
expect(ctx.config.debug).toBeFalsy()
})
})

// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
Expand Down
32 changes: 15 additions & 17 deletions packages/core/src/context/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Colors } from 'picocolors/types'

import { createDotDirectory } from '@/lib/dotdir/index.js'
import { createLog } from '@/lib/log.js'
import type { AnyRecord, KiddStore, Merge, ResolvedDirs } from '@/types/index.js'
import type { AnyRecord, KiddStore, Merge } from '@/types/index.js'

import { createContextError } from './error.js'
import { createContextFormat } from './format.js'
Expand All @@ -25,20 +25,19 @@ import type {
/**
* Options for creating a {@link CommandContext} instance via {@link createContext}.
*
* Carries the parsed args, validated config, and CLI metadata needed to
* assemble a fully-wired context. Optional overrides allow callers to inject
* custom {@link Log}, {@link Prompts}, and {@link Status} implementations;
* when omitted, default `@clack/prompts`-backed instances are used.
* Carries the parsed args and CLI metadata needed to assemble a fully-wired
* context. Optional overrides allow callers to inject custom {@link Log},
* {@link Prompts}, and {@link Status} implementations; when omitted, default
* `@clack/prompts`-backed instances are used.
*/
export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends AnyRecord> {
export interface CreateContextOptions<TArgs extends AnyRecord> {
readonly args: TArgs
readonly argv: readonly string[]
readonly config: TConfig
readonly meta: {
readonly name: string
readonly version: string
readonly command: string[]
readonly dirs: ResolvedDirs
readonly dirs: { readonly local: string; readonly global: string }
}
readonly display?: DisplayConfig
readonly log?: Log
Expand All @@ -60,9 +59,9 @@ export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends A
* @param options - Args, config, and meta for the current invocation.
* @returns A fully constructed CommandContext.
*/
export function createContext<TArgs extends AnyRecord, TConfig extends AnyRecord>(
options: CreateContextOptions<TArgs, TConfig>
): CommandContext<TArgs, TConfig> {
export function createContext<TArgs extends AnyRecord>(
options: CreateContextOptions<TArgs>
): CommandContext<TArgs> {
const dc = options.display ?? {}
const commonDefaults = resolveCommonDefaults(dc)

Expand Down Expand Up @@ -93,9 +92,8 @@ export function createContext<TArgs extends AnyRecord, TConfig extends AnyRecord
// Middleware-augmented properties (e.g. `report`, `auth`) are added at runtime.
// See `decorateContext` — they are intentionally absent here.
return {
args: options.args as CommandContext<TArgs, TConfig>['args'],
args: options.args as CommandContext<TArgs>['args'],
colors: Object.freeze({ ...pc }) as Colors,
config: options.config as CommandContext<TArgs, TConfig>['config'],
dotdir: ctxDotdir,
fail(message: string, failOptions?: { code?: string; exitCode?: number }): never {
// Accepted exception: ctx.fail() is typed `never` and caught by the CLI boundary.
Expand All @@ -104,12 +102,12 @@ export function createContext<TArgs extends AnyRecord, TConfig extends AnyRecord
},
format: ctxFormat,
log: ctxLog,
meta: ctxMeta as CommandContext<TArgs, TConfig>['meta'],
meta: ctxMeta as CommandContext<TArgs>['meta'],
prompts: ctxPrompts,
raw: Object.freeze({ argv: Object.freeze([...options.argv]) }),
status: ctxStatus,
store: ctxStore,
} as CommandContext<TArgs, TConfig>
} as CommandContext<TArgs>
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -144,8 +142,8 @@ function resolveCommonDefaults(dc: DisplayConfig): {
* @param commonDefaults - Common per-call defaults from display config.
* @returns A Status instance.
*/
function resolveStatus<TArgs extends AnyRecord, TConfig extends AnyRecord>(
options: CreateContextOptions<TArgs, TConfig>,
function resolveStatus<TArgs extends AnyRecord>(
options: CreateContextOptions<TArgs>,
dc: DisplayConfig,
commonDefaults: { readonly guide?: boolean; readonly output?: DisplayConfig['output'] }
): Status {
Expand Down
39 changes: 14 additions & 25 deletions packages/core/src/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
AnyRecord,
DeepReadonly,
KiddArgs,
CliConfig,
KiddStore,
Merge,
ResolvedDirs,
Expand Down Expand Up @@ -717,39 +716,34 @@ export type ImperativeContextKeys = 'colors' | 'fail' | 'format' | 'prompts'
* Context subset available inside `screen()` components via `useScreenContext()`.
*
* Retains `log` and `status` (swapped with React-backed implementations),
* data properties (`args`, `config`, `meta`, `store`), and any
* middleware-decorated properties (`auth`, `http`, `report`, etc.).
* data properties (`args`, `meta`, `store`), and any
* middleware-decorated properties (`auth`, `http`, `report`, `config`, etc.).
*
* Omits `colors`, `fail`, `format`, and `prompts` which have no
* screen-safe equivalent.
*
* @typeParam TArgs - Parsed args type.
* @typeParam TConfig - Config type.
*/
export type ScreenContext<
TArgs extends AnyRecord = AnyRecord,
TConfig extends AnyRecord = AnyRecord,
> = Omit<CommandContext<TArgs, TConfig>, ImperativeContextKeys>
export type ScreenContext<TArgs extends AnyRecord = AnyRecord> = Omit<
CommandContext<TArgs>,
ImperativeContextKeys
>

/**
* The context object threaded through every handler, middleware, and hook.
*
* Contains framework-level primitives: parsed args, validated config, CLI
* metadata, a key-value store, formatting helpers, logging, prompts,
* status indicators, and a fail function. Additional capabilities (e.g.
* `report`, `auth`) are added by middleware via `decorateContext`.
* Contains framework-level primitives: parsed args, CLI metadata, a key-value
* store, formatting helpers, logging, prompts, status indicators, and a fail
* function. Additional capabilities (e.g. `config`, `report`, `auth`) are
* added by middleware via `decorateContext`.
*
* All data properties (args, config, meta) are deeply readonly — attempting
* to mutate any nested property produces a compile-time error. Use `ctx.store`
* for mutable state that flows between middleware and handlers.
* All data properties (args, meta) are deeply readonly — attempting to mutate
* any nested property produces a compile-time error. Use `ctx.store` for
* mutable state that flows between middleware and handlers.
*
* @typeParam TArgs - Parsed args type (inferred from the command's zod/yargs args definition).
* @typeParam TConfig - Config type (inferred from the zod schema passed to `cli({ config: { schema } })`).
*/
export interface CommandContext<
TArgs extends AnyRecord = AnyRecord,
TConfig extends AnyRecord = AnyRecord,
> {
export interface CommandContext<TArgs extends AnyRecord = AnyRecord> {
/**
* Parsed and validated args for this command. Deeply immutable.
*/
Expand All @@ -761,11 +755,6 @@ export interface CommandContext<
*/
readonly colors: Colors

/**
* Runtime config validated against the zod schema. Deeply immutable.
*/
readonly config: DeepReadonly<Merge<CliConfig, TConfig>>

/**
* Dot directory manager for reading/writing files in the CLI's
* dot directories (e.g. `~/.myapp/`, `<project>/.myapp/`).
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ export { defineConfig } from '@kidd-cli/config'
export { render, renderToString, screen, useScreenContext } from './screen/index.js'
export type { ScreenDef, ScreenExit } from './screen/index.js'
export type {
CliConfig,
Command,
CommandsConfig,
ConfigType,
HelpOptions,
MiddlewareEnv,
Resolvable,
Expand Down
Loading
Loading