cron-human 1.0.2 → 1.1.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Akin Ibitoye
3
+ Copyright (c) 2026 Akin
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -104,4 +104,57 @@ cron-human "*/15 * * * *" --json --quiet
104
104
 
105
105
  ## License
106
106
 
107
- MIT
107
+ MIT © Akin Ibitoye
108
+
109
+ ## Interactive Mode (TUI)
110
+
111
+ Launch the interactive Terminal UI with:
112
+
113
+ ```bash
114
+ cron-human tui
115
+ # or
116
+ cron-human --interactive
117
+ ```
118
+
119
+ ![Cron Human TUI Screenshot](tui-screenshot.png)
120
+
121
+ ### Key Features
122
+ - **Live Preview**: Real-time validation and English translation as you type.
123
+ - **History**: Auto-saves successful cron expressions. Navigate history with `Up/Down` and reload with `Enter`.
124
+ - **Clipboard Integration**: Paste (`Ctrl+V`) directly into the editor. Copy (`c`) saved expressions from history.
125
+ - **Toggle Options**: Quickly enable/disable seconds support (`Space`).
126
+
127
+ ### Keybindings
128
+
129
+ | Key | Action |
130
+ |---|---|
131
+ | `Tab` | Cycle focus (Input → Options → History) |
132
+ | `Ctrl+V` | Paste from clipboard |
133
+ | `Ctrl+R` | Reset/Clear input |
134
+ | `Ctrl+C` / `Q` | Quit |
135
+
136
+ **History Panel:**
137
+ | Key | Action |
138
+ |---|---|
139
+ | `Up` / `Down` | Navigate history |
140
+ | `Enter` | Load selected expression |
141
+ | `c` | Copy selected expression |
142
+
143
+ **Options Panel:**
144
+ | Key | Action |
145
+ |---|---|
146
+ | `Space` | Toggle checkbox |
147
+
148
+ ## Manual Test Cases
149
+
150
+ 1. **Basic Minute**: `* * * * *` -> "Every minute"
151
+ 2. **Specific Time**: `30 14 * * *` -> "At 14:30"
152
+ 3. **Interval**: `*/5 * * * *` -> "Every 5 minutes"
153
+ 4. **Range**: `0 9-17 * * 1-5` -> "At 0 minutes past the hour, between 09:00 and 17:59, Monday through Friday"
154
+ 5. **Macro**: `@daily` -> "At 00:00"
155
+ 6. **With Seconds**: `*/30 * * * * *` (Enable "Allow Seconds") -> "Every 30 seconds"
156
+ 7. **Invalid**: `* * * 99 *` -> "Error: Invalid cron expression..."
157
+ 8. **Timezone**: Set TZ to `UTC` -> Verify next runs match UTC.
158
+ 9. **History**: Enter a valid cron -> Press Up -> should see it.
159
+ 10. **Help**: Press `Tab` until Focus is History, then `Tab` again -> Focus Input.
160
+ 11. **Clipboard**: Press `Ctrl+V` to paste a cron string. Select history item and press `c` to copy.
package/dist/cli.js CHANGED
@@ -1,22 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { createRequire } from 'module';
4
3
  import { DateTime } from 'luxon';
5
4
  import { explainCron, getNextRuns, validateCron } from './lib.js';
6
- const require = createRequire(import.meta.url);
7
- const pkg = require('../package.json');
5
+ import { handleErr } from "./utils/error.js";
6
+ import pkg from "../package.json" with { type: "json" };
8
7
  const program = new Command();
9
8
  program
10
9
  .name('cron-human')
11
10
  .description('Converts cron expressions to human-readable English and prints next run times.')
12
11
  .version(pkg.version, '-v, --version')
13
- .argument('<expression>', 'Cron expression to parse')
12
+ .argument('[expression]', 'Cron expression to parse')
14
13
  .option('-n, --next <number>', 'how many upcoming run times to print', '5')
15
14
  .option('--tz <timezone>', 'timezone for output (default: system timezone)')
16
15
  .option('--json', 'output machine-readable JSON', false)
17
16
  .option('-q, --quiet', 'only print the one-line human explanation (no next runs)', false)
18
17
  .option('--seconds', 'support 6-field cron expressions with seconds', false)
19
- .action((expression, options) => {
18
+ .option('-i, --interactive', 'launch interactive TUI mode')
19
+ .action(async (expression, options) => {
20
+ if (options.interactive) {
21
+ const { startTui } = await import('./ui/launcher.js');
22
+ await startTui();
23
+ return;
24
+ }
25
+ if (!expression) {
26
+ program.help();
27
+ return;
28
+ }
20
29
  if (options.tz) {
21
30
  const test = DateTime.now().setZone(options.tz);
22
31
  if (!test.isValid) {
@@ -47,7 +56,8 @@ program
47
56
  description = explainCron(expression);
48
57
  }
49
58
  catch (e) {
50
- console.error(`Error: Could not generate description. ${e?.message ?? e}`);
59
+ const err = handleErr(e);
60
+ console.error(`Error: Could not generate description. ${err.message}`);
51
61
  process.exit(1);
52
62
  }
53
63
  const output = {
@@ -68,9 +78,17 @@ program
68
78
  }
69
79
  }
70
80
  }
71
- catch (err) {
72
- console.error(`Error: ${err.message}`);
81
+ catch (e) {
82
+ const err = handleErr(e);
83
+ console.error(err);
73
84
  process.exit(1);
74
85
  }
75
86
  });
87
+ // Add explicit command as well
88
+ program.command('tui')
89
+ .description('Launch the interactive TUI')
90
+ .action(async () => {
91
+ const { startTui } = await import('./ui/launcher.js');
92
+ await startTui();
93
+ });
76
94
  program.parse();
package/dist/lib.js CHANGED
@@ -2,23 +2,22 @@ import cronstrue from 'cronstrue';
2
2
  import { DateTime } from 'luxon';
3
3
  import { CronExpressionParser } from 'cron-parser';
4
4
  const MACROS = {
5
- "@yearly": "0 0 0 1 1 *",
6
- "@annually": "0 0 0 1 1 *",
7
- "@monthly": "0 0 0 1 * *",
8
- "@weekly": "0 0 0 * * 0",
9
- "@daily": "0 0 0 * * *",
10
- "@hourly": "0 0 * * * *",
11
- "@minutely": "0 * * * * *",
5
+ "@yearly": "0 0 1 1 *",
6
+ "@annually": "0 0 1 1 *",
7
+ "@monthly": "0 0 1 * *",
8
+ "@weekly": "0 0 * * 0",
9
+ "@daily": "0 0 * * *",
10
+ "@hourly": "0 * * * *",
11
+ "@minutely": "* * * * *",
12
12
  "@secondly": "* * * * * *",
13
- "@weekdays": "0 0 0 * * 1-5",
14
- "@weekends": "0 0 0 * * 0,6",
13
+ "@weekdays": "0 0 * * 1-5",
14
+ "@weekends": "0 0 * * 0,6",
15
15
  };
16
16
  function normalizeForParse(expr) {
17
17
  const t = expr.trim();
18
18
  if (!t.startsWith("@"))
19
19
  return t;
20
20
  const key = t.toLowerCase();
21
- // for parsing, use expanded cron if known, otherwise keep original (will error)
22
21
  return MACROS[key] ?? t;
23
22
  }
24
23
  function fieldCount(expr) {
@@ -51,15 +50,9 @@ export function validateCron(expression, options = {}) {
51
50
  }
52
51
  const normalized = normalizeForParse(expression);
53
52
  if (expression.trim().startsWith("@")) {
54
- try {
55
- const parseOpts = {};
56
- if (options.timezone)
57
- parseOpts.tz = options.timezone;
58
- CronExpressionParser.parse(normalized, parseOpts);
59
- return null;
60
- }
61
- catch (err) {
62
- return `Invalid cron expression: ${err.message}`;
53
+ const macro = expression.trim().toLowerCase();
54
+ if (macro === '@secondly' && !options.allowSeconds) {
55
+ return 'Error: @secondly requires --seconds flag.';
63
56
  }
64
57
  }
65
58
  const fields = fieldCount(normalized);
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const App: React.FC;
package/dist/ui/app.js ADDED
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import { InputSection } from './components/InputSection.js';
5
+ import { PreviewSection } from './components/PreviewSection.js';
6
+ import { OptionsPanel } from './components/OptionsPanel.js';
7
+ import { HistoryPanel } from './components/HistoryPanel.js';
8
+ import clipboardy from 'clipboardy';
9
+ var FocusArea;
10
+ (function (FocusArea) {
11
+ FocusArea[FocusArea["Input"] = 0] = "Input";
12
+ FocusArea[FocusArea["Options"] = 1] = "Options";
13
+ FocusArea[FocusArea["History"] = 2] = "History";
14
+ })(FocusArea || (FocusArea = {}));
15
+ export const App = () => {
16
+ const { exit } = useApp();
17
+ const [expression, setExpression] = useState('');
18
+ const [timezone, setTimezone] = useState(undefined);
19
+ const [allowSeconds, setAllowSeconds] = useState(false);
20
+ const [focus, setFocus] = useState(FocusArea.Input);
21
+ const [history, setHistory] = useState([]);
22
+ const [historyIndex, setHistoryIndex] = useState(0);
23
+ const [showHelp, setShowHelp] = useState(false);
24
+ const [notification, setNotification] = useState(null);
25
+ useEffect(() => {
26
+ if (notification) {
27
+ const timer = setTimeout(() => setNotification(null), 2000);
28
+ return () => clearTimeout(timer);
29
+ }
30
+ }, [notification]);
31
+ useInput((input, key) => {
32
+ if (key.ctrl && input === 'c') {
33
+ exit();
34
+ return;
35
+ }
36
+ if (input === 'q' && !key.ctrl && focus !== FocusArea.Input) {
37
+ exit();
38
+ return;
39
+ }
40
+ if (key.tab) {
41
+ setFocus((prev) => {
42
+ if (prev === FocusArea.Input)
43
+ return FocusArea.Options;
44
+ if (prev === FocusArea.Options)
45
+ return FocusArea.History;
46
+ return FocusArea.Input;
47
+ });
48
+ }
49
+ if (key.ctrl && input === 'r') {
50
+ setExpression('');
51
+ setFocus(FocusArea.Input);
52
+ }
53
+ if (input === '?') {
54
+ }
55
+ if (key.ctrl && input === 'v') {
56
+ if (focus === FocusArea.Input) {
57
+ clipboardy.read().then(text => {
58
+ if (text) {
59
+ setExpression(text.trim());
60
+ setNotification('Pasted from clipboard!');
61
+ }
62
+ }).catch(err => {
63
+ setNotification(`Paste failed: ${err.message}`);
64
+ });
65
+ }
66
+ }
67
+ if (focus === FocusArea.Options) {
68
+ if (input === ' ') {
69
+ setAllowSeconds(prev => !prev);
70
+ }
71
+ }
72
+ if (focus === FocusArea.History) {
73
+ if (key.upArrow) {
74
+ setHistoryIndex(prev => Math.max(0, prev - 1));
75
+ }
76
+ if (key.downArrow) {
77
+ setHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
78
+ }
79
+ if (key.return) {
80
+ if (history[historyIndex]) {
81
+ setExpression(history[historyIndex].expression);
82
+ setFocus(FocusArea.Input);
83
+ }
84
+ }
85
+ if (input === 'c') {
86
+ if (history[historyIndex]) {
87
+ clipboardy.write(history[historyIndex].expression).then(() => {
88
+ setNotification('Copied to clipboard!');
89
+ }).catch((err) => {
90
+ setNotification(`Copy failed: ${err.message}`);
91
+ });
92
+ }
93
+ }
94
+ }
95
+ });
96
+ const handleInputSubmit = (val) => {
97
+ if (val.trim()) {
98
+ const last = history[0];
99
+ if (!last || last.expression !== val) {
100
+ setHistory(prev => [{ expression: val, timestamp: new Date() }, ...prev].slice(0, 50));
101
+ }
102
+ }
103
+ };
104
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", height: "100%", children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: "Cron Human TUI" }), _jsx(Text, { children: " | " }), _jsx(Text, { dimColor: true, children: "Ctrl+C to Quit" })] }), notification && _jsx(Text, { color: "yellow", bold: true, children: notification })] }), _jsx(InputSection, { value: expression, onChange: setExpression, onSubmit: handleInputSubmit, isFocused: focus === FocusArea.Input }), _jsx(Box, { marginY: 1, children: _jsx(OptionsPanel, { isFocused: focus === FocusArea.Options, timezone: timezone || "Local", allowSeconds: allowSeconds, onToggleSeconds: () => setAllowSeconds(!allowSeconds), onChangeTimezone: setTimezone }) }), _jsx(PreviewSection, { expression: expression, timezone: timezone, allowSeconds: allowSeconds }), _jsxs(Box, { marginTop: 1, children: [_jsx(HistoryPanel, { isFocused: focus === FocusArea.History, items: history.slice(0, 5), selectedIndex: historyIndex }), history.length > 5 && _jsxs(Text, { dimColor: true, children: ["... ", history.length - 5, " more"] })] })] }));
105
+ };
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const HelpScreen: React.FC;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const HelpScreen = () => {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "white", padding: 1, children: [_jsx(Text, { bold: true, underline: true, children: "Help & Keybindings" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "Tab" }), _jsxs(Text, { children: [": Cycle focus (Input -", '>', " Options -", '>', " History)"] })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { children: ": Run conversion / Save to history" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Up/Down" }), _jsx(Text, { children: ": Navigate history" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "c" }), _jsx(Text, { children: ": Copy selected history item" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+V" }), _jsx(Text, { children: ": Paste from clipboard" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+R" }), _jsx(Text, { children: ": Reset input" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+C / Q" }), _jsx(Text, { children: ": Quit" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Examples:" }) }), _jsx(Box, { children: _jsx(Text, { children: "* * * * * (Every minute)" }) }), _jsx(Box, { children: _jsx(Text, { children: "0 12 * * 1-5 (At 12:00 on weekdays)" }) }), _jsx(Box, { children: _jsx(Text, { children: "@daily (Run once a day at midnight)" }) })] }));
5
+ };
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ interface HistoryItem {
3
+ expression: string;
4
+ timestamp: Date;
5
+ }
6
+ interface HistoryPanelProps {
7
+ isFocused: boolean;
8
+ items: HistoryItem[];
9
+ selectedIndex: number;
10
+ }
11
+ export declare const HistoryPanel: React.FC<HistoryPanelProps>;
12
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const HistoryPanel = ({ isFocused, items, selectedIndex }) => {
4
+ if (items.length === 0) {
5
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "History:" }), _jsx(Text, { dimColor: true, children: "No history yet. Press Enter to save successful runs." })] }));
6
+ }
7
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "History (Up/Down to navigate, Enter to load, 'c' to copy):" }), items.map((item, index) => (_jsx(Box, { children: _jsxs(Text, { color: index === selectedIndex ? "cyan" : "white", children: [index === selectedIndex ? "> " : " ", item.expression] }) }, index)))] }));
8
+ };
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ interface InputSectionProps {
3
+ value: string;
4
+ onChange: (value: string) => void;
5
+ onSubmit: (value: string) => void;
6
+ isFocused: boolean;
7
+ }
8
+ export declare const InputSection: React.FC<InputSectionProps>;
9
+ export {};
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ export const InputSection = ({ value, onChange, onSubmit, isFocused }) => {
5
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? "green" : "white", children: "Cron Expression:" }), _jsx(Box, { children: _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: isFocused, placeholder: "* * * * *" }) })] }));
6
+ };
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ interface OptionsPanelProps {
3
+ isFocused: boolean;
4
+ timezone: string;
5
+ allowSeconds: boolean;
6
+ onToggleSeconds: () => void;
7
+ onChangeTimezone: (tz: string) => void;
8
+ }
9
+ export declare const OptionsPanel: React.FC<OptionsPanelProps>;
10
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const OptionsPanel = ({ isFocused, timezone, allowSeconds, onToggleSeconds, onChangeTimezone }) => {
4
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Options (Press Tab to focus, then Space to toggle):" }), _jsxs(Box, { children: [_jsxs(Text, { color: isFocused ? "cyan" : "white", children: ["[ ", allowSeconds ? 'X' : ' ', " ] Allow Seconds"] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["Timezone: ", timezone || 'Local', " (Ctrl+T to set)"] }) })] })] }));
5
+ };
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface PreviewSectionProps {
3
+ expression: string;
4
+ timezone?: string;
5
+ allowSeconds?: boolean;
6
+ }
7
+ export declare const PreviewSection: React.FC<PreviewSectionProps>;
8
+ export {};
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { validateCron, explainCron, getNextRuns } from '../../lib.js';
4
+ export const PreviewSection = ({ expression, timezone, allowSeconds }) => {
5
+ const validationError = validateCron(expression, { timezone, allowSeconds });
6
+ let content;
7
+ let nextRuns = [];
8
+ let isError = false;
9
+ if (validationError) {
10
+ content = validationError;
11
+ isError = true;
12
+ }
13
+ else {
14
+ try {
15
+ content = explainCron(expression);
16
+ nextRuns = getNextRuns(expression, 3, timezone);
17
+ }
18
+ catch (e) {
19
+ content = e.message;
20
+ isError = true;
21
+ }
22
+ }
23
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: isError ? "red" : "blue", paddingX: 1, flexGrow: 1, children: [_jsx(Text, { bold: true, color: "white", children: "Human Readable:" }), _jsx(Text, { color: isError ? "red" : "green", children: content }), !isError && nextRuns.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Next runs:" }), nextRuns.map((run, i) => (_jsxs(Text, { dimColor: true, children: [" - ", run] }, i)))] }))] }));
24
+ };
@@ -0,0 +1 @@
1
+ export declare function startTui(): Promise<void>;
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { App } from './app.js';
4
+ export function startTui() {
5
+ const { waitUntilExit } = render(React.createElement(App));
6
+ return waitUntilExit();
7
+ }
@@ -0,0 +1,5 @@
1
+ declare class CliError extends Error {
2
+ constructor(msg: string);
3
+ }
4
+ declare function handleErr(err: unknown): CliError;
5
+ export { CliError, handleErr };
@@ -0,0 +1,17 @@
1
+ class CliError extends Error {
2
+ constructor(msg) {
3
+ super(msg);
4
+ }
5
+ }
6
+ function handleErr(err) {
7
+ if (err instanceof Error) {
8
+ return new CliError(err.message);
9
+ }
10
+ else if (typeof err === "string") {
11
+ return new CliError(err);
12
+ }
13
+ else {
14
+ return new CliError("unknown error");
15
+ }
16
+ }
17
+ export { CliError, handleErr };
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "cron-human",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "description": "A CLI that converts cron expressions to human-readable English and prints next run times",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",
7
+ "type": "module",
7
8
  "bin": {
8
9
  "cron-human": "dist/cli.js"
9
10
  },
10
- "type": "module",
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.build.json",
13
- "start": "node dist/cli.js",
13
+ "start": "node dist/cli.js '*/5 * * * *'",
14
14
  "dev": "tsx src/cli.ts",
15
15
  "test": "vitest run",
16
16
  "test:watch": "vitest",
@@ -38,21 +38,35 @@
38
38
  "LICENSE"
39
39
  ],
40
40
  "dependencies": {
41
+ "clipboardy": "^5.1.0",
41
42
  "commander": "^14.0.2",
42
43
  "cron-parser": "^5.5.0",
43
44
  "cronstrue": "^3.9.0",
44
- "luxon": "^3.7.2"
45
+ "ink": "^6.6.0",
46
+ "ink-select-input": "^6.2.0",
47
+ "ink-text-input": "^6.0.0",
48
+ "luxon": "^3.7.2",
49
+ "react": "^19.2.4"
45
50
  },
46
51
  "devDependencies": {
52
+ "@testing-library/react": "^16.3.2",
53
+ "@types/ink-select-input": "^3.0.5",
54
+ "@types/ink-text-input": "^2.0.5",
47
55
  "@types/luxon": "^3.7.1",
48
56
  "@types/node": "^25.0.10",
57
+ "@types/react": "^19.2.10",
49
58
  "@typescript-eslint/eslint-plugin": "^8.53.1",
50
59
  "@typescript-eslint/parser": "^8.53.1",
60
+ "chalk": "^5.6.2",
51
61
  "eslint": "^9.39.2",
52
62
  "eslint-config-prettier": "^10.1.8",
63
+ "ink-testing-library": "^4.0.0",
53
64
  "prettier": "^3.8.1",
54
65
  "tsx": "^4.21.0",
55
66
  "typescript": "^5.9.3",
56
67
  "vitest": "^4.0.17"
68
+ },
69
+ "overrides": {
70
+ "es-toolkit": "1.42.0"
57
71
  }
58
72
  }