ecklf 1.0.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/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # ecklf
2
+
3
+ A terminal UI for [ecklf.com](https://ecklf.com). Browse Florentin's projects and open
4
+ them on the web or GitHub — complete with a spinning ASCII wireframe and typed
5
+ multi‑language greetings, just like the website.
6
+
7
+ ## Usage
8
+
9
+ Run it without installing:
10
+
11
+ ```sh
12
+ npx ecklf
13
+ ```
14
+
15
+ Or install globally:
16
+
17
+ ```sh
18
+ npm i -g ecklf
19
+ # then
20
+ ecklf
21
+ ```
22
+
23
+ ## Controls
24
+
25
+ | Key | Action |
26
+ | -------------- | ---------------------------------------- |
27
+ | `↑` / `↓`, `j` / `k` | Navigate projects |
28
+ | `↵` | Open menu → choose **Website** / **GitHub** |
29
+ | `w` | Open ecklf.com in your browser |
30
+ | `g` | Open the selected project on GitHub |
31
+ | `←` / `→` | Switch choice (in the open menu) |
32
+ | `esc` | Back |
33
+ | `q` | Quit |
34
+
35
+ ## How it works
36
+
37
+ - Project data is fetched **live** from [ecklf.com](https://ecklf.com) at launch and
38
+ parsed from the server-rendered markup. If the site can't be reached it falls back
39
+ to a bundled snapshot, so it still works offline.
40
+ - The header features two animations mirroring the site:
41
+ - a rotating 3D wireframe **torus-knot** rendered with braille characters, and
42
+ - a **typewriter** cycling greetings in different languages.
43
+
44
+ ## Development
45
+
46
+ This project uses [pnpm](https://pnpm.io).
47
+
48
+ ```sh
49
+ pnpm install
50
+ pnpm build # compile TypeScript -> dist/
51
+ pnpm start # run the built CLI
52
+ pnpm dev # tsc --watch
53
+ ```
54
+
55
+ ## License
56
+
57
+ MIT © [Florentin Eckl](https://ecklf.com)
package/dist/App.js ADDED
@@ -0,0 +1,86 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ import { Box, Text, useApp, useInput } from "ink";
4
+ import { Greeting } from "./Greeting.js";
5
+ import { Knot } from "./Knot.js";
6
+ import { openUrl } from "./open.js";
7
+ import { fetchSiteData, SITE_URL } from "./data.js";
8
+ const ACCENT = "cyanBright";
9
+ export const App = () => {
10
+ const { exit } = useApp();
11
+ const [status, setStatus] = useState("loading");
12
+ const [data, setData] = useState(null);
13
+ const [selected, setSelected] = useState(0);
14
+ const [mode, setMode] = useState("list");
15
+ const [actionChoice, setActionChoice] = useState(0); // 0 = website, 1 = github
16
+ const [opened, setOpened] = useState(null);
17
+ useEffect(() => {
18
+ let active = true;
19
+ fetchSiteData().then((d) => {
20
+ if (!active)
21
+ return;
22
+ setData(d);
23
+ setStatus("ready");
24
+ });
25
+ return () => {
26
+ active = false;
27
+ };
28
+ }, []);
29
+ useInput((input, key) => {
30
+ if (input === "q" || (key.ctrl && input === "c")) {
31
+ exit();
32
+ return;
33
+ }
34
+ if (status !== "ready" || !data)
35
+ return;
36
+ const projects = data.projects;
37
+ if (mode === "list") {
38
+ if (key.upArrow || input === "k") {
39
+ setSelected((s) => (s - 1 + projects.length) % projects.length);
40
+ setOpened(null);
41
+ }
42
+ else if (key.downArrow || input === "j") {
43
+ setSelected((s) => (s + 1) % projects.length);
44
+ setOpened(null);
45
+ }
46
+ else if (key.return || input === " ") {
47
+ setMode("action");
48
+ setActionChoice(1); // default highlight GitHub
49
+ }
50
+ else if (input === "g") {
51
+ openUrl(projects[selected].github);
52
+ setOpened(projects[selected].github);
53
+ }
54
+ else if (input === "w") {
55
+ openUrl(SITE_URL);
56
+ setOpened(SITE_URL);
57
+ }
58
+ }
59
+ else {
60
+ // action mode
61
+ if (key.leftArrow || key.rightArrow || input === "h" || input === "l" || key.tab) {
62
+ setActionChoice((c) => (c === 0 ? 1 : 0));
63
+ }
64
+ else if (key.return) {
65
+ const p = projects[selected];
66
+ const url = actionChoice === 0 ? SITE_URL : p.github;
67
+ openUrl(url);
68
+ setOpened(url);
69
+ setMode("list");
70
+ }
71
+ else if (key.escape) {
72
+ setMode("list");
73
+ }
74
+ }
75
+ }, { isActive: process.stdin.isTTY === true });
76
+ if (status === "loading") {
77
+ return (_jsx(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: _jsx(Text, { color: "gray", children: "Connecting to ecklf.com\u2026" }) }));
78
+ }
79
+ const d = data;
80
+ const projects = d.projects;
81
+ const current = projects[selected];
82
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Box, { flexDirection: "row", children: [_jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", children: [_jsx(Box, { children: _jsx(Greeting, {}) }), _jsx(Text, { bold: true, color: "whiteBright", children: d.name }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: d.tagline }), _jsx(Text, { color: "gray", children: d.subtitle })] })] }), _jsx(Box, { width: 28, height: 14, justifyContent: "flex-end", children: _jsx(Knot, { cols: 26, rows: 14 }) })] }), _jsxs(Box, { marginTop: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "whiteBright", children: "Projects" }), _jsxs(Text, { color: "gray", children: [" ", d.source === "live" ? "● live" : "○ offline"] })] }), _jsx(Box, { flexDirection: "column", children: projects.map((p, i) => {
83
+ const active = i === selected;
84
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: active ? ACCENT : "gray", children: active ? "❯ " : " " }), _jsx(Text, { bold: true, color: active ? "whiteBright" : "white", children: p.title })] }), active && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: "gray", dimColor: true, children: p.description }) }))] }, p.github));
85
+ }) }), mode === "action" && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "gray", children: ["Open ", _jsx(Text, { color: "whiteBright", children: current.title }), " \u2192"] }), _jsxs(Box, { marginTop: 0, children: [_jsx(Text, { color: actionChoice === 0 ? "black" : ACCENT, backgroundColor: actionChoice === 0 ? ACCENT : undefined, children: " Website " }), _jsx(Text, { children: " " }), _jsx(Text, { color: actionChoice === 1 ? "black" : ACCENT, backgroundColor: actionChoice === 1 ? ACCENT : undefined, children: " GitHub " })] })] })), opened && mode === "list" && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "greenBright", children: "\u2197 opened " }), _jsx(Text, { color: "gray", children: opened })] })), _jsx(Box, { marginTop: 1, children: mode === "list" ? (_jsx(Text, { color: "gray", dimColor: true, children: "\u2191/\u2193 navigate \u00B7 \u21B5 open\u2026 \u00B7 w website \u00B7 g github \u00B7 q quit" })) : (_jsx(Text, { color: "gray", dimColor: true, children: "\u2190/\u2192 choose \u00B7 \u21B5 confirm \u00B7 esc back" })) })] }));
86
+ };
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { Text } from "ink";
4
+ // Multi-language greetings, cycled with a typewriter effect — mirrors the
5
+ // animated headline on ecklf.com.
6
+ const GREETINGS = [
7
+ "Hallo",
8
+ "Hello",
9
+ "Bonjour",
10
+ "Hola",
11
+ "Ciao",
12
+ "こんにちは",
13
+ "안녕하세요",
14
+ "你好",
15
+ "Olá",
16
+ "Привет",
17
+ "Hej",
18
+ "Merhaba",
19
+ ];
20
+ export const Greeting = () => {
21
+ const [index, setIndex] = useState(0);
22
+ const [text, setText] = useState("");
23
+ const [phase, setPhase] = useState("typing");
24
+ const [cursorOn, setCursorOn] = useState(true);
25
+ const timer = useRef();
26
+ // Blinking cursor.
27
+ useEffect(() => {
28
+ const id = setInterval(() => setCursorOn((c) => !c), 450);
29
+ return () => clearInterval(id);
30
+ }, []);
31
+ useEffect(() => {
32
+ const word = GREETINGS[index];
33
+ const schedule = (fn, ms) => {
34
+ timer.current = setTimeout(fn, ms);
35
+ };
36
+ if (phase === "typing") {
37
+ if (text.length < word.length) {
38
+ schedule(() => setText(word.slice(0, text.length + 1)), 90);
39
+ }
40
+ else {
41
+ setPhase("holding");
42
+ }
43
+ }
44
+ else if (phase === "holding") {
45
+ schedule(() => setPhase("deleting"), 1400);
46
+ }
47
+ else {
48
+ if (text.length > 0) {
49
+ schedule(() => setText(word.slice(0, text.length - 1)), 45);
50
+ }
51
+ else {
52
+ setIndex((i) => (i + 1) % GREETINGS.length);
53
+ setPhase("typing");
54
+ }
55
+ }
56
+ return () => {
57
+ if (timer.current)
58
+ clearTimeout(timer.current);
59
+ };
60
+ }, [text, phase, index]);
61
+ return (_jsxs(Text, { bold: true, color: "white", children: [text, _jsx(Text, { color: "cyanBright", children: cursorOn ? "▋" : " " })] }));
62
+ };
package/dist/Knot.js ADDED
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { useClock } from "./hooks.js";
4
+ import { renderKnot } from "./knot-renderer.js";
5
+ export const Knot = ({ cols = 26, rows = 14 }) => {
6
+ const t = useClock(24);
7
+ const frame = renderKnot(t, cols, rows);
8
+ return _jsx(Text, { color: "gray", children: frame });
9
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { render } from "ink";
4
+ import { App } from "./App.js";
5
+ const args = process.argv.slice(2);
6
+ if (args.includes("--help") || args.includes("-h")) {
7
+ console.log(`
8
+ ecklf — a terminal UI for ecklf.com
9
+
10
+ Usage
11
+ $ ecklf
12
+
13
+ Controls
14
+ ↑/↓ or j/k navigate projects
15
+ ↵ open menu (Website / GitHub)
16
+ w open ecklf.com
17
+ g open selected project on GitHub
18
+ q quit
19
+ `);
20
+ process.exit(0);
21
+ }
22
+ if (args.includes("--version") || args.includes("-v")) {
23
+ console.log("1.0.0");
24
+ process.exit(0);
25
+ }
26
+ if (!process.stdin.isTTY) {
27
+ console.error("ecklf is an interactive terminal app — please run it directly in a terminal.");
28
+ process.exit(1);
29
+ }
30
+ // Enter alternate screen buffer so the TUI doesn't clutter scrollback.
31
+ process.stdout.write("\x1b[?1049h");
32
+ const { waitUntilExit } = render(_jsx(App, {}));
33
+ waitUntilExit().then(() => {
34
+ process.stdout.write("\x1b[?1049l");
35
+ });
package/dist/data.js ADDED
@@ -0,0 +1,92 @@
1
+ const SITE_URL = "https://ecklf.com";
2
+ /**
3
+ * Fallback data (last known good snapshot of ecklf.com) used when the
4
+ * site cannot be reached. Keeps the TUI useful offline.
5
+ */
6
+ const FALLBACK = {
7
+ name: "I'm Florentin",
8
+ tagline: "Full Stack Developer from Germany",
9
+ subtitle: "Compute Infrastructure ▲ Vercel",
10
+ source: "fallback",
11
+ projects: [
12
+ { title: "tailwindcss-radix", description: "Utilities and variants for styling Radix state", github: "https://github.com/ecklf/tailwindcss-radix" },
13
+ { title: "tailwindcss-base-ui", description: "Utilities and variants for styling Base UI state", github: "https://github.com/ecklf/tailwindcss-base-ui" },
14
+ { title: "tailwindcss-figma-kit", description: "Design Kit for Utility-First CSS", github: "https://github.com/ecklf/tailwindcss-figma-kit" },
15
+ { title: "tailwindcss-figma-plugin", description: "Style importer for Figma", github: "https://github.com/ecklf/tailwindcss-figma-plugin" },
16
+ { title: "flutters", description: "Flappy Bird + Doodle Jump", github: "https://github.com/ecklf/flutters" },
17
+ { title: "coingecko-rs", description: "Rust client for the CoinGecko API", github: "https://github.com/ecklf/coingecko-rs" },
18
+ { title: "macos-tags", description: "Rust library for modifying macOS tags", github: "https://github.com/ecklf/macos-tags" },
19
+ { title: "reddit-clawler", description: "A simple Reddit Crawler written in Rust", github: "https://github.com/ecklf/reddit-clawler" },
20
+ ],
21
+ };
22
+ function decodeEntities(input) {
23
+ return input
24
+ .replace(/&amp;/g, "&")
25
+ .replace(/&lt;/g, "<")
26
+ .replace(/&gt;/g, ">")
27
+ .replace(/&quot;/g, '"')
28
+ .replace(/&#39;|&#x27;/g, "'")
29
+ .replace(/&apos;/g, "'")
30
+ .replace(/&nbsp;/g, " ");
31
+ }
32
+ function stripTags(input) {
33
+ return decodeEntities(input.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim();
34
+ }
35
+ /**
36
+ * Parse the server-rendered ecklf.com markup. The project list lives inside
37
+ * <li> elements that each contain an <h3> title, a <p> description and an
38
+ * <a href="…github…"> link.
39
+ */
40
+ export function parseSite(html) {
41
+ const projects = [];
42
+ const liRegex = /<li\b[^>]*>([\s\S]*?)<\/li>/gi;
43
+ let match;
44
+ while ((match = liRegex.exec(html)) !== null) {
45
+ const block = match[1];
46
+ const hrefMatch = block.match(/href="(https:\/\/github\.com\/ecklf\/[^"]+)"/i);
47
+ if (!hrefMatch)
48
+ continue;
49
+ const titleMatch = block.match(/<h3\b[^>]*>([\s\S]*?)<\/h3>/i);
50
+ const descMatch = block.match(/<p\b[^>]*>([\s\S]*?)<\/p>/i);
51
+ if (!titleMatch)
52
+ continue;
53
+ const title = stripTags(titleMatch[1]);
54
+ const description = descMatch ? stripTags(descMatch[1]) : "";
55
+ const github = hrefMatch[1];
56
+ if (title && !projects.some((p) => p.github === github)) {
57
+ projects.push({ title, description, github });
58
+ }
59
+ }
60
+ return projects;
61
+ }
62
+ export async function fetchSiteData(timeoutMs = 6000) {
63
+ const controller = new AbortController();
64
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
65
+ try {
66
+ const res = await fetch(SITE_URL, {
67
+ signal: controller.signal,
68
+ headers: { "user-agent": "ecklf-cli" },
69
+ });
70
+ if (!res.ok)
71
+ throw new Error(`HTTP ${res.status}`);
72
+ const html = await res.text();
73
+ const projects = parseSite(html);
74
+ if (projects.length === 0)
75
+ return FALLBACK;
76
+ return {
77
+ name: FALLBACK.name,
78
+ tagline: FALLBACK.tagline,
79
+ subtitle: FALLBACK.subtitle,
80
+ projects,
81
+ source: "live",
82
+ };
83
+ }
84
+ catch {
85
+ return FALLBACK;
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ export const websiteUrl = (project) => SITE_URL;
92
+ export { SITE_URL, FALLBACK };
package/dist/hooks.js ADDED
@@ -0,0 +1,26 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ /**
3
+ * Drives a continuously increasing clock (seconds) using setInterval,
4
+ * suitable for animation. `fps` controls update frequency.
5
+ */
6
+ export function useClock(fps = 20) {
7
+ const [t, setT] = useState(0);
8
+ const start = useRef(Date.now());
9
+ useEffect(() => {
10
+ const id = setInterval(() => {
11
+ setT((Date.now() - start.current) / 1000);
12
+ }, 1000 / fps);
13
+ return () => clearInterval(id);
14
+ }, [fps]);
15
+ return t;
16
+ }
17
+ export function useInterval(callback, ms) {
18
+ const saved = useRef(callback);
19
+ useEffect(() => {
20
+ saved.current = callback;
21
+ }, [callback]);
22
+ useEffect(() => {
23
+ const id = setInterval(() => saved.current(), ms);
24
+ return () => clearInterval(id);
25
+ }, [ms]);
26
+ }
@@ -0,0 +1,104 @@
1
+ // 3D torus-knot wireframe renderer -> ASCII braille grid.
2
+ // Mirrors the spinning wireframe on ecklf.com.
3
+ // Generate vertices along a (p,q) torus knot.
4
+ function torusKnot(samples, p = 2, q = 3, R = 1, r = 0.4) {
5
+ const pts = [];
6
+ for (let i = 0; i < samples; i++) {
7
+ const u = (i / samples) * Math.PI * 2;
8
+ const cu = Math.cos(q * u);
9
+ const x = (R + r * cu) * Math.cos(p * u);
10
+ const y = (R + r * cu) * Math.sin(p * u);
11
+ const z = r * Math.sin(q * u);
12
+ pts.push({ x, y, z });
13
+ }
14
+ return pts;
15
+ }
16
+ function rotate(p, ax, ay, az) {
17
+ // X
18
+ let { x, y, z } = p;
19
+ let cy = Math.cos(ax), sy = Math.sin(ax);
20
+ let ny = y * cy - z * sy;
21
+ let nz = y * sy + z * cy;
22
+ y = ny;
23
+ z = nz;
24
+ // Y
25
+ cy = Math.cos(ay);
26
+ sy = Math.sin(ay);
27
+ let nx = x * cy + z * sy;
28
+ nz = -x * sy + z * cy;
29
+ x = nx;
30
+ z = nz;
31
+ // Z
32
+ cy = Math.cos(az);
33
+ sy = Math.sin(az);
34
+ nx = x * cy - y * sy;
35
+ ny = x * sy + y * cy;
36
+ x = nx;
37
+ y = ny;
38
+ return { x, y, z };
39
+ }
40
+ // Braille dot bit layout (2 cols x 4 rows per cell).
41
+ const BRAILLE_OFFSETS = [
42
+ [0x01, 0x08],
43
+ [0x02, 0x10],
44
+ [0x04, 0x20],
45
+ [0x40, 0x80],
46
+ ];
47
+ const SAMPLES = 240;
48
+ const KNOT = torusKnot(SAMPLES);
49
+ /**
50
+ * Render the rotating knot to a string. `cols`/`rows` are character cell
51
+ * dimensions; the braille grid is 2x wider and 4x taller in dots.
52
+ */
53
+ export function renderKnot(t, cols, rows) {
54
+ const dotW = cols * 2;
55
+ const dotH = rows * 4;
56
+ const grid = new Uint8Array(cols * rows);
57
+ const ax = t * 0.7;
58
+ const ay = t * 1.0;
59
+ const az = t * 0.3;
60
+ const scale = Math.min(dotW, dotH) * 0.32;
61
+ const cx = dotW / 2;
62
+ const cy = dotH / 2;
63
+ const persp = 3.2;
64
+ const projected = new Array(SAMPLES);
65
+ for (let i = 0; i < SAMPLES; i++) {
66
+ const r = rotate(KNOT[i], ax, ay, az);
67
+ const depth = persp / (persp + r.z);
68
+ const px = cx + r.x * scale * depth;
69
+ // terminal cells ~2x taller than wide -> compress vertical a touch
70
+ const py = cy + r.y * scale * depth * 1.0;
71
+ projected[i] = { x: px, y: py };
72
+ }
73
+ const plot = (x, y) => {
74
+ const xi = Math.round(x);
75
+ const yi = Math.round(y);
76
+ if (xi < 0 || xi >= dotW || yi < 0 || yi >= dotH)
77
+ return;
78
+ const cellX = xi >> 1;
79
+ const cellY = yi >> 2;
80
+ const idx = cellY * cols + cellX;
81
+ const bit = BRAILLE_OFFSETS[yi & 3][xi & 1];
82
+ grid[idx] |= bit;
83
+ };
84
+ // Draw line segments between consecutive (looping) knot points.
85
+ for (let i = 0; i < SAMPLES; i++) {
86
+ const a = projected[i];
87
+ const b = projected[(i + 1) % SAMPLES];
88
+ const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y)));
89
+ for (let s = 0; s <= steps; s++) {
90
+ const f = s / steps;
91
+ plot(a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f);
92
+ }
93
+ }
94
+ let out = "";
95
+ for (let row = 0; row < rows; row++) {
96
+ for (let col = 0; col < cols; col++) {
97
+ const v = grid[row * cols + col];
98
+ out += v === 0 ? " " : String.fromCharCode(0x2800 + v);
99
+ }
100
+ if (row < rows - 1)
101
+ out += "\n";
102
+ }
103
+ return out;
104
+ }
package/dist/open.js ADDED
@@ -0,0 +1,34 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Open a URL in the user's default browser. Cross-platform, dependency-free.
4
+ */
5
+ export function openUrl(url) {
6
+ const platform = process.platform;
7
+ let command;
8
+ let args;
9
+ if (platform === "darwin") {
10
+ command = "open";
11
+ args = [url];
12
+ }
13
+ else if (platform === "win32") {
14
+ command = "cmd";
15
+ args = ["/c", "start", "", url];
16
+ }
17
+ else {
18
+ command = "xdg-open";
19
+ args = [url];
20
+ }
21
+ try {
22
+ const child = spawn(command, args, {
23
+ stdio: "ignore",
24
+ detached: true,
25
+ });
26
+ child.on("error", () => {
27
+ /* swallow — nothing we can do in a TUI */
28
+ });
29
+ child.unref();
30
+ }
31
+ catch {
32
+ /* ignore */
33
+ }
34
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "ecklf",
3
+ "version": "1.0.0",
4
+ "description": "A terminal UI for ecklf.com — browse Florentin's projects and open them on the web or GitHub, with a spinning ASCII wireframe and typed greetings.",
5
+ "keywords": [
6
+ "ecklf",
7
+ "cli",
8
+ "tui",
9
+ "ink",
10
+ "portfolio",
11
+ "terminal"
12
+ ],
13
+ "author": "Florentin Eckl <hi@ecklf.com> (https://ecklf.com)",
14
+ "license": "MIT",
15
+ "homepage": "https://ecklf.com",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/ecklf/ecklf.git"
19
+ },
20
+ "type": "module",
21
+ "packageManager": "pnpm@10.33.0",
22
+ "bin": {
23
+ "ecklf": "dist/cli.js"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc",
33
+ "dev": "tsc --watch",
34
+ "start": "node dist/cli.js",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "dependencies": {
38
+ "ink": "^5.0.1",
39
+ "react": "^18.3.1"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^26.0.1",
43
+ "@types/react": "^18.3.12",
44
+ "typescript": "^5.6.3"
45
+ }
46
+ }