bugcap 0.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/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { render } from "ink";
4
+ import React from "react";
5
+ import { resetState, loadState } from "./state.js";
6
+ import { startWatcher, startEmbeddedWatcher } from "./watcher.js";
7
+ import Game from "./components/Game.js";
8
+ const program = new Command();
9
+ program
10
+ .name("bugcap")
11
+ .description("Turn your AI coding into a terminal idle game")
12
+ .version("0.1.0");
13
+ // Default command — watcher + TUI together
14
+ program
15
+ .action(() => {
16
+ const dir = process.cwd();
17
+ const cleanupWatcher = startEmbeddedWatcher(dir);
18
+ const { unmount } = render(React.createElement(Game));
19
+ function cleanup() {
20
+ cleanupWatcher();
21
+ unmount();
22
+ process.exit(0);
23
+ }
24
+ process.on("SIGINT", cleanup);
25
+ process.on("SIGTERM", cleanup);
26
+ });
27
+ // Watch command — headless watcher (no TUI)
28
+ program
29
+ .command("watch [path]")
30
+ .description("Start a headless watcher (no game UI) for an additional project directory")
31
+ .action((targetPath) => {
32
+ const dir = targetPath ?? process.cwd();
33
+ startWatcher(dir);
34
+ });
35
+ // Reset command
36
+ program
37
+ .command("reset")
38
+ .description("Reset all game progress")
39
+ .option("-f, --force", "Skip confirmation prompt")
40
+ .action(async (options) => {
41
+ const state = loadState();
42
+ if (!state) {
43
+ console.log("No game data to reset.");
44
+ return;
45
+ }
46
+ if (!options.force) {
47
+ const readline = await import("node:readline");
48
+ const rl = readline.createInterface({
49
+ input: process.stdin,
50
+ output: process.stdout,
51
+ });
52
+ const answer = await new Promise((resolve) => {
53
+ rl.question(`Are you sure you want to reset? You'll lose $${state.money.toLocaleString()} and ${state.tokens.toLocaleString()} tokens. (y/N) `, resolve);
54
+ });
55
+ rl.close();
56
+ if (answer.toLowerCase() !== "y") {
57
+ console.log("Reset cancelled.");
58
+ return;
59
+ }
60
+ }
61
+ resetState();
62
+ console.log("Game progress has been reset.");
63
+ });
64
+ program.parse();
@@ -0,0 +1,2 @@
1
+ import React from "react";
2
+ export default function Game(): React.JSX.Element;
@@ -0,0 +1,172 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from "react";
3
+ import { Box, Text, useStdout } from "ink";
4
+ import { loadState, loadRecentEvents, } from "../state.js";
5
+ import { getLevelMultiplier } from "../economy.js";
6
+ // ── Helpers ──────────────────────────────────────────────
7
+ function formatNumber(n) {
8
+ return n.toLocaleString("en-US");
9
+ }
10
+ function formatMoney(n) {
11
+ if (n >= 1_000_000)
12
+ return `$${(n / 1_000_000).toFixed(2)}M`;
13
+ if (n >= 10_000)
14
+ return `$${(n / 1_000).toFixed(1)}K`;
15
+ return `$${formatNumber(n)}`;
16
+ }
17
+ function xpForNextLevel(level) {
18
+ return (level + 1) * 500;
19
+ }
20
+ function timeAgo(timestamp) {
21
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
22
+ if (seconds < 5)
23
+ return "now";
24
+ if (seconds < 60)
25
+ return `${seconds}s`;
26
+ const minutes = Math.floor(seconds / 60);
27
+ if (minutes < 60)
28
+ return `${minutes}m`;
29
+ const hours = Math.floor(minutes / 60);
30
+ return `${hours}h`;
31
+ }
32
+ // ── ASCII Art ────────────────────────────────────────────
33
+ const LOGO_LINES = [
34
+ " ____ _ _ ____ ____ _ ____ ",
35
+ "| __ )| | | |/ ___| / ___| / \\ | _ \\ ",
36
+ "| _ \\| | | | | _ | | / _ \\ | |_) |",
37
+ "| |_) | |_| | |_| | | |___ / ___ \\| __/ ",
38
+ "|____/ \\___/ \\____| \\____/_/ \\_\\_| ",
39
+ ];
40
+ const LOGO_COLORS = [
41
+ "green",
42
+ "green",
43
+ "cyan",
44
+ "cyan",
45
+ "yellow",
46
+ ];
47
+ const BUG_ART = [
48
+ " _ ",
49
+ " / \\ ",
50
+ " / _ \\ ",
51
+ " | |_| | ",
52
+ " \\___/ BUG! ",
53
+ ];
54
+ const MINI_BUG = "~(o.o)~";
55
+ // ── Animated Components ──────────────────────────────────
56
+ const PULSE_FRAMES = ["*", "o", "O", "o"];
57
+ const SPINNER_FRAMES = [" ", ". ", ".. ", "..."];
58
+ function useAnimationFrame(fps = 4) {
59
+ const [frame, setFrame] = useState(0);
60
+ useEffect(() => {
61
+ const interval = setInterval(() => {
62
+ setFrame((f) => f + 1);
63
+ }, 1000 / fps);
64
+ return () => clearInterval(interval);
65
+ }, [fps]);
66
+ return frame;
67
+ }
68
+ // ── Progress Bar ─────────────────────────────────────────
69
+ function ProgressBar({ current, max, width = 20, filledColor = "green", emptyColor, }) {
70
+ const ratio = Math.min(current / max, 1);
71
+ const filled = Math.round(ratio * width);
72
+ const empty = width - filled;
73
+ const percent = Math.round(ratio * 100);
74
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "white", children: "[" }), _jsx(Text, { color: filledColor, children: "█".repeat(filled) }), _jsx(Text, { dimColor: true, children: "░".repeat(empty) }), _jsx(Text, { color: "white", children: "]" }), _jsxs(Text, { dimColor: true, children: [" ", percent, "%"] })] }));
75
+ }
76
+ // ── Stat Box ─────────────────────────────────────────────
77
+ function StatBox({ label, value, color = "white", }) {
78
+ return (_jsxs(Box, { flexDirection: "column", marginRight: 2, children: [_jsx(Text, { dimColor: true, children: label }), _jsx(Text, { bold: true, color: color, children: value })] }));
79
+ }
80
+ // ── Event Line ───────────────────────────────────────────
81
+ function EventLine({ event, showTime = false, }) {
82
+ const iconMap = {
83
+ token_gain: " +",
84
+ bug_found: " !",
85
+ bug_sold: " $",
86
+ level_up: " *",
87
+ };
88
+ const colorMap = {
89
+ token_gain: "green",
90
+ bug_found: "red",
91
+ bug_sold: "yellow",
92
+ level_up: "cyan",
93
+ };
94
+ const icon = iconMap[event.type] ?? " ?";
95
+ const color = colorMap[event.type] ?? "white";
96
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: icon }), _jsx(Text, { children: " " }), _jsx(Text, { color: color, children: event.description ?? event.type }), showTime && _jsxs(Text, { dimColor: true, children: [" ", timeAgo(event.timestamp)] })] }));
97
+ }
98
+ // ── Welcome Screen ───────────────────────────────────────
99
+ function WelcomeScreen() {
100
+ const frame = useAnimationFrame(2);
101
+ const bugFrame = frame % 2 === 0 ? MINI_BUG : "~(O.O)~";
102
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { flexDirection: "column", marginBottom: 1, children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: line }, i))) }), _jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: bugFrame }), _jsx(Text, { children: " Watching for changes... Start coding!" })] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "Your AI coding activity will appear here." }), _jsx(Text, { dimColor: true, children: "Watch extra projects: npx bugcap watch ~/other-project" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, italic: true, children: "Turn your AI coding into a terminal idle game" }) })] }));
103
+ }
104
+ // ── Compact View ─────────────────────────────────────────
105
+ function CompactView({ state, isOnline, }) {
106
+ const frame = useAnimationFrame(2);
107
+ const pulse = isOnline ? PULSE_FRAMES[frame % PULSE_FRAMES.length] : "x";
108
+ const pulseColor = isOnline ? "green" : "red";
109
+ return (_jsx(Box, { flexDirection: "column", padding: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: pulseColor, children: ["[", pulse, "]"] }), _jsxs(Text, { bold: true, color: "green", children: [" ", "BUG CAP", " "] }), _jsx(Text, { bold: true, color: "yellow", children: formatMoney(state.money) }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: "cyan", children: ["Lv", state.level] }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { children: ["T:", formatNumber(state.tokens)] }), _jsx(Text, { dimColor: true, children: " | " }), _jsxs(Text, { color: "red", children: ["B:", state.bugsSold] })] }) }));
110
+ }
111
+ // ── Normal View ──────────────────────────────────────────
112
+ function NormalView({ state, events, isOnline, }) {
113
+ const frame = useAnimationFrame(4);
114
+ const multiplier = getLevelMultiplier(state.level);
115
+ const xpNeeded = xpForNextLevel(state.level);
116
+ const xpProgress = Math.min(state.xp, xpNeeded);
117
+ // Animated watcher indicator
118
+ const pulse = isOnline ? PULSE_FRAMES[frame % PULSE_FRAMES.length] : "x";
119
+ const pulseColor = isOnline ? "green" : "red";
120
+ const statusText = isOnline ? "LIVE" : "OFFLINE";
121
+ // Money flash effect: alternate bold on recent change
122
+ const recentMoney = events.some((e) => e.type === "bug_sold" && Date.now() - e.timestamp < 3000);
123
+ const moneyFlash = recentMoney && frame % 2 === 0;
124
+ // Activity dots
125
+ const dots = isOnline ? SPINNER_FRAMES[frame % SPINNER_FRAMES.length] : "";
126
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Box, { flexDirection: "row", justifyContent: "space-between", children: _jsx(Box, { children: LOGO_LINES.map((line, i) => (_jsx(Text, { color: LOGO_COLORS[i], children: i === 0 ? line : "\n" + line }, i))) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [_jsxs(Text, { color: pulseColor, bold: true, children: ["[", pulse, "]"] }), _jsxs(Text, { color: pulseColor, bold: true, children: [" ", statusText] }), _jsx(Text, { dimColor: true, children: dots })] }) }), _jsx(Box, { borderStyle: "round", borderColor: moneyFlash ? "green" : "yellow", paddingX: 2, marginTop: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "Bug Bounty" }), _jsxs(Text, { bold: true, color: moneyFlash ? "green" : "yellow", children: [" ", "$", formatNumber(state.money)] })] }) }), _jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(StatBox, { label: "Level", value: `${state.level} (${multiplier.toFixed(2)}x)`, color: "cyan" }), _jsx(StatBox, { label: "Bugs Sold", value: formatNumber(state.bugsSold), color: "red" }), _jsx(StatBox, { label: "Tokens", value: formatNumber(state.tokens), color: "green" })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "XP " }), _jsxs(Text, { children: [formatNumber(xpProgress), "/", formatNumber(xpNeeded)] })] }), _jsx(ProgressBar, { current: xpProgress, max: xpNeeded, width: 30, filledColor: "cyan" })] }), events.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, dimColor: true, children: [" ", "Recent Activity", " "] }), events.map((event, i) => (_jsx(EventLine, { event: event, showTime: i === events.length - 1 }, i)))] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, italic: true, children: [MINI_BUG, " Keep coding to earn more!"] }) })] }));
127
+ }
128
+ // ── Main Component ───────────────────────────────────────
129
+ export default function Game() {
130
+ const [state, setState] = useState(null);
131
+ const [events, setEvents] = useState([]);
132
+ const [initialized, setInitialized] = useState(false);
133
+ const { stdout } = useStdout();
134
+ const columns = stdout?.columns ?? 80;
135
+ useEffect(() => {
136
+ function poll() {
137
+ const s = loadState();
138
+ setState(s);
139
+ if (s) {
140
+ setEvents(loadRecentEvents(8));
141
+ }
142
+ if (!initialized)
143
+ setInitialized(true);
144
+ }
145
+ poll();
146
+ const interval = setInterval(poll, 1000);
147
+ return () => clearInterval(interval);
148
+ }, [initialized]);
149
+ if (!initialized) {
150
+ return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "cyan", children: "Loading..." }) }));
151
+ }
152
+ if (!state) {
153
+ return _jsx(WelcomeScreen, {});
154
+ }
155
+ const isOnline = isWatcherOnline(state);
156
+ if (columns < 60) {
157
+ return _jsx(CompactView, { state: state, isOnline: isOnline });
158
+ }
159
+ return _jsx(NormalView, { state: state, events: events, isOnline: isOnline });
160
+ }
161
+ function isWatcherOnline(state) {
162
+ if (state.watcherPid) {
163
+ try {
164
+ process.kill(state.watcherPid, 0);
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ return false;
172
+ }
@@ -0,0 +1,3 @@
1
+ import { GameState, GameEvent } from "./state.js";
2
+ export declare function getLevelMultiplier(level: number): number;
3
+ export declare function processFileChange(state: GameState, linesChanged: number): GameEvent[];
@@ -0,0 +1,77 @@
1
+ import { appendEvent } from "./state.js";
2
+ // Tokens earned per line changed
3
+ const TOKENS_PER_LINE = 5;
4
+ // Base bug chance per batch of changes (percentage)
5
+ const BUG_BASE_CHANCE = 15;
6
+ // Extra bug chance per 50 lines changed
7
+ const BUG_CHANCE_PER_50_LINES = 10;
8
+ // Base price for selling a bug
9
+ const BUG_BASE_PRICE = 25;
10
+ // XP gained per token earned
11
+ const XP_PER_TOKEN = 0.1;
12
+ // XP required for each level: level N requires N * 500 XP
13
+ function xpForLevel(level) {
14
+ return level * 500;
15
+ }
16
+ export function getLevelMultiplier(level) {
17
+ return 1 + (level - 1) * 0.25;
18
+ }
19
+ export function processFileChange(state, linesChanged) {
20
+ const events = [];
21
+ // Calculate tokens
22
+ const tokens = Math.max(1, Math.round(linesChanged * TOKENS_PER_LINE));
23
+ state.tokens += tokens;
24
+ const tokenEvent = {
25
+ type: "token_gain",
26
+ value: tokens,
27
+ timestamp: Date.now(),
28
+ description: `${linesChanged} lines changed — +${tokens} tokens`,
29
+ };
30
+ events.push(tokenEvent);
31
+ appendEvent(tokenEvent);
32
+ // XP gain
33
+ state.xp += Math.round(tokens * XP_PER_TOKEN);
34
+ // Check level up
35
+ while (state.xp >= xpForLevel(state.level + 1)) {
36
+ state.level++;
37
+ const levelEvent = {
38
+ type: "level_up",
39
+ value: state.level,
40
+ timestamp: Date.now(),
41
+ description: `Level up! Now level ${state.level}`,
42
+ };
43
+ events.push(levelEvent);
44
+ appendEvent(levelEvent);
45
+ }
46
+ // Bug generation — chance increases with more lines changed
47
+ const bugChance = BUG_BASE_CHANCE + Math.floor(linesChanged / 50) * BUG_CHANCE_PER_50_LINES;
48
+ if (Math.random() * 100 < bugChance) {
49
+ state.bugs++;
50
+ const bugEvent = {
51
+ type: "bug_found",
52
+ value: 1,
53
+ timestamp: Date.now(),
54
+ description: "Bug detected — queued for sale",
55
+ };
56
+ events.push(bugEvent);
57
+ appendEvent(bugEvent);
58
+ }
59
+ // Auto-sell bugs
60
+ while (state.bugs > 0) {
61
+ const multiplier = getLevelMultiplier(state.level);
62
+ const price = Math.round(BUG_BASE_PRICE * multiplier);
63
+ state.bugs--;
64
+ state.bugsSold++;
65
+ state.money += price;
66
+ const sellEvent = {
67
+ type: "bug_sold",
68
+ value: price,
69
+ timestamp: Date.now(),
70
+ description: `Bug sold — +$${price}`,
71
+ };
72
+ events.push(sellEvent);
73
+ appendEvent(sellEvent);
74
+ }
75
+ state.lastEventAt = Date.now();
76
+ return events;
77
+ }
@@ -0,0 +1,23 @@
1
+ export interface GameState {
2
+ tokens: number;
3
+ bugs: number;
4
+ bugsSold: number;
5
+ money: number;
6
+ xp: number;
7
+ level: number;
8
+ lastEventAt: number;
9
+ watcherPid: number | null;
10
+ }
11
+ export interface GameEvent {
12
+ type: "token_gain" | "bug_found" | "bug_sold" | "level_up";
13
+ value: number;
14
+ timestamp: number;
15
+ description?: string;
16
+ }
17
+ export declare function defaultState(): GameState;
18
+ export declare function loadState(): GameState | null;
19
+ export declare function saveState(state: GameState): void;
20
+ export declare function appendEvent(event: GameEvent): void;
21
+ export declare function loadRecentEvents(count?: number): GameEvent[];
22
+ export declare function resetState(): void;
23
+ export declare function getStatePath(): string;
package/dist/state.js ADDED
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const BUGCAP_DIR = path.join(os.homedir(), ".bugcap");
5
+ const STATE_FILE = path.join(BUGCAP_DIR, "state.json");
6
+ const EVENTS_FILE = path.join(BUGCAP_DIR, "events.jsonl");
7
+ const MAX_EVENTS_SIZE = 5 * 1024 * 1024; // 5MB
8
+ export function defaultState() {
9
+ return {
10
+ tokens: 0,
11
+ bugs: 0,
12
+ bugsSold: 0,
13
+ money: 0,
14
+ xp: 0,
15
+ level: 1,
16
+ lastEventAt: 0,
17
+ watcherPid: null,
18
+ };
19
+ }
20
+ function ensureDir() {
21
+ if (!fs.existsSync(BUGCAP_DIR)) {
22
+ fs.mkdirSync(BUGCAP_DIR, { recursive: true });
23
+ }
24
+ }
25
+ export function loadState() {
26
+ try {
27
+ if (!fs.existsSync(STATE_FILE))
28
+ return null;
29
+ const data = fs.readFileSync(STATE_FILE, "utf-8");
30
+ return JSON.parse(data);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ export function saveState(state) {
37
+ ensureDir();
38
+ fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
39
+ }
40
+ export function appendEvent(event) {
41
+ ensureDir();
42
+ // Rotate if too large
43
+ try {
44
+ if (fs.existsSync(EVENTS_FILE)) {
45
+ const stats = fs.statSync(EVENTS_FILE);
46
+ if (stats.size >= MAX_EVENTS_SIZE) {
47
+ // Keep last half of the file
48
+ const content = fs.readFileSync(EVENTS_FILE, "utf-8");
49
+ const lines = content.trim().split("\n");
50
+ const half = lines.slice(Math.floor(lines.length / 2));
51
+ fs.writeFileSync(EVENTS_FILE, half.join("\n") + "\n");
52
+ }
53
+ }
54
+ }
55
+ catch {
56
+ // ignore rotation errors
57
+ }
58
+ fs.appendFileSync(EVENTS_FILE, JSON.stringify(event) + "\n");
59
+ }
60
+ export function loadRecentEvents(count = 10) {
61
+ try {
62
+ if (!fs.existsSync(EVENTS_FILE))
63
+ return [];
64
+ const content = fs.readFileSync(EVENTS_FILE, "utf-8");
65
+ const lines = content.trim().split("\n").filter(Boolean);
66
+ return lines
67
+ .slice(-count)
68
+ .map((line) => JSON.parse(line));
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ export function resetState() {
75
+ try {
76
+ if (fs.existsSync(STATE_FILE))
77
+ fs.unlinkSync(STATE_FILE);
78
+ if (fs.existsSync(EVENTS_FILE))
79
+ fs.unlinkSync(EVENTS_FILE);
80
+ }
81
+ catch {
82
+ // ignore
83
+ }
84
+ }
85
+ export function getStatePath() {
86
+ return STATE_FILE;
87
+ }
@@ -0,0 +1,4 @@
1
+ /** Headless watcher with console output — used by `bugcap watch` */
2
+ export declare function startWatcher(targetDir: string): void;
3
+ /** Embedded watcher (no console output) — used by default `bugcap` command */
4
+ export declare function startEmbeddedWatcher(targetDir: string): () => void;
@@ -0,0 +1,170 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { watch } from "chokidar";
4
+ import { loadState, saveState, defaultState } from "./state.js";
5
+ import { processFileChange } from "./economy.js";
6
+ const IGNORED_DIRS = [
7
+ ".git",
8
+ "node_modules",
9
+ "vendor",
10
+ "dist",
11
+ "build",
12
+ ".next",
13
+ "__pycache__",
14
+ ".venv",
15
+ "target",
16
+ ];
17
+ // Track file sizes to estimate lines changed
18
+ const fileSizes = new Map();
19
+ function getFileLineCount(filePath) {
20
+ try {
21
+ const content = fs.readFileSync(filePath, "utf-8");
22
+ return content.split("\n").length;
23
+ }
24
+ catch {
25
+ return 0;
26
+ }
27
+ }
28
+ function loadGitignorePatterns(dir) {
29
+ const gitignorePath = path.join(dir, ".gitignore");
30
+ try {
31
+ if (!fs.existsSync(gitignorePath))
32
+ return [];
33
+ const content = fs.readFileSync(gitignorePath, "utf-8");
34
+ return content
35
+ .split("\n")
36
+ .map((line) => line.trim())
37
+ .filter((line) => line && !line.startsWith("#"));
38
+ }
39
+ catch {
40
+ return [];
41
+ }
42
+ }
43
+ // Debounce changes per file
44
+ const pendingChanges = new Map();
45
+ const DEBOUNCE_MS = 500;
46
+ function createWatcher(targetDir) {
47
+ const resolvedDir = path.resolve(targetDir);
48
+ if (!fs.existsSync(resolvedDir)) {
49
+ throw new Error(`Directory "${resolvedDir}" does not exist.`);
50
+ }
51
+ // Load or create state
52
+ let state = loadState() ?? defaultState();
53
+ state.watcherPid = process.pid;
54
+ saveState(state);
55
+ // Build ignore patterns
56
+ const gitignorePatterns = loadGitignorePatterns(resolvedDir);
57
+ const ignored = [
58
+ ...IGNORED_DIRS.map((d) => `**/${d}/**`),
59
+ ...gitignorePatterns,
60
+ ];
61
+ const watcher = watch(resolvedDir, {
62
+ ignored,
63
+ persistent: true,
64
+ ignoreInitial: true,
65
+ awaitWriteFinish: {
66
+ stabilityThreshold: 300,
67
+ pollInterval: 100,
68
+ },
69
+ });
70
+ function handleChange(filePath) {
71
+ const existing = pendingChanges.get(filePath);
72
+ if (existing)
73
+ clearTimeout(existing);
74
+ pendingChanges.set(filePath, setTimeout(() => {
75
+ pendingChanges.delete(filePath);
76
+ const currentLines = getFileLineCount(filePath);
77
+ const previousLines = fileSizes.get(filePath) ?? 0;
78
+ const linesChanged = Math.abs(currentLines - previousLines);
79
+ fileSizes.set(filePath, currentLines);
80
+ if (linesChanged === 0 && previousLines > 0)
81
+ return;
82
+ const effectiveLines = Math.max(1, linesChanged);
83
+ state = loadState() ?? state;
84
+ processFileChange(state, effectiveLines);
85
+ saveState(state);
86
+ }, DEBOUNCE_MS));
87
+ }
88
+ return { watcher, handleChange };
89
+ }
90
+ /** Headless watcher with console output — used by `bugcap watch` */
91
+ export function startWatcher(targetDir) {
92
+ const resolvedDir = path.resolve(targetDir);
93
+ let state;
94
+ const { watcher, handleChange } = createWatcher(resolvedDir);
95
+ state = loadState();
96
+ let totalTokens = state?.tokens ?? 0;
97
+ let totalBugs = state?.bugsSold ?? 0;
98
+ let eventCount = 0;
99
+ console.log(`Bug Capitalist — watching ${resolvedDir}`);
100
+ console.log("Capturing file changes... (Ctrl+C to stop)\n");
101
+ console.log("Open another terminal and run: npx bugcap");
102
+ console.log("to see your score and game progress.\n");
103
+ function updateStatusLine() {
104
+ const s = loadState();
105
+ if (s) {
106
+ totalTokens = s.tokens;
107
+ totalBugs = s.bugsSold;
108
+ }
109
+ process.stdout.write(`\rTokens: ${totalTokens.toLocaleString()} | Bugs sold: ${totalBugs} | Events: ${eventCount} `);
110
+ }
111
+ updateStatusLine();
112
+ // Wrap handleChange to also update the status line
113
+ function handleChangeWithStatus(filePath) {
114
+ handleChange(filePath);
115
+ // Update status after debounce settles
116
+ setTimeout(() => {
117
+ eventCount++;
118
+ updateStatusLine();
119
+ }, DEBOUNCE_MS + 100);
120
+ }
121
+ watcher.on("add", handleChangeWithStatus);
122
+ watcher.on("change", handleChangeWithStatus);
123
+ watcher.on("unlink", (filePath) => {
124
+ const previousLines = fileSizes.get(filePath) ?? 0;
125
+ fileSizes.delete(filePath);
126
+ if (previousLines > 0) {
127
+ const s = loadState() ?? defaultState();
128
+ processFileChange(s, previousLines);
129
+ saveState(s);
130
+ eventCount++;
131
+ updateStatusLine();
132
+ }
133
+ });
134
+ function cleanup() {
135
+ console.log("\n\nWatcher stopped. Your progress has been saved.");
136
+ const s = loadState();
137
+ if (s) {
138
+ s.watcherPid = null;
139
+ saveState(s);
140
+ }
141
+ watcher.close();
142
+ process.exit(0);
143
+ }
144
+ process.on("SIGINT", cleanup);
145
+ process.on("SIGTERM", cleanup);
146
+ }
147
+ /** Embedded watcher (no console output) — used by default `bugcap` command */
148
+ export function startEmbeddedWatcher(targetDir) {
149
+ const { watcher, handleChange } = createWatcher(targetDir);
150
+ watcher.on("add", handleChange);
151
+ watcher.on("change", handleChange);
152
+ watcher.on("unlink", (filePath) => {
153
+ const previousLines = fileSizes.get(filePath) ?? 0;
154
+ fileSizes.delete(filePath);
155
+ if (previousLines > 0) {
156
+ let state = loadState() ?? defaultState();
157
+ processFileChange(state, previousLines);
158
+ saveState(state);
159
+ }
160
+ });
161
+ // Return cleanup function
162
+ return () => {
163
+ const s = loadState();
164
+ if (s) {
165
+ s.watcherPid = null;
166
+ saveState(s);
167
+ }
168
+ watcher.close();
169
+ };
170
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "bugcap",
3
+ "version": "0.1.0",
4
+ "description": "Turn your AI coding into a terminal idle game",
5
+ "type": "module",
6
+ "bin": {
7
+ "bugcap": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "dev": "tsc --watch",
12
+ "start": "node dist/cli.js",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": ["cli", "terminal", "idle-game", "ai", "developer-tools"],
16
+ "license": "MIT",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "dependencies": {
24
+ "chokidar": "^4.0.3",
25
+ "commander": "^13.1.0",
26
+ "ink": "^5.1.0",
27
+ "react": "^18.3.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.13.10",
31
+ "@types/react": "^18.3.18",
32
+ "typescript": "^5.8.2"
33
+ }
34
+ }