Skip to content

Commit a7dff7d

Browse files
zrosenbauerclaude
andauthored
feat(packages/core): add icons middleware (#56)
* feat(packages/core): add icons middleware with Nerd Font detection and install Add a new `icons` middleware that decorates `ctx.icons` with a callable icon resolver. Detects whether Nerd Fonts are installed via the `font-list` package, resolves icon names to Nerd Font glyphs or emoji fallbacks, and supports interactive font installation. Features: - Callable context: `ctx.icons('branch')` / `ctx.icons.get('branch')` - 33 predefined icons across 4 categories (git, devops, status, files) - System font detection and matching to Nerd Font equivalents - Interactive setup with font selection and auto-install or manual commands - Async shell commands for responsive spinner and ctrl+c support - Custom icon definitions via middleware options - Exported as `@kidd-cli/core/icons` Co-Authored-By: Claude <noreply@anthropic.com> * refactor(packages/core): remove callable object pattern from icons context Replace Object.assign on a function with a plain frozen object. ctx.icons is now a regular object with methods (get, has, installed, setup, category) instead of a callable function with properties bolted on. Co-Authored-By: Claude <noreply@anthropic.com> * fix(packages/core): address PR review comments for icons middleware - Add missing return after ctx.fail() in setup example to prevent fallthrough - Log error on auto-setup failure instead of silently discarding it - Change buildFontChoices return type to mutable array for clack compatibility Co-Authored-By: Claude <noreply@anthropic.com> * refactor(packages/core): address second round of PR review comments - Replace if/else with ts-pattern match() in setup example - Convert resolveInstallStatus to use object parameter - Add Zod validation for font names before shell interpolation - Convert all 2+ param helpers to use object destructuring Co-Authored-By: Claude <noreply@anthropic.com> * chore(packages/core): add changeset for icons middleware Co-Authored-By: Claude <noreply@anthropic.com> * feat(packages/core): add install tests, docs, and address review feedback - Add comprehensive tests for install.ts (font validation, selection flow, confirmation flow) - Add icons concept documentation at docs/concepts/icons.md - Use attemptAsync in detect.ts instead of try/catch - Add JSDoc explaining Unicode escape sequences in definitions.ts Co-Authored-By: Claude <noreply@anthropic.com> * feat(packages/core): add install tests, docs, and address review feedback - Remove mutable detection cache in favor of pure async function - Add Zod validation for font-list results at the boundary - Freeze icon definitions at factory time instead of per-request - Add canonical BREW_SLUG_MAP for correct Homebrew cask slugs - Document callable ctx.icons(name) shorthand in IconsContext - Fix lint violations in install.test.ts Co-Authored-By: Claude <noreply@anthropic.com> * fix(packages/core): address icons middleware code review feedback Remove redundant Zod validation in detect.ts, fix side-effect .map() in install.ts, correct font option default in docs, add readonly modifiers to buildFontChoices return type, and add platform-specific installation tests for darwin/linux/unsupported platforms. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bf8663 commit a7dff7d

26 files changed

+2437
-2
lines changed

.changeset/add-icons-middleware.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@kidd-cli/core': minor
3+
---
4+
5+
Add icons middleware with Nerd Font detection and interactive installation
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
---
2-
"@kidd-cli/core": minor
3-
"@kidd-cli/cli": minor
2+
'@kidd-cli/core': minor
3+
'@kidd-cli/cli': minor
44
---
55

66
Add `ConfigType` utility type and `CliConfig` augmentation interface for typed `ctx.config`.
77

88
**@kidd-cli/core:**
9+
910
- Add `ConfigType<TSchema>` utility type to derive `CliConfig` from a Zod schema
1011
- Rename `KiddConfig` augmentation interface to `CliConfig` to avoid confusion with the build config type in `@kidd-cli/config`
1112
- Export `CliConfig` and `ConfigType` from `@kidd-cli/core`
1213

1314
**@kidd-cli/cli:**
15+
1416
- Add `--config` flag to `kidd init` to scaffold config schema setup during project creation
1517
- Add `kidd add config` command to scaffold config into existing projects
1618
- Scaffolded config includes Zod schema with `ConfigType` module augmentation wiring

docs/concepts/icons.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# Icons
2+
3+
The icons system provides Nerd Font glyph resolution with automatic emoji fallback, font detection, interactive installation prompts, and categorized icon definitions for kidd CLIs.
4+
5+
Icons is a sub-export of the `@kidd-cli/core` package (`@kidd-cli/core/icons`), not a separate package. It ships as middleware that decorates `ctx.icons` with methods for resolving icon names to glyphs, checking font availability, and triggering installation.
6+
7+
## Key Concepts
8+
9+
### Nerd Font vs Emoji Fallback
10+
11+
Every icon has two representations: a Nerd Font glyph and an emoji fallback. When the middleware initializes, it detects whether Nerd Fonts are installed on the system. All subsequent `ctx.icons.get()` calls resolve to the appropriate variant automatically.
12+
13+
- **Nerd Fonts detected** -- returns the Nerd Font glyph (e.g. `\uE725` for `branch`)
14+
- **Nerd Fonts not detected** -- returns the emoji fallback (e.g. the twisted arrows emoji for `branch`)
15+
16+
This means commands never need to check font availability themselves. Call `ctx.icons.get('branch')` and the correct character is returned.
17+
18+
### Icon Categories
19+
20+
Icons are organized into four categories:
21+
22+
| Category | Description | Examples |
23+
| -------- | -------------------------- | -------------------------------------------- |
24+
| `git` | Version control operations | `branch`, `commit`, `merge`, `pr`, `tag` |
25+
| `devops` | Infrastructure and CI/CD | `deploy`, `docker`, `kubernetes`, `terminal` |
26+
| `status` | Status indicators | `success`, `error`, `warning`, `pending` |
27+
| `files` | File types and filesystem | `file`, `folder`, `typescript`, `json` |
28+
29+
### Auto-Setup
30+
31+
When `autoSetup` is enabled, the middleware checks for Nerd Font availability during initialization. If no Nerd Font is detected, it prompts the user to install one interactively. This runs once at startup, before any command handler executes.
32+
33+
## Adding the Middleware
34+
35+
```ts
36+
import { cli } from '@kidd-cli/core'
37+
import { icons } from '@kidd-cli/core/icons'
38+
39+
cli({
40+
name: 'my-app',
41+
version: '1.0.0',
42+
middleware: [icons()],
43+
commands: `${import.meta.dirname}/commands`,
44+
})
45+
```
46+
47+
### With Configuration
48+
49+
```ts
50+
icons({
51+
autoSetup: true,
52+
font: 'FiraCode',
53+
})
54+
```
55+
56+
## IconsOptions
57+
58+
| Option | Type | Default | Description |
59+
| ------------ | -------------------------------- | --------------------- | --------------------------------------------------------------------------- |
60+
| `icons` | `Record<string, IconDefinition>` | Built-in defaults | Custom icon definitions to merge with defaults |
61+
| `autoSetup` | `boolean` | `false` | Prompt to install Nerd Fonts if not detected |
62+
| `font` | `string` | Interactive selection | The Nerd Font family to install (when omitted, shows an interactive picker) |
63+
| `forceSetup` | `boolean` | `false` | Always show the install prompt, even if fonts are detected |
64+
65+
## IconsContext
66+
67+
The icons middleware decorates `ctx.icons` with an `IconsContext` object providing methods for icon resolution.
68+
69+
| Method | Type | Description |
70+
| --------------- | ----------------------------------------------- | --------------------------------------------- |
71+
| `get(name)` | `(name: string) => string` | Resolve an icon name to its glyph string |
72+
| `has(name)` | `(name: string) => boolean` | Check whether an icon name is defined |
73+
| `installed()` | `() => boolean` | Whether Nerd Fonts are detected on the system |
74+
| `setup()` | `() => AsyncResult<boolean, IconsError>` | Interactively prompt to install Nerd Fonts |
75+
| `category(cat)` | `(cat: IconCategory) => Record<string, string>` | Get all resolved icons for a given category |
76+
77+
### `ctx.icons.get()`
78+
79+
Resolve an icon name to its display string. Returns the Nerd Font glyph when fonts are installed, the emoji fallback otherwise. Returns an empty string when the name is not found.
80+
81+
```ts
82+
export default command({
83+
async handler(ctx) {
84+
const icon = ctx.icons.get('branch')
85+
ctx.logger.info(`${icon} Current branch: main`)
86+
},
87+
})
88+
```
89+
90+
### `ctx.icons.has()`
91+
92+
Check whether an icon name exists in the definitions (built-in or custom).
93+
94+
```ts
95+
if (ctx.icons.has('deploy')) {
96+
ctx.logger.info(`${ctx.icons.get('deploy')} Deploying...`)
97+
}
98+
```
99+
100+
### `ctx.icons.category()`
101+
102+
Retrieve all resolved icons for a category as a record of name-to-glyph mappings.
103+
104+
```ts
105+
const statusIcons = ctx.icons.category('status')
106+
// { success: '...', error: '...', warning: '...', ... }
107+
108+
ctx.logger.info(`${statusIcons.success} Build passed`)
109+
ctx.logger.error(`${statusIcons.error} Tests failed`)
110+
```
111+
112+
### `ctx.icons.installed()`
113+
114+
Check whether Nerd Fonts are available. When `forceSetup` is enabled, this always returns `false` to allow re-triggering the setup flow.
115+
116+
```ts
117+
if (!ctx.icons.installed()) {
118+
ctx.logger.warn('Nerd Fonts not detected. Icons will use emoji fallback.')
119+
}
120+
```
121+
122+
### `ctx.icons.setup()`
123+
124+
Interactively prompt the user to install Nerd Fonts. Returns a Result tuple with `true` on success or an `IconsError` on failure. On success, subsequent `get()` calls resolve to Nerd Font glyphs.
125+
126+
```ts
127+
if (!ctx.icons.installed()) {
128+
const [error, result] = await ctx.icons.setup()
129+
if (error) {
130+
ctx.logger.warn(`Font install failed: ${error.message}`)
131+
}
132+
}
133+
```
134+
135+
### IconsError
136+
137+
| `type` | Description |
138+
| -------------------- | -------------------------------- |
139+
| `'detection_failed'` | Nerd Font detection check failed |
140+
| `'install_failed'` | Font installation failed |
141+
142+
## Custom Icons
143+
144+
Merge custom icon definitions with the built-in defaults by passing an `icons` record. Each entry must provide both a `nerdFont` glyph and an `emoji` fallback.
145+
146+
```ts
147+
icons({
148+
icons: {
149+
lambda: { nerdFont: '\uE7A4', emoji: '\u{03BB}' },
150+
rust: { nerdFont: '\uE7A8', emoji: '\u{1F980}' },
151+
},
152+
})
153+
```
154+
155+
Custom definitions override built-in icons with the same name. Access them the same way:
156+
157+
```ts
158+
const icon = ctx.icons.get('lambda')
159+
```
160+
161+
### IconDefinition
162+
163+
```ts
164+
interface IconDefinition {
165+
readonly nerdFont: string
166+
readonly emoji: string
167+
}
168+
```
169+
170+
## Built-in Icons
171+
172+
### Git
173+
174+
`branch`, `clone`, `commit`, `compare`, `fetch`, `fork`, `git`, `merge`, `pr`, `tag`, `worktree`
175+
176+
### DevOps
177+
178+
`ci`, `cloud`, `deploy`, `docker`, `kubernetes`, `server`, `terminal`
179+
180+
### Status
181+
182+
`error`, `info`, `pending`, `running`, `stopped`, `success`, `warning`
183+
184+
### Files
185+
186+
`config`, `file`, `folder`, `javascript`, `json`, `lock`, `markdown`, `typescript`
187+
188+
## References
189+
190+
- [kidd API Reference](../reference/kidd.md)
191+
- [Context](./context.md)
192+
- [Lifecycle](./lifecycle.md)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { command } from '@kidd-cli/core'
2+
import { z } from 'zod'
3+
4+
const args = z.object({
5+
name: z.enum(['git', 'devops', 'status', 'files']).describe('Icon category to display'),
6+
})
7+
8+
export default command({
9+
args,
10+
description: 'List all icons in a category',
11+
handler: (ctx) => {
12+
const resolved = ctx.icons.category(ctx.args.name)
13+
14+
ctx.output.table(
15+
Object.entries(resolved).map(([name, glyph]) => ({
16+
Glyph: glyph,
17+
Name: name,
18+
}))
19+
)
20+
},
21+
})

examples/icons/commands/setup.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { command } from '@kidd-cli/core'
2+
import { match } from 'ts-pattern'
3+
4+
export default command({
5+
description: 'Interactively install Nerd Fonts',
6+
handler: async (ctx) => {
7+
if (ctx.icons.installed()) {
8+
ctx.logger.success('Nerd Fonts are already installed')
9+
return
10+
}
11+
12+
ctx.logger.info('Nerd Fonts are not installed on this system')
13+
const [error, installed] = await ctx.icons.setup()
14+
15+
if (error) {
16+
ctx.fail(error.message)
17+
return
18+
}
19+
20+
match(installed)
21+
.with(true, () => ctx.logger.success('Nerd Fonts installed successfully'))
22+
.with(false, () => ctx.logger.info('Installation skipped'))
23+
.exhaustive()
24+
},
25+
})

examples/icons/commands/show.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { command } from '@kidd-cli/core'
2+
import { z } from 'zod'
3+
4+
const args = z.object({
5+
name: z.string().describe('Icon name to look up'),
6+
})
7+
8+
export default command({
9+
args,
10+
description: 'Show a single icon by name',
11+
handler: (ctx) => {
12+
if (!ctx.icons.has(ctx.args.name)) {
13+
ctx.fail(`Unknown icon: "${ctx.args.name}"`)
14+
}
15+
16+
const glyph = ctx.icons.get(ctx.args.name)
17+
ctx.output.write(`${glyph} ${ctx.args.name}`)
18+
},
19+
})

examples/icons/commands/status.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { command } from '@kidd-cli/core'
2+
3+
export default command({
4+
description: 'Show Nerd Font detection status and all icons',
5+
handler: (ctx) => {
6+
if (ctx.icons.installed()) {
7+
ctx.logger.success('Nerd Fonts detected - showing Nerd Font glyphs')
8+
} else {
9+
ctx.logger.warn('Nerd Fonts not detected - showing emoji fallbacks')
10+
}
11+
12+
const categories = [
13+
{ label: 'Git icons:', name: 'git' as const },
14+
{ label: 'Status icons:', name: 'status' as const },
15+
{ label: 'DevOps icons:', name: 'devops' as const },
16+
{ label: 'File icons:', name: 'files' as const },
17+
]
18+
19+
const _logged = categories.map(({ label, name }) => {
20+
ctx.logger.info('')
21+
ctx.logger.info(label)
22+
const icons = ctx.icons.category(name)
23+
return Object.entries(icons).map(([iconName, glyph]) =>
24+
ctx.logger.info(` ${glyph} ${iconName}`)
25+
)
26+
})
27+
},
28+
})

examples/icons/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { cli } from '@kidd-cli/core'
2+
import { icons } from '@kidd-cli/core/icons'
3+
4+
cli({
5+
description: 'Icons middleware demo CLI',
6+
help: { header: 'icon-demo - explore Nerd Font and emoji icons' },
7+
middleware: [icons({ forceSetup: true })],
8+
name: 'icon-demo',
9+
version: '1.0.0',
10+
})

examples/icons/kidd.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from '@kidd-cli/core'
2+
3+
export default defineConfig({
4+
build: { out: './dist' },
5+
commands: './commands',
6+
entry: './index.ts',
7+
})

examples/icons/package.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@examples/icons",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "kidd dev",
8+
"build": "kidd build",
9+
"compile": "kidd compile",
10+
"routes": "kidd routes",
11+
"typecheck": "tsgo --noEmit"
12+
},
13+
"dependencies": {
14+
"@kidd-cli/core": "workspace:*",
15+
"zod": "catalog:"
16+
},
17+
"devDependencies": {
18+
"@kidd-cli/cli": "workspace:*",
19+
"tsdown": "catalog:",
20+
"tsx": "catalog:",
21+
"typescript": "catalog:"
22+
}
23+
}

0 commit comments

Comments
 (0)