caroushell 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ubershmekel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # Caroushell
2
+
3
+ Caroushell is an interactive terminal carousel that suggests commands from your
4
+ history, AI prompts, and configuration snippets so you can pick the next shell
5
+ command without leaving the keyboard.
6
+
7
+ ## Features
8
+
9
+ - Dual-pane carousel that combines history-based and AI-generated command
10
+ suggestions.
11
+ - Runs the selected command directly in your current terminal session.
12
+ - Logs activity under `~/.caroushell/logs` for easy troubleshooting.
13
+ - Extensible config file (`~/.caroushell/config.json`) so you can point the CLI
14
+ at different API keys or settings.
15
+
16
+ ## Requirements
17
+
18
+ - Node.js 18 or newer.
19
+ - A `~/.caroushell/config.json` file that contains the tokens Caroushell needs.
20
+ Currently the file expects a Gemini API key:
21
+
22
+ ```json
23
+ {
24
+ "GEMINI_API_KEY": "your-api-key"
25
+ }
26
+ ```
27
+
28
+ ## Installation
29
+
30
+ Install globally (recommended):
31
+
32
+ ```bash
33
+ npm install -g caroushell
34
+ caroushell
35
+ ```
36
+
37
+ Or run it ad-hoc with NPX once it is published:
38
+
39
+ ```bash
40
+ npx caroushell
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ Caroushell opens an interactive prompt:
46
+
47
+ - Type to update the suggestions immediately and trigger refreshed history/AI
48
+ results.
49
+ - Use arrow keys to move between suggestions in the carousel.
50
+ - Press `Enter` to run the highlighted command.
51
+ - Press `Ctrl+C` to exit. `Ctrl+D` exits when the current row is empty.
52
+
53
+ Logs are written to `~/.caroushell/logs/MM-DD.txt`. Inspect these files if you
54
+ need to debug AI suggestions or the terminal renderer. Configuration lives at
55
+ `~/.caroushell/config.json` (override via `CAROUSHELL_CONFIG_PATH`).
56
+
57
+ ## Development
58
+
59
+ ```bash
60
+ npm install
61
+ npm run dev # tsx watch mode
62
+ npm run build # emits dist/
63
+ npm run test:generate
64
+ npm publish --dry-run # verify package contents before publishing
65
+ ```
66
+
67
+ The `prepare` script automatically builds before `npm publish` or when
68
+ installing from git. The package ships only the compiled `dist/` output plus
69
+ this README and the MIT license so `npx caroushell` works immediately.
70
+
71
+ ## License
72
+
73
+ Released under the [MIT License](./LICENSE).
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AISuggester = void 0;
4
+ exports.generateContent = generateContent;
5
+ const logs_1 = require("./logs");
6
+ const config_1 = require("./config");
7
+ async function generateContent(prompt, options) {
8
+ const apiKey = options?.apiKey || process.env.GEMINI_API_KEY;
9
+ const model = options?.model || "gemini-2.5-flash-lite";
10
+ const temperature = options?.temperature ?? 0.3;
11
+ const maxOutputTokens = options?.maxOutputTokens ?? 256;
12
+ if (!apiKey) {
13
+ (0, logs_1.logLine)("AI generation skipped: missing API key");
14
+ return "";
15
+ }
16
+ if (!prompt.trim()) {
17
+ (0, logs_1.logLine)("AI generation skipped: empty prompt");
18
+ return "";
19
+ }
20
+ try {
21
+ const start = Date.now();
22
+ const fetchImpl = globalThis.fetch;
23
+ if (!fetchImpl)
24
+ return "";
25
+ const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`, {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({
29
+ contents: [
30
+ {
31
+ role: "user",
32
+ parts: [{ text: prompt }],
33
+ },
34
+ ],
35
+ generationConfig: {
36
+ temperature,
37
+ maxOutputTokens,
38
+ },
39
+ }),
40
+ });
41
+ if (!res.ok)
42
+ return "";
43
+ const json = (await res.json());
44
+ const text = json?.candidates?.[0]?.content?.parts?.[0]?.text || "";
45
+ const out = typeof text === "string" ? text : "";
46
+ const duration = Date.now() - start;
47
+ // Log duration and each non-empty line of the AI text
48
+ try {
49
+ await (0, logs_1.logLine)(`AI duration: ${duration} ms`);
50
+ if (out.trim()) {
51
+ const lines = out
52
+ .split(/\r?\n/)
53
+ .map((s) => s.trim())
54
+ .filter(Boolean);
55
+ // .map((s) => `AI text: ${s}`);
56
+ // await logLines(lines);
57
+ }
58
+ }
59
+ catch {
60
+ // best-effort logging; ignore failures
61
+ }
62
+ return out;
63
+ }
64
+ catch {
65
+ return "";
66
+ }
67
+ }
68
+ class AISuggester {
69
+ constructor(opts) {
70
+ this.prefix = "🤖";
71
+ this.apiKey = opts?.apiKey;
72
+ this.model = opts?.model || "gemini-2.5-flash-lite";
73
+ }
74
+ async init() {
75
+ this.apiKey =
76
+ this.apiKey ||
77
+ (await (0, config_1.getConfig)()).GEMINI_API_KEY ||
78
+ process.env.GEMINI_API_KEY;
79
+ }
80
+ descriptionForAi() {
81
+ return "";
82
+ }
83
+ async suggest(carousel, maxDisplayed) {
84
+ if (!this.apiKey) {
85
+ (0, logs_1.logLine)("AI generation skipped: missing API key");
86
+ return [];
87
+ }
88
+ const descriptions = [];
89
+ for (const suggester of carousel.getSuggesters()) {
90
+ const desc = suggester.descriptionForAi();
91
+ if (desc) {
92
+ descriptions.push(desc);
93
+ }
94
+ }
95
+ const prompt = `You are a shell assistant. Given a partial shell input, suggest ${maxDisplayed}\
96
+ useful, concise shell commands that the user might run next.\
97
+ Return one suggestion per line, no numbering, no extra text.
98
+
99
+ The current line is: "${carousel.getCurrentRow()}
100
+
101
+ ${descriptions.join("\n\n")}
102
+ `;
103
+ (0, logs_1.logLine)(prompt);
104
+ const text = await generateContent(prompt, {
105
+ apiKey: this.apiKey,
106
+ model: this.model,
107
+ temperature: 0.3,
108
+ maxOutputTokens: 128,
109
+ });
110
+ const lines = text
111
+ .split(/\r?\n/)
112
+ .map((s) => s.trim())
113
+ .filter(Boolean)
114
+ .slice(0, maxDisplayed);
115
+ (0, logs_1.logLine)(`AI lines: ${lines.length}`);
116
+ return lines;
117
+ }
118
+ }
119
+ exports.AISuggester = AISuggester;
package/dist/app.js ADDED
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.App = void 0;
4
+ const terminal_1 = require("./terminal");
5
+ const keyboard_1 = require("./keyboard");
6
+ const carousel_1 = require("./carousel");
7
+ const history_suggester_1 = require("./history-suggester");
8
+ const ai_suggester_1 = require("./ai-suggester");
9
+ const child_process_1 = require("child_process");
10
+ function debounce(fn, ms) {
11
+ // Debounce function to limit the rate at which a function can fire
12
+ let t = null;
13
+ return (...args) => {
14
+ if (t)
15
+ clearTimeout(t);
16
+ t = setTimeout(() => fn(...args), ms);
17
+ };
18
+ }
19
+ class App {
20
+ constructor() {
21
+ this.terminal = new terminal_1.Terminal();
22
+ this.keyboard = new keyboard_1.Keyboard();
23
+ this.history = new history_suggester_1.HistorySuggester();
24
+ this.ai = new ai_suggester_1.AISuggester();
25
+ this.carousel = new carousel_1.Carousel({
26
+ top: this.history,
27
+ bottom: this.ai,
28
+ topRows: 2,
29
+ bottomRows: 2,
30
+ terminal: this.terminal,
31
+ });
32
+ }
33
+ async init() {
34
+ await this.history.init();
35
+ await this.ai.init();
36
+ }
37
+ async run() {
38
+ await this.init();
39
+ this.keyboard.start();
40
+ const updateSuggestions = debounce(async () => {
41
+ await this.carousel.updateSuggestions();
42
+ }, 300);
43
+ const handlers = {
44
+ "ctrl-c": () => this.exit(),
45
+ "ctrl-d": () => {
46
+ if (this.carousel.getCurrentRow().length === 0)
47
+ this.exit();
48
+ },
49
+ backspace: () => {
50
+ this.carousel.deleteBeforeCursor();
51
+ // Immediate prompt redraw with existing suggestions
52
+ this.render();
53
+ // Async fetch of new suggestions
54
+ updateSuggestions();
55
+ },
56
+ enter: async () => {
57
+ const cmd = this.carousel.getCurrentRow().trim();
58
+ this.carousel.setInputBuffer("", 0);
59
+ await this.runCommand(cmd);
60
+ this.carousel.resetIndex();
61
+ updateSuggestions();
62
+ },
63
+ char: (evt) => {
64
+ this.carousel.insertAtCursor(evt.sequence);
65
+ // Immediate prompt redraw with existing suggestions
66
+ this.render();
67
+ // Async fetch of new suggestions
68
+ updateSuggestions();
69
+ },
70
+ up: () => {
71
+ this.carousel.up();
72
+ this.render();
73
+ },
74
+ down: () => {
75
+ this.carousel.down();
76
+ this.render();
77
+ },
78
+ left: () => {
79
+ this.carousel.moveCursorLeft();
80
+ this.render();
81
+ },
82
+ right: () => {
83
+ this.carousel.moveCursorRight();
84
+ this.render();
85
+ },
86
+ home: () => { },
87
+ end: () => { },
88
+ delete: () => { },
89
+ escape: () => { },
90
+ };
91
+ this.keyboard.on("key", async (evt) => {
92
+ const fn = handlers[evt.name];
93
+ if (fn)
94
+ await fn(evt);
95
+ });
96
+ // Initial draw
97
+ this.render();
98
+ await this.carousel.updateSuggestions();
99
+ }
100
+ render() {
101
+ this.carousel.render();
102
+ // Cursor placement handled inside carousel render.
103
+ }
104
+ async runCommand(cmd) {
105
+ const { yellow, reset } = terminal_1.colors;
106
+ if (!cmd) {
107
+ // Log an empty line
108
+ this.terminal.renderBlock([">"]);
109
+ this.terminal.write("\n");
110
+ this.terminal.resetBlockTracking();
111
+ return;
112
+ }
113
+ // Log command in yellow
114
+ const width = process.stdout.columns || 80;
115
+ const lines = [`${yellow}$ ${cmd}${reset}`];
116
+ this.terminal.renderBlock(lines);
117
+ // Ensure command output starts on the next line
118
+ this.terminal.write("\n");
119
+ await this.history.add(cmd);
120
+ // Spawn shell
121
+ const isWin = process.platform === "win32";
122
+ const proc = (0, child_process_1.spawn)(isWin ? "cmd.exe" : "/bin/bash", [isWin ? "/c" : "-lc", cmd], {
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+ await new Promise((resolve) => {
126
+ proc.stdout.on("data", (d) => process.stdout.write(d));
127
+ proc.stderr.on("data", (d) => process.stderr.write(d));
128
+ proc.on("close", () => resolve());
129
+ });
130
+ // After arbitrary output, reset render block tracking
131
+ this.terminal.resetBlockTracking();
132
+ }
133
+ exit() {
134
+ this.keyboard.stop();
135
+ process.exit(0);
136
+ }
137
+ }
138
+ exports.App = App;
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Carousel = void 0;
4
+ const logs_1 = require("./logs");
5
+ const terminal_1 = require("./terminal");
6
+ class Carousel {
7
+ constructor(opts) {
8
+ this.latestTop = [];
9
+ this.latestBottom = [];
10
+ this.index = 0;
11
+ this.inputBuffer = "";
12
+ this.inputCursor = 0;
13
+ this.terminal = opts.terminal;
14
+ this.top = opts.top;
15
+ this.bottom = opts.bottom;
16
+ this.topRowCount = opts.topRows;
17
+ this.bottomRowCount = opts.bottomRows;
18
+ const empty = "---";
19
+ this.latestTop = Array(this.topRowCount).fill(empty);
20
+ this.latestBottom = Array(this.bottomRowCount).fill(empty);
21
+ }
22
+ async updateSuggestions(input) {
23
+ if (typeof input === "string") {
24
+ this.setInputBuffer(input);
25
+ }
26
+ const topPromise = this.top.suggest(this, this.topRowCount);
27
+ const bottomPromise = this.bottom.suggest(this, this.bottomRowCount);
28
+ topPromise.then((r) => {
29
+ this.latestTop = r;
30
+ this.render();
31
+ });
32
+ bottomPromise.then((r) => {
33
+ this.latestBottom = r;
34
+ this.render();
35
+ });
36
+ }
37
+ up() {
38
+ this.index += 1;
39
+ if (this.index >= this.latestTop.length) {
40
+ this.index = this.latestTop.length;
41
+ }
42
+ }
43
+ down() {
44
+ this.index -= 1;
45
+ if (-this.index >= this.latestBottom.length) {
46
+ this.index = -this.latestBottom.length;
47
+ }
48
+ }
49
+ getRow(rowIndex) {
50
+ if (rowIndex < 0) {
51
+ const bottomIndex = -rowIndex - 1;
52
+ return this.latestBottom[bottomIndex] || "";
53
+ }
54
+ if (rowIndex === 0) {
55
+ return this.inputBuffer;
56
+ }
57
+ if (rowIndex > 0) {
58
+ const topIndex = rowIndex - 1;
59
+ return this.latestTop[topIndex] || "";
60
+ }
61
+ return "";
62
+ }
63
+ getPrefixByIndex(index) {
64
+ if (index < 0) {
65
+ return this.bottom.prefix;
66
+ }
67
+ if (index > 0) {
68
+ return this.top.prefix;
69
+ }
70
+ return "$> ";
71
+ }
72
+ getFormattedRow(rowIndex) {
73
+ const rowStr = this.getRow(rowIndex);
74
+ let prefix = this.getPrefixByIndex(rowIndex);
75
+ const { brightWhite, reset, dim } = terminal_1.colors;
76
+ let color = dim;
77
+ if (this.index === rowIndex) {
78
+ color = brightWhite;
79
+ if (rowIndex !== 0) {
80
+ prefix = "> ";
81
+ }
82
+ }
83
+ return `${color}${prefix}${rowStr}${reset}`;
84
+ }
85
+ getCurrentRow() {
86
+ return this.getRow(this.index);
87
+ }
88
+ setInputBuffer(value, cursorPos = value.length) {
89
+ this.inputBuffer = value;
90
+ this.inputCursor = Math.max(0, Math.min(cursorPos, this.inputBuffer.length));
91
+ }
92
+ resetIndex() {
93
+ this.index = 0;
94
+ }
95
+ adoptSelectionIntoInput() {
96
+ if (this.index === 0)
97
+ return;
98
+ const current = this.getRow(this.index);
99
+ this.setInputBuffer(current, current.length);
100
+ this.index = 0;
101
+ }
102
+ insertAtCursor(text) {
103
+ if (!text)
104
+ return;
105
+ this.adoptSelectionIntoInput();
106
+ const before = this.inputBuffer.slice(0, this.inputCursor);
107
+ const after = this.inputBuffer.slice(this.inputCursor);
108
+ this.inputBuffer = `${before}${text}${after}`;
109
+ this.inputCursor += text.length;
110
+ }
111
+ deleteBeforeCursor() {
112
+ this.adoptSelectionIntoInput();
113
+ if (this.inputCursor === 0)
114
+ return;
115
+ const before = this.inputBuffer.slice(0, this.inputCursor - 1);
116
+ const after = this.inputBuffer.slice(this.inputCursor);
117
+ this.inputBuffer = `${before}${after}`;
118
+ this.inputCursor -= 1;
119
+ }
120
+ moveCursorLeft() {
121
+ this.adoptSelectionIntoInput();
122
+ if (this.inputCursor === 0)
123
+ return;
124
+ this.inputCursor -= 1;
125
+ }
126
+ moveCursorRight() {
127
+ this.adoptSelectionIntoInput();
128
+ if (this.inputCursor >= this.inputBuffer.length)
129
+ return;
130
+ this.inputCursor += 1;
131
+ }
132
+ getPromptCursorColumn() {
133
+ const prefix = this.getPrefixByIndex(0);
134
+ return prefix.length + this.inputCursor;
135
+ }
136
+ render() {
137
+ (0, logs_1.logLine)("Rendering carousel");
138
+ // Draw all the lines
139
+ const width = process.stdout.columns || 80;
140
+ const { brightWhite, reset, dim } = terminal_1.colors;
141
+ const lines = [];
142
+ const start = this.index + this.topRowCount;
143
+ const rowCount = this.topRowCount + this.bottomRowCount + 1;
144
+ const end = start - rowCount;
145
+ for (let i = start; i > end; i--) {
146
+ lines.push({
147
+ rowIndex: i,
148
+ text: this.getFormattedRow(i).slice(0, width - 2),
149
+ });
150
+ }
151
+ const promptLineIndex = lines.findIndex((line) => line.rowIndex === 0);
152
+ const cursorRow = this.topRowCount;
153
+ const cursorCol = this.getPromptCursorColumn();
154
+ this.terminal.renderBlock(lines.map((line) => line.text), cursorRow, cursorCol);
155
+ }
156
+ getSuggesters() {
157
+ return [this.top, this.bottom];
158
+ }
159
+ }
160
+ exports.Carousel = Carousel;
package/dist/config.js ADDED
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.configFolder = configFolder;
37
+ exports.getConfig = getConfig;
38
+ const fs_1 = require("fs");
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ function configFolder(subpath) {
42
+ const home = os.homedir();
43
+ // Default path: ~/.caroushell/history
44
+ return path.join(home, ".caroushell", subpath);
45
+ }
46
+ async function getConfig() {
47
+ // Load config from ~/.caroushell/config.json
48
+ const configPath = process.env.CAROUSHELL_CONFIG_PATH || configFolder("config.json");
49
+ const config = JSON.parse(await fs_1.promises.readFile(configPath, "utf8"));
50
+ return config;
51
+ }
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.HistorySuggester = void 0;
7
+ const fs_1 = require("fs");
8
+ const path_1 = __importDefault(require("path"));
9
+ const config_1 = require("./config");
10
+ class HistorySuggester {
11
+ constructor(filePath) {
12
+ this.prefix = "⌛";
13
+ this.items = [];
14
+ this.maxItems = 1000;
15
+ const home = process.env.HOME || process.env.USERPROFILE || process.cwd();
16
+ // Default path: ~/.caroushell/history
17
+ this.filePath = filePath || (0, config_1.configFolder)("history");
18
+ }
19
+ async init() {
20
+ try {
21
+ await fs_1.promises.mkdir(path_1.default.dirname(this.filePath), { recursive: true });
22
+ }
23
+ catch { }
24
+ try {
25
+ const data = await fs_1.promises.readFile(this.filePath, "utf8");
26
+ this.items = data.split(/\r?\n/).filter(Boolean);
27
+ }
28
+ catch {
29
+ this.items = [];
30
+ }
31
+ }
32
+ async add(command) {
33
+ if (!command.trim())
34
+ return;
35
+ // Deduplicate recent duplicate
36
+ if (this.items[this.items.length - 1] !== command) {
37
+ this.items.push(command);
38
+ if (this.items.length > this.maxItems)
39
+ this.items.shift();
40
+ await fs_1.promises
41
+ .mkdir(path_1.default.dirname(this.filePath), { recursive: true })
42
+ .catch(() => { });
43
+ await fs_1.promises.writeFile(this.filePath, this.items.join("\n"), "utf8");
44
+ }
45
+ }
46
+ async suggest(carousel, maxDisplayed) {
47
+ const input = carousel.getCurrentRow();
48
+ if (!input) {
49
+ return this.items.reverse();
50
+ }
51
+ const q = input.toLowerCase();
52
+ const matched = [];
53
+ for (let i = this.items.length - 1; i >= 0; i--) {
54
+ const it = this.items[i];
55
+ if (it.toLowerCase().includes(q))
56
+ matched.push(it);
57
+ }
58
+ return matched;
59
+ }
60
+ descriptionForAi() {
61
+ const lines = [];
62
+ const maxHistoryLines = 20;
63
+ const start = Math.max(0, this.items.length - maxHistoryLines);
64
+ const end = this.items.length - 1;
65
+ const reverseSlice = this.items.slice(start, end).reverse();
66
+ if (reverseSlice.length > 0) {
67
+ lines.push(`The most recent command is: "${reverseSlice[0]}"`);
68
+ }
69
+ if (reverseSlice.length > 1) {
70
+ lines.push("The most recent commands are (from recent to oldest):");
71
+ for (let i = 0; i < reverseSlice.length; i++) {
72
+ lines.push(` ${i + 1}. ${reverseSlice[i]}`);
73
+ }
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+ }
78
+ exports.HistorySuggester = HistorySuggester;
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Keyboard = void 0;
4
+ const events_1 = require("events");
5
+ // Map escape/control sequences to semantic key names
6
+ const KEYMAP = {
7
+ // Control keys
8
+ '\u0003': { name: 'ctrl-c', ctrl: true }, // ^C
9
+ '\u0004': { name: 'ctrl-d', ctrl: true }, // ^D
10
+ '\r': { name: 'enter' },
11
+ '\n': { name: 'enter' },
12
+ '\u007f': { name: 'backspace' }, // DEL
13
+ '\u0008': { name: 'backspace' }, // BS (Windows)
14
+ '\u001b': { name: 'escape' },
15
+ // Arrows (ANSI)
16
+ '\u001b[A': { name: 'up' },
17
+ '\u001b[B': { name: 'down' },
18
+ '\u001b[C': { name: 'right' },
19
+ '\u001b[D': { name: 'left' },
20
+ // Home/End/Delete variants
21
+ '\u001b[H': { name: 'home' },
22
+ '\u001b[F': { name: 'end' },
23
+ '\u001b[1~': { name: 'home' },
24
+ '\u001b[4~': { name: 'end' },
25
+ '\u001b[3~': { name: 'delete' },
26
+ };
27
+ // For efficient prefix checks
28
+ const KEY_PREFIXES = new Set();
29
+ for (const seq of Object.keys(KEYMAP)) {
30
+ for (let i = 1; i <= seq.length; i++) {
31
+ KEY_PREFIXES.add(seq.slice(0, i));
32
+ }
33
+ }
34
+ class Keyboard extends events_1.EventEmitter {
35
+ constructor() {
36
+ super(...arguments);
37
+ this.active = false;
38
+ this.buffer = '';
39
+ }
40
+ start() {
41
+ if (this.active)
42
+ return;
43
+ this.active = true;
44
+ const stdin = process.stdin;
45
+ stdin.setEncoding('utf8');
46
+ if (stdin.isTTY)
47
+ stdin.setRawMode(true);
48
+ stdin.resume();
49
+ const onData = (data) => this.handleData(data);
50
+ stdin.on('data', onData);
51
+ this.once('stop', () => {
52
+ stdin.off('data', onData);
53
+ if (stdin.isTTY)
54
+ stdin.setRawMode(false);
55
+ });
56
+ }
57
+ stop() {
58
+ if (!this.active)
59
+ return;
60
+ this.active = false;
61
+ this.emit('stop');
62
+ }
63
+ handleData(data) {
64
+ this.buffer += data;
65
+ this.processBuffer();
66
+ }
67
+ processBuffer() {
68
+ // Try to consume as many full key sequences as possible
69
+ while (this.buffer.length > 0) {
70
+ const evt = this.matchSequence(this.buffer);
71
+ if (evt === 'need-more')
72
+ return; // wait for more bytes
73
+ if (evt) {
74
+ this.emit('key', evt);
75
+ this.buffer = this.buffer.slice(evt.sequence.length);
76
+ continue;
77
+ }
78
+ // No mapped sequence at buffer start; emit first char as 'char'
79
+ const ch = this.buffer[0];
80
+ const code = ch.charCodeAt(0);
81
+ if (code < 32 && ch !== '\t') {
82
+ // ignore other control chars
83
+ this.buffer = this.buffer.slice(1);
84
+ continue;
85
+ }
86
+ this.emit('key', { name: 'char', sequence: ch });
87
+ this.buffer = this.buffer.slice(1);
88
+ }
89
+ }
90
+ matchSequence(buf) {
91
+ // Fast path: exact match
92
+ const exact = KEYMAP[buf];
93
+ if (exact)
94
+ return { ...exact, sequence: buf };
95
+ // Try the longest possible mapped sequence that matches the buffer prefix
96
+ // Limit search by checking prefixes set.
97
+ let maxLen = 0;
98
+ let matched = null;
99
+ for (const seq of Object.keys(KEYMAP)) {
100
+ if (buf.startsWith(seq)) {
101
+ if (seq.length > maxLen) {
102
+ maxLen = seq.length;
103
+ matched = { ...KEYMAP[seq], sequence: seq };
104
+ }
105
+ }
106
+ }
107
+ if (matched)
108
+ return matched;
109
+ // If current buffer is a prefix to any known sequence, wait for more
110
+ if (KEY_PREFIXES.has(buf))
111
+ return 'need-more';
112
+ // No sequence match
113
+ return null;
114
+ }
115
+ }
116
+ exports.Keyboard = Keyboard;
package/dist/logs.js ADDED
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.logLine = logLine;
37
+ exports.ensureLogFolderExists = ensureLogFolderExists;
38
+ const fs_1 = require("fs");
39
+ const path = __importStar(require("path"));
40
+ const config_1 = require("./config");
41
+ function getLogDir() {
42
+ return (0, config_1.configFolder)("logs");
43
+ }
44
+ function getLogFilePath(d = new Date()) {
45
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
46
+ const dd = String(d.getDate()).padStart(2, "0");
47
+ return path.join(getLogDir(), `${mm}-${dd}.txt`);
48
+ }
49
+ async function ensureDir(dir) {
50
+ await fs_1.promises.mkdir(dir, { recursive: true });
51
+ }
52
+ function timestamp(date = new Date()) {
53
+ // local time iso string
54
+ return date.toISOString();
55
+ }
56
+ async function logLine(message, when = new Date()) {
57
+ const dir = getLogDir();
58
+ await ensureDir(dir);
59
+ const file = getLogFilePath(when);
60
+ const line = `[${timestamp(when)}] ${message}\n`;
61
+ await fs_1.promises.appendFile(file, line, "utf8");
62
+ }
63
+ // Ensure the ~/.caroushell/logs folder exists early in app startup
64
+ async function ensureLogFolderExists() {
65
+ const dir = getLogDir();
66
+ await ensureDir(dir);
67
+ return dir;
68
+ }
package/dist/main.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ const app_1 = require("./app");
5
+ const logs_1 = require("./logs");
6
+ async function main() {
7
+ await (0, logs_1.ensureLogFolderExists)();
8
+ (0, logs_1.logLine)("Caroushell started");
9
+ const app = new app_1.App();
10
+ await app.run();
11
+ }
12
+ main().catch((err) => {
13
+ console.error(err);
14
+ process.exit(1);
15
+ });
@@ -0,0 +1,108 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Terminal = exports.colors = void 0;
7
+ const readline_1 = __importDefault(require("readline"));
8
+ // Color helpers
9
+ exports.colors = {
10
+ reset: "\x1b[0m",
11
+ white: "\x1b[37m",
12
+ brightWhite: "\x1b[97m",
13
+ dim: "\x1b[2m",
14
+ yellow: "\x1b[33m",
15
+ };
16
+ class Terminal {
17
+ constructor() {
18
+ this.out = process.stdout;
19
+ this.activeRows = 0;
20
+ this.cursorRow = 0;
21
+ this.cursorCol = 0;
22
+ }
23
+ moveCursorToTopOfBlock() {
24
+ if (this.activeRows === 0)
25
+ return;
26
+ readline_1.default.cursorTo(this.out, 0);
27
+ if (this.cursorRow > 0) {
28
+ readline_1.default.moveCursor(this.out, 0, -this.cursorRow);
29
+ }
30
+ this.cursorRow = 0;
31
+ this.cursorCol = 0;
32
+ }
33
+ withCork(fn) {
34
+ // Cork is like "don't flush" and then "uncork" is like flush.
35
+ // This prevents a flicker on the screen when we move the cursor around to render.
36
+ // Node's Writable has cork/uncork; guard for environments that may not.
37
+ const w = this.out;
38
+ const hasCork = typeof w.cork === "function" &&
39
+ typeof w.uncork === "function";
40
+ if (!hasCork) {
41
+ return fn();
42
+ }
43
+ w.cork();
44
+ try {
45
+ return fn();
46
+ }
47
+ finally {
48
+ w.uncork();
49
+ }
50
+ }
51
+ write(text) {
52
+ this.out.write(text);
53
+ }
54
+ hideCursor() {
55
+ this.write("\x1b[?25l");
56
+ }
57
+ showCursor() {
58
+ this.write("\x1b[?25h");
59
+ }
60
+ // Render a block of lines by clearing previous block (if any) and writing fresh
61
+ renderBlock(lines, cursorRow, cursorCol) {
62
+ this.withCork(() => {
63
+ this.moveCursorToTopOfBlock();
64
+ if (this.activeRows > 0) {
65
+ readline_1.default.cursorTo(this.out, 0);
66
+ readline_1.default.clearScreenDown(this.out);
67
+ }
68
+ for (let i = 0; i < lines.length; i++) {
69
+ this.out.write(lines[i]);
70
+ if (i < lines.length - 1)
71
+ this.out.write("\n");
72
+ }
73
+ this.activeRows = lines.length;
74
+ this.cursorRow = Math.max(0, this.activeRows - 1);
75
+ const lastLine = lines[this.cursorRow] || "";
76
+ this.cursorCol = lastLine.length;
77
+ const needsPosition = typeof cursorRow === "number" || typeof cursorCol === "number";
78
+ if (needsPosition) {
79
+ const targetRow = typeof cursorRow === "number"
80
+ ? Math.min(Math.max(cursorRow, 0), Math.max(0, this.activeRows - 1))
81
+ : this.cursorRow;
82
+ const targetCol = Math.max(0, cursorCol ?? this.cursorCol);
83
+ this.moveCursorTo(targetRow, targetCol);
84
+ }
85
+ });
86
+ }
87
+ moveCursorTo(lineIndex, column) {
88
+ if (this.activeRows === 0)
89
+ return;
90
+ const safeLine = Math.min(Math.max(lineIndex, 0), Math.max(0, this.activeRows - 1));
91
+ const safeColumn = Math.max(0, column);
92
+ const rowDelta = safeLine - this.cursorRow;
93
+ if (rowDelta !== 0) {
94
+ readline_1.default.moveCursor(this.out, 0, rowDelta);
95
+ }
96
+ readline_1.default.cursorTo(this.out, safeColumn);
97
+ this.cursorRow = safeLine;
98
+ this.cursorCol = safeColumn;
99
+ }
100
+ // When we have printed arbitrary output that is not managed by renderBlock,
101
+ // reset internal line tracking so the next render starts fresh.
102
+ resetBlockTracking() {
103
+ this.activeRows = 0;
104
+ this.cursorRow = 0;
105
+ this.cursorCol = 0;
106
+ }
107
+ }
108
+ exports.Terminal = Terminal;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const ai_suggester_1 = require("./ai-suggester");
4
+ async function main() {
5
+ if (!process.env.GEMINI_API_KEY) {
6
+ console.warn("Warning: GEMINI_API_KEY is not set. The answer may be empty.");
7
+ }
8
+ const question = "What is the capital of France?";
9
+ const answer = await (0, ai_suggester_1.generateContent)(question);
10
+ console.log(`Q: ${question}`);
11
+ console.log(`A: ${answer.trim()}`);
12
+ }
13
+ main().catch((err) => {
14
+ console.error(err);
15
+ process.exit(1);
16
+ });
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "caroushell",
3
+ "version": "0.1.0",
4
+ "description": "Terminal carousel that suggests commands from history, config, and AI.",
5
+ "type": "commonjs",
6
+ "main": "dist/main.js",
7
+ "bin": {
8
+ "caroushell": "dist/main.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "dev": "tsx src/main.ts",
17
+ "build": "tsc -p tsconfig.json",
18
+ "prepare": "npm run build",
19
+ "start": "node dist/main.js",
20
+ "test:generate": "tsx src/test-generate.ts"
21
+ },
22
+ "keywords": [
23
+ "cli",
24
+ "shell",
25
+ "history",
26
+ "ai"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/ubershmekel/caroushell.git"
31
+ },
32
+ "bugs": {
33
+ "url": "https://github.com/ubershmekel/caroushell/issues"
34
+ },
35
+ "homepage": "https://github.com/ubershmekel/caroushell#readme",
36
+ "author": "Ubershmekel",
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^24.10.0",
43
+ "tsx": "^4.19.2",
44
+ "typescript": "^5.6.3"
45
+ }
46
+ }