Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions .changeset/ctx-raw-argv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): expose `ctx.raw.argv` — a normalized argv where `argv[0]` is always the CLI name (via yargs `$0`), regardless of invocation mode. Middleware can inspect the full invocation without guessing preamble offsets.

Fixes #146
7 changes: 7 additions & 0 deletions .changeset/dynamic-fullscreen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): allow `screen({ fullscreen })` to accept a resolver function `(ctx: ScreenContext) => boolean | Promise<boolean>` for runtime fullscreen decisions based on args, config, or terminal capabilities.

Fixes #148
7 changes: 7 additions & 0 deletions .changeset/render-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@kidd-cli/core": minor
---

feat(core): export `render()` and `renderToString()` helpers that wrap Ink's render methods with kidd's `KiddProvider` (screen context, output store, screen-backed log/spinner/report). Enables full lifecycle control from normal `command()` handlers.

Fixes #147
3 changes: 3 additions & 0 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export async function cli<TSchema extends z.ZodType = z.ZodType>(

applyDisplayGlobals(options.display)

const normalizedArgv = [String(argv.$0), ...process.argv.slice(ARGV_SLICE_START)]

const [runtimeError, runtime] = await createRuntime({
argv: normalizedArgv,
config: options.config,
dirs,
display: options.display,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/context/create-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isContextError } from './error.js'

function defaultOptions(): {
args: { name: string; verbose: boolean }
argv: readonly string[]
config: { debug: boolean }
meta: {
command: string[]
Expand All @@ -16,6 +17,7 @@ function defaultOptions(): {
} {
return {
args: { name: 'test', verbose: true },
argv: ['my-cli', 'deploy', 'preview', '--verbose'],
config: { debug: false },
meta: {
command: ['deploy', 'preview'],
Expand Down Expand Up @@ -67,6 +69,38 @@ describe('createContext()', () => {
})
})

// ---------------------------------------------------------------------------
// Raw
// ---------------------------------------------------------------------------

describe('raw', () => {
it('should contain the normalized argv', () => {
const ctx = createContext(defaultOptions())
expect(ctx.raw.argv).toEqual(['my-cli', 'deploy', 'preview', '--verbose'])
})

it('should have argv[0] as the CLI name', () => {
const ctx = createContext(defaultOptions())
expect(ctx.raw.argv[0]).toBe('my-cli')
})

it('should freeze the argv array', () => {
const ctx = createContext(defaultOptions())
expect(Object.isFrozen(ctx.raw.argv)).toBeTruthy()
})

it('should freeze the raw object', () => {
const ctx = createContext(defaultOptions())
expect(Object.isFrozen(ctx.raw)).toBeTruthy()
})

it('should not share references with the input array', () => {
const argv = ['my-cli', 'deploy']
const ctx = createContext({ ...defaultOptions(), argv })
expect(ctx.raw.argv).not.toBe(argv)
})
})

// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/context/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
*/
export interface CreateContextOptions<TArgs extends AnyRecord, TConfig extends AnyRecord> {
readonly args: TArgs
readonly argv: readonly string[]
readonly config: TConfig
readonly meta: {
readonly name: string
Expand Down Expand Up @@ -105,6 +106,7 @@ export function createContext<TArgs extends AnyRecord, TConfig extends AnyRecord
log: ctxLog,
meta: ctxMeta as CommandContext<TArgs, TConfig>['meta'],
prompts: ctxPrompts,
raw: Object.freeze({ argv: Object.freeze([...options.argv]) }),
status: ctxStatus,
store: ctxStore,
} as CommandContext<TArgs, TConfig>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/context/decorate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({

function defaultOptions(): {
args: { name: string }
argv: readonly string[]
config: Record<string, never>
meta: {
command: string[]
Expand All @@ -36,6 +37,7 @@ function defaultOptions(): {
} {
return {
args: { name: 'test' },
argv: ['my-cli', 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/context/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -806,4 +806,16 @@ export interface CommandContext<
* CLI metadata (name, version, resolved command path). Deeply immutable.
*/
readonly meta: DeepReadonly<Meta>

/**
* Raw invocation data not processed by the arg parser.
*
* `argv` is a normalized token array where `argv[0]` is always the CLI
* name regardless of invocation mode (`node script.js …` vs compiled
* binary). Middleware can inspect the full invocation without guessing
* the preamble offset.
*/
readonly raw: {
readonly argv: readonly string[]
}
}
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { autoload } from './autoload.js'
export { decorateContext } from './context/decorate.js'
export { middleware } from './middleware.js'
export { defineConfig } from '@kidd-cli/config'
export { screen, useScreenContext } from './screen/index.js'
export { render, renderToString, screen, useScreenContext } from './screen/index.js'
export type { ScreenDef, ScreenExit } from './screen/index.js'
export type {
CliConfig,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/middleware/auth/auth-http-chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock(import('@clack/prompts'), async (importOriginal) => ({
function createTestContext(cliName: string): ReturnType<typeof createContext> {
return createContext({
args: {},
argv: [cliName, 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/middleware/typed-middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface Organization {
function createTestContext(): CommandContext {
return createContext({
args: {},
argv: ['test-app', 'test'],
config: {},
meta: {
command: ['test'],
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export async function createRuntime<TSchema extends z.ZodType>(

const ctx = createContext({
args: validatedArgs,
argv: options.argv,
config,
display: options.display,
log: options.log,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
export interface RuntimeOptions<TSchema extends z.ZodType = z.ZodType> {
readonly name: string
readonly version: string
readonly argv: readonly string[]
readonly dirs: ResolvedDirs
readonly config?: CliConfigOptions<TSchema>
readonly middleware?: Middleware[]
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/screen/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { screen } from './screen.js'
export { render, renderToString } from './render.js'
export { screen, toScreenContext } from './screen.js'
export type { ScreenDef, ScreenExit } from './screen.js'

export { useScreenContext } from './provider.js'
135 changes: 135 additions & 0 deletions packages/core/src/screen/render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { ReactElement } from 'react'
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import type { CommandContext, ScreenContext, Store } from '../context/types.js'

vi.mock(import('ink'), () => ({
render: vi.fn(() => ({
cleanup: vi.fn(),

Check failure on line 9 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
clear: vi.fn(),

Check failure on line 10 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
rerender: vi.fn(),

Check failure on line 11 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
unmount: vi.fn(),

Check failure on line 12 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
waitUntilExit: vi.fn().mockResolvedValue(undefined),

Check failure on line 13 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
})),

Check failure on line 14 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
renderToString: vi.fn(() => 'rendered-output'),

Check failure on line 15 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

eslint-plugin-vitest(require-mock-type-parameters)

Missing type parameters on mock function call
}))

const ink = await import('ink')
const mockedInkRender = vi.mocked(ink.render)
const mockedInkRenderToString = vi.mocked(ink.renderToString)

function StubComponent(): null {
return null
}

function makeStore(): Store {
const map = new Map<string, unknown>()
return {
clear: () => map.clear(),
delete: (key: string) => map.delete(key),
get: (key: string) => map.get(key),
has: (key: string) => map.has(key),
set: (key: string, value: unknown) => map.set(key, value),
} as Store
}

const baseMeta = Object.freeze({
command: ['test'] as readonly string[],
dirs: Object.freeze({ global: '.cli', local: '.cli' }),
name: 'test-cli',
version: '1.0.0',
})

function makeContext(overrides?: Partial<CommandContext>): CommandContext {
return {
args: {},
colors: {} as CommandContext['colors'],
config: {},
fail: () => {
throw new Error('fail')
},
format: {} as CommandContext['format'],
log: {} as CommandContext['log'],
meta: baseMeta,
prompts: {} as CommandContext['prompts'],
raw: Object.freeze({ argv: Object.freeze(['test-cli', 'test']) }),
status: {} as CommandContext['status'],
store: makeStore(),
...overrides,
} as CommandContext
}

describe('render()', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('should call ink render and return an instance', async () => {
const { render } = await import('./render.js')
const instance = await render(<StubComponent />, makeContext())

expect(mockedInkRender).toHaveBeenCalledOnce()
expect(instance).toHaveProperty('waitUntilExit')
expect(instance).toHaveProperty('unmount')
})

it('should wrap node in KiddProvider with ScreenContext', async () => {
const { render } = await import('./render.js')
const ctx = makeContext({ config: { debug: true } })

await render(<StubComponent />, ctx)

const rendered = mockedInkRender.mock.calls[0]![0] as ReactElement

Check failure on line 83 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

typescript-eslint(no-non-null-assertion)

Forbidden non-null assertion.
const providerValue = rendered.props.value as ScreenContext
expect(providerValue.config).toEqual({ debug: true })
expect(providerValue).toHaveProperty('log')
expect(providerValue).not.toHaveProperty('fail')
expect(providerValue).not.toHaveProperty('prompts')
})

it('should pass render options through to ink', async () => {
const { render } = await import('./render.js')
const options = { debug: true }

await render(<StubComponent />, makeContext(), options)

expect(mockedInkRender).toHaveBeenCalledWith(expect.anything(), options)
})
})

describe('renderToString()', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('should call ink renderToString and return a string', async () => {
const { renderToString } = await import('./render.js')
const result = await renderToString(<StubComponent />, makeContext())

expect(mockedInkRenderToString).toHaveBeenCalledOnce()
expect(result).toBe('rendered-output')
})

it('should wrap node in KiddProvider with ScreenContext', async () => {
const { renderToString } = await import('./render.js')
const ctx = makeContext({ config: { theme: 'dark' } })

await renderToString(<StubComponent />, ctx)

const rendered = mockedInkRenderToString.mock.calls[0]![0] as ReactElement

Check failure on line 120 in packages/core/src/screen/render.test.tsx

View workflow job for this annotation

GitHub Actions / Code Standards & Tests

typescript-eslint(no-non-null-assertion)

Forbidden non-null assertion.
const providerValue = rendered.props.value as ScreenContext
expect(providerValue.config).toEqual({ theme: 'dark' })
expect(providerValue).not.toHaveProperty('colors')
expect(providerValue).not.toHaveProperty('format')
})

it('should pass options through to ink renderToString', async () => {
const { renderToString } = await import('./render.js')
const options = { columns: 120 }

await renderToString(<StubComponent />, makeContext(), options)

expect(mockedInkRenderToString).toHaveBeenCalledWith(expect.anything(), options)
})
})
55 changes: 55 additions & 0 deletions packages/core/src/screen/render.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Instance, RenderOptions, RenderToStringOptions } from 'ink'
import type { ReactNode } from 'react'
import React from 'react'

import type { CommandContext } from '../context/types.js'
import { KiddProvider } from './provider.js'
import { toScreenContext } from './screen.js'

/**
* Render a React element as a live Ink terminal application with the kidd
* screen context injected.
*
* Wraps the provided node in a {@link KiddProvider} that supplies the
* screen context (React-backed `log`, `spinner`, `store`, and any
* middleware-decorated properties). Returns the raw Ink {@link Instance}
* so callers have full lifecycle control — `waitUntilExit()`, `unmount()`,
* `rerender()`, etc.
*
* @param node - The React element to render.
* @param ctx - The command context to convert and inject as screen context.
* @param options - Optional Ink render options (stdout, stdin, debug, etc.).
* @returns The Ink render instance.
*/
export async function render(
node: ReactNode,
ctx: CommandContext,
options?: RenderOptions
): Promise<Instance> {
const { render: inkRender } = await import('ink')
const screenCtx = toScreenContext(ctx)
return inkRender(<KiddProvider value={screenCtx}>{node}</KiddProvider>, options)
}

/**
* Render a React element to a string with the kidd screen context injected.
*
* Wraps the provided node in a {@link KiddProvider} and delegates to Ink's
* `renderToString`. Useful for generating documentation, writing output to
* files, testing, or scenarios where rendered output is needed as a string
* without a persistent terminal session.
*
* @param node - The React element to render.
* @param ctx - The command context to convert and inject as screen context.
* @param options - Optional render-to-string options (columns width).
* @returns The rendered string output.
*/
export async function renderToString(
node: ReactNode,
ctx: CommandContext,
options?: RenderToStringOptions
): Promise<string> {
const { renderToString: inkRenderToString } = await import('ink')
const screenCtx = toScreenContext(ctx)
return inkRenderToString(<KiddProvider value={screenCtx}>{node}</KiddProvider>, options)
}
Loading
Loading