caroushell 0.1.0 → 0.1.2

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 CHANGED
@@ -1,30 +1,38 @@
1
1
  # Caroushell
2
2
 
3
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.
4
+ history, and AI suggestions as you type.
6
5
 
7
6
  ## Features
8
7
 
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.
8
+ - The top panel of the carousel shows history
9
+ - The bottom panel of the carousel shows AI-generated command suggestions.
10
+ - Go up and down the carousel with arrow keys.
11
+ - Press `Enter` to run the highlighted command.
12
12
  - Logs activity under `~/.caroushell/logs` for easy troubleshooting.
13
13
  - Extensible config file (`~/.caroushell/config.json`) so you can point the CLI
14
- at different API keys or settings.
14
+ at different AI providers.
15
15
 
16
16
  ## Requirements
17
17
 
18
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:
19
+ - On first launch Caroushell will prompt you for an OpenAI-compatible endpoint
20
+ URL, API key, and model name, then store them in `~/.caroushell/config.json`.
21
+ - You can also create the file manually:
21
22
 
22
23
  ```json
23
24
  {
24
- "GEMINI_API_KEY": "your-api-key"
25
+ "apiUrl": "https://openrouter.ai/api/v1",
26
+ "apiKey": "your-api-key",
27
+ "model": "gpt-4o-mini"
25
28
  }
26
29
  ```
27
30
 
31
+ Any endpoint that implements the OpenAI Chat Completions API (OpenRouter,
32
+ OpenAI, etc.) will work as long as the URL, key, and model are valid. If you
33
+ only provide a Gemini API key in the config, Caroushell will default to the
34
+ Gemini Flash Lite 2.5 endpoint and model.
35
+
28
36
  ## Installation
29
37
 
30
38
  Install globally (recommended):
@@ -58,9 +66,9 @@ need to debug AI suggestions or the terminal renderer. Configuration lives at
58
66
 
59
67
  ```bash
60
68
  npm install
61
- npm run dev # tsx watch mode
62
- npm run build # emits dist/
63
- npm run test:generate
69
+ npm run dev
70
+ npm run build
71
+ npm run test:generate # tests ai text generation
64
72
  npm publish --dry-run # verify package contents before publishing
65
73
  ```
66
74
 
@@ -2,87 +2,128 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.AISuggester = void 0;
4
4
  exports.generateContent = generateContent;
5
+ exports.listModels = listModels;
5
6
  const logs_1 = require("./logs");
6
7
  const config_1 = require("./config");
7
8
  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";
9
+ const apiKey = options?.apiKey ||
10
+ process.env.CAROUSHELL_API_KEY ||
11
+ process.env.GEMINI_API_KEY;
12
+ const apiUrl = options?.apiUrl || process.env.CAROUSHELL_API_URL;
13
+ const model = options?.model || process.env.CAROUSHELL_MODEL || process.env.OPENAI_MODEL;
10
14
  const temperature = options?.temperature ?? 0.3;
11
15
  const maxOutputTokens = options?.maxOutputTokens ?? 256;
12
16
  if (!apiKey) {
13
17
  (0, logs_1.logLine)("AI generation skipped: missing API key");
14
18
  return "";
15
19
  }
20
+ if (!apiUrl) {
21
+ (0, logs_1.logLine)("AI generation skipped: missing API URL");
22
+ return "";
23
+ }
24
+ if (!model) {
25
+ (0, logs_1.logLine)("AI generation skipped: missing model");
26
+ return "";
27
+ }
16
28
  if (!prompt.trim()) {
17
29
  (0, logs_1.logLine)("AI generation skipped: empty prompt");
18
30
  return "";
19
31
  }
20
32
  try {
21
33
  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}`, {
34
+ const request = buildRequest({
35
+ apiKey,
36
+ apiUrl,
37
+ model,
38
+ prompt,
39
+ temperature,
40
+ maxOutputTokens,
41
+ });
42
+ const res = await fetch(request.url, request.init);
43
+ if (!res.ok) {
44
+ return "ai fetch error: " + res.statusText;
45
+ }
46
+ const out = await extractText(await res.json());
47
+ const text = typeof out === "string" ? out : "";
48
+ const duration = Date.now() - start;
49
+ (0, logs_1.logLine)(`AI duration: ${duration} ms`);
50
+ return text;
51
+ }
52
+ catch (err) {
53
+ return "ai error: " + err.message;
54
+ }
55
+ }
56
+ async function listModels(apiUrl, apiKey) {
57
+ const url = apiUrl.replace("/chat/completions", "") + "/models";
58
+ const res = await fetch(url, { headers: headers(apiKey) });
59
+ const models = await res.json();
60
+ return models.data.map((m) => m.id);
61
+ }
62
+ function headers(apiKey) {
63
+ return {
64
+ "Content-Type": "application/json",
65
+ Authorization: `Bearer ${apiKey}`,
66
+ "HTTP-Referer": "https://github.com/ubershmekel/caroushell",
67
+ "X-Title": "Caroushell",
68
+ };
69
+ }
70
+ function buildRequest(args) {
71
+ return {
72
+ url: args.apiUrl + "/chat/completions",
73
+ init: {
26
74
  method: "POST",
27
- headers: { "Content-Type": "application/json" },
75
+ headers: headers(args.apiKey),
28
76
  body: JSON.stringify({
29
- contents: [
77
+ model: args.model,
78
+ temperature: args.temperature,
79
+ max_tokens: args.maxOutputTokens,
80
+ messages: [
30
81
  {
31
- role: "user",
32
- parts: [{ text: prompt }],
82
+ role: "system",
83
+ content: "You are a shell assistant that suggests terminal command completions.",
33
84
  },
85
+ { role: "user", content: args.prompt },
34
86
  ],
35
- generationConfig: {
36
- temperature,
37
- maxOutputTokens,
38
- },
39
87
  }),
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
- }
88
+ },
89
+ };
90
+ }
91
+ async function extractText(json) {
92
+ const typed = json;
93
+ return typed?.choices?.[0]?.message?.content || "";
67
94
  }
68
95
  class AISuggester {
69
96
  constructor(opts) {
70
97
  this.prefix = "🤖";
71
98
  this.apiKey = opts?.apiKey;
72
- this.model = opts?.model || "gemini-2.5-flash-lite";
99
+ this.apiUrl = opts?.apiUrl;
100
+ this.model = opts?.model;
73
101
  }
74
102
  async init() {
103
+ const config = await (0, config_1.getConfig)();
75
104
  this.apiKey =
76
105
  this.apiKey ||
77
- (await (0, config_1.getConfig)()).GEMINI_API_KEY ||
106
+ config.apiKey ||
107
+ config.GEMINI_API_KEY ||
78
108
  process.env.GEMINI_API_KEY;
109
+ this.apiUrl =
110
+ this.apiUrl || config.apiUrl || process.env.CAROUSHELL_API_URL;
111
+ this.model = this.model || config.model || process.env.CAROUSHELL_MODEL;
112
+ // If the user provided only a Gemini key, default the URL/model accordingly.
113
+ if (!this.apiUrl && config.GEMINI_API_KEY) {
114
+ this.apiUrl =
115
+ "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent";
116
+ }
117
+ if (!this.model && config.GEMINI_API_KEY) {
118
+ this.model = "gemini-2.5-flash-lite";
119
+ }
79
120
  }
80
121
  descriptionForAi() {
81
122
  return "";
82
123
  }
83
124
  async suggest(carousel, maxDisplayed) {
84
- if (!this.apiKey) {
85
- (0, logs_1.logLine)("AI generation skipped: missing API key");
125
+ if (!this.apiKey || !this.apiUrl || !this.model) {
126
+ (0, logs_1.logLine)("AI generation skipped: missing API configuration");
86
127
  return [];
87
128
  }
88
129
  const descriptions = [];
@@ -95,6 +136,7 @@ class AISuggester {
95
136
  const prompt = `You are a shell assistant. Given a partial shell input, suggest ${maxDisplayed}\
96
137
  useful, concise shell commands that the user might run next.\
97
138
  Return one suggestion per line, no numbering, no extra text.
139
+ Return the whole suggestion, not just what remains to type out.
98
140
 
99
141
  The current line is: "${carousel.getCurrentRow()}
100
142
 
@@ -103,6 +145,7 @@ ${descriptions.join("\n\n")}
103
145
  (0, logs_1.logLine)(prompt);
104
146
  const text = await generateContent(prompt, {
105
147
  apiKey: this.apiKey,
148
+ apiUrl: this.apiUrl,
106
149
  model: this.model,
107
150
  temperature: 0.3,
108
151
  maxOutputTokens: 128,
package/dist/app.js CHANGED
@@ -6,7 +6,7 @@ const keyboard_1 = require("./keyboard");
6
6
  const carousel_1 = require("./carousel");
7
7
  const history_suggester_1 = require("./history-suggester");
8
8
  const ai_suggester_1 = require("./ai-suggester");
9
- const child_process_1 = require("child_process");
9
+ const spawner_1 = require("./spawner");
10
10
  function debounce(fn, ms) {
11
11
  // Debounce function to limit the rate at which a function can fire
12
12
  let t = null;
@@ -29,43 +29,53 @@ class App {
29
29
  bottomRows: 2,
30
30
  terminal: this.terminal,
31
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 () => {
32
+ this.queueUpdateSuggestions = debounce(async () => {
41
33
  await this.carousel.updateSuggestions();
42
34
  }, 300);
43
- const handlers = {
44
- "ctrl-c": () => this.exit(),
35
+ this.handlers = {
36
+ "ctrl-c": () => {
37
+ if (this.carousel.isPromptRowSelected() && !this.carousel.hasInput()) {
38
+ this.exit();
39
+ return;
40
+ }
41
+ this.carousel.clearInput();
42
+ this.render();
43
+ this.queueUpdateSuggestions();
44
+ },
45
45
  "ctrl-d": () => {
46
- if (this.carousel.getCurrentRow().length === 0)
46
+ if (this.carousel.isPromptRowSelected() && !this.carousel.hasInput()) {
47
47
  this.exit();
48
+ return;
49
+ }
50
+ this.carousel.deleteAtCursor();
51
+ this.render();
52
+ this.queueUpdateSuggestions();
53
+ },
54
+ "ctrl-u": () => {
55
+ this.carousel.deleteToLineStart();
56
+ this.render();
57
+ this.queueUpdateSuggestions();
48
58
  },
49
59
  backspace: () => {
50
60
  this.carousel.deleteBeforeCursor();
51
61
  // Immediate prompt redraw with existing suggestions
52
62
  this.render();
53
63
  // Async fetch of new suggestions
54
- updateSuggestions();
64
+ this.queueUpdateSuggestions();
55
65
  },
56
66
  enter: async () => {
57
67
  const cmd = this.carousel.getCurrentRow().trim();
58
68
  this.carousel.setInputBuffer("", 0);
59
69
  await this.runCommand(cmd);
60
70
  this.carousel.resetIndex();
61
- updateSuggestions();
71
+ this.queueUpdateSuggestions();
62
72
  },
63
73
  char: (evt) => {
64
74
  this.carousel.insertAtCursor(evt.sequence);
65
75
  // Immediate prompt redraw with existing suggestions
66
76
  this.render();
67
77
  // Async fetch of new suggestions
68
- updateSuggestions();
78
+ this.queueUpdateSuggestions();
69
79
  },
70
80
  up: () => {
71
81
  this.carousel.up();
@@ -83,20 +93,43 @@ class App {
83
93
  this.carousel.moveCursorRight();
84
94
  this.render();
85
95
  },
86
- home: () => { },
87
- end: () => { },
88
- delete: () => { },
96
+ home: () => {
97
+ this.carousel.moveCursorHome();
98
+ this.render();
99
+ },
100
+ end: () => {
101
+ this.carousel.moveCursorEnd();
102
+ this.render();
103
+ },
104
+ delete: () => {
105
+ this.carousel.deleteAtCursor();
106
+ this.render();
107
+ this.queueUpdateSuggestions();
108
+ },
89
109
  escape: () => { },
90
110
  };
91
- this.keyboard.on("key", async (evt) => {
92
- const fn = handlers[evt.name];
93
- if (fn)
94
- await fn(evt);
111
+ }
112
+ async init() {
113
+ await this.history.init();
114
+ await this.ai.init();
115
+ }
116
+ async run() {
117
+ await this.init();
118
+ this.keyboard.start();
119
+ this.keyboard.on("key", (evt) => {
120
+ void this.handleKey(evt);
95
121
  });
96
122
  // Initial draw
97
123
  this.render();
98
124
  await this.carousel.updateSuggestions();
99
125
  }
126
+ async handleKey(evt) {
127
+ const fn = this.handlers[evt.name];
128
+ if (fn) {
129
+ await fn(evt);
130
+ }
131
+ }
132
+ queueUpdateSuggestions() { }
100
133
  render() {
101
134
  this.carousel.render();
102
135
  // Cursor placement handled inside carousel render.
@@ -116,17 +149,16 @@ class App {
116
149
  this.terminal.renderBlock(lines);
117
150
  // Ensure command output starts on the next line
118
151
  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
- });
152
+ this.keyboard.pause();
153
+ try {
154
+ const success = await (0, spawner_1.runUserCommand)(cmd);
155
+ if (success) {
156
+ await this.history.add(cmd);
157
+ }
158
+ }
159
+ finally {
160
+ this.keyboard.resume();
161
+ }
130
162
  // After arbitrary output, reset render block tracking
131
163
  this.terminal.resetBlockTracking();
132
164
  }
package/dist/carousel.js CHANGED
@@ -75,11 +75,15 @@ class Carousel {
75
75
  const { brightWhite, reset, dim } = terminal_1.colors;
76
76
  let color = dim;
77
77
  if (this.index === rowIndex) {
78
- color = brightWhite;
78
+ color = terminal_1.colors.purple;
79
79
  if (rowIndex !== 0) {
80
80
  prefix = "> ";
81
81
  }
82
82
  }
83
+ if (rowIndex !== 0 && !rowStr) {
84
+ // The edge of the top or bottom panel
85
+ prefix = "---";
86
+ }
83
87
  return `${color}${prefix}${rowStr}${reset}`;
84
88
  }
85
89
  getCurrentRow() {
@@ -129,6 +133,40 @@ class Carousel {
129
133
  return;
130
134
  this.inputCursor += 1;
131
135
  }
136
+ moveCursorHome() {
137
+ this.adoptSelectionIntoInput();
138
+ this.inputCursor = 0;
139
+ }
140
+ moveCursorEnd() {
141
+ this.adoptSelectionIntoInput();
142
+ this.inputCursor = this.inputBuffer.length;
143
+ }
144
+ deleteAtCursor() {
145
+ this.adoptSelectionIntoInput();
146
+ if (this.inputCursor >= this.inputBuffer.length)
147
+ return;
148
+ const before = this.inputBuffer.slice(0, this.inputCursor);
149
+ const after = this.inputBuffer.slice(this.inputCursor + 1);
150
+ this.inputBuffer = `${before}${after}`;
151
+ }
152
+ deleteToLineStart() {
153
+ this.adoptSelectionIntoInput();
154
+ if (this.inputCursor === 0)
155
+ return;
156
+ const after = this.inputBuffer.slice(this.inputCursor);
157
+ this.setInputBuffer(after, 0);
158
+ }
159
+ clearInput() {
160
+ this.adoptSelectionIntoInput();
161
+ this.setInputBuffer("", 0);
162
+ this.index = 0;
163
+ }
164
+ hasInput() {
165
+ return this.inputBuffer.length > 0;
166
+ }
167
+ isPromptRowSelected() {
168
+ return this.index === 0;
169
+ }
132
170
  getPromptCursorColumn() {
133
171
  const prefix = this.getPrefixByIndex(0);
134
172
  return prefix.length + this.inputCursor;
package/dist/config.js CHANGED
@@ -34,18 +34,74 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.configFolder = configFolder;
37
+ exports.getConfigPath = getConfigPath;
38
+ exports.doesConfigExist = doesConfigExist;
37
39
  exports.getConfig = getConfig;
38
40
  const fs_1 = require("fs");
39
41
  const path = __importStar(require("path"));
40
42
  const os = __importStar(require("os"));
43
+ const GEMINI_DEFAULT_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent";
44
+ const GEMINI_DEFAULT_MODEL = "gemini-2.5-flash-lite";
41
45
  function configFolder(subpath) {
42
46
  const home = os.homedir();
43
- // Default path: ~/.caroushell/history
47
+ // Default path: ~/.caroushell/<subpath>
44
48
  return path.join(home, ".caroushell", subpath);
45
49
  }
50
+ function getConfigPath() {
51
+ return process.env.CAROUSHELL_CONFIG_PATH || configFolder("config.json");
52
+ }
53
+ async function readConfigFile() {
54
+ const configPath = getConfigPath();
55
+ const raw = await fs_1.promises.readFile(configPath, "utf8");
56
+ return JSON.parse(raw);
57
+ }
58
+ async function doesConfigExist() {
59
+ const configPath = getConfigPath();
60
+ try {
61
+ await fs_1.promises.access(configPath);
62
+ const raw = await readConfigFile();
63
+ if (!raw)
64
+ return false;
65
+ return true;
66
+ }
67
+ catch (err) {
68
+ if (err?.code === "ENOENT") {
69
+ return false;
70
+ }
71
+ // detect empty json file syntax error
72
+ if (err instanceof SyntaxError) {
73
+ return false;
74
+ }
75
+ throw err;
76
+ }
77
+ }
46
78
  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;
79
+ const configPath = getConfigPath();
80
+ const raw = await readConfigFile();
81
+ const envApiKey = process.env.CAROUSHELL_API_KEY || process.env.GEMINI_API_KEY || undefined;
82
+ const envApiUrl = process.env.CAROUSHELL_API_URL || undefined;
83
+ const envModel = process.env.CAROUSHELL_MODEL || undefined;
84
+ const geminiApiKey = raw.GEMINI_API_KEY || process.env.GEMINI_API_KEY || undefined;
85
+ const resolved = {
86
+ ...raw,
87
+ apiUrl: raw.apiUrl || envApiUrl,
88
+ apiKey: raw.apiKey || raw.GEMINI_API_KEY || envApiKey,
89
+ model: raw.model || envModel,
90
+ };
91
+ // If the user only supplied a Gemini key, assume the Gemini defaults.
92
+ if (!resolved.apiUrl && geminiApiKey) {
93
+ resolved.apiUrl = GEMINI_DEFAULT_API_URL;
94
+ }
95
+ if (!resolved.model && geminiApiKey) {
96
+ resolved.model = GEMINI_DEFAULT_MODEL;
97
+ }
98
+ if (!resolved.apiUrl || !resolved.apiKey || !resolved.model) {
99
+ throw new Error(`Config at ${configPath} is missing required fields. Please include apiUrl, apiKey, and model (or just GEMINI_API_KEY).`);
100
+ }
101
+ return resolved;
102
+ }
103
+ function isGeminiUrl(url) {
104
+ const lower = url.toLowerCase();
105
+ return (lower.includes("generativelanguage.googleapis.com") ||
106
+ lower.includes("gemini"));
51
107
  }
@@ -0,0 +1,120 @@
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
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.runHelloNewUserFlow = runHelloNewUserFlow;
40
+ const fs_1 = require("fs");
41
+ const path = __importStar(require("path"));
42
+ const readline_1 = __importDefault(require("readline"));
43
+ const ai_suggester_1 = require("./ai-suggester");
44
+ function isInteractive() {
45
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
46
+ }
47
+ async function prompt(question, rl) {
48
+ return await new Promise((resolve) => {
49
+ rl.question(question, (answer) => resolve(answer));
50
+ });
51
+ }
52
+ async function runHelloNewUserFlow(configPath) {
53
+ if (!isInteractive()) {
54
+ throw new Error(`Missing config at ${configPath} and no interactive terminal is available.\n` +
55
+ "Create the file manually or run Caroushell from a TTY.");
56
+ }
57
+ const dir = path.dirname(configPath);
58
+ await fs_1.promises.mkdir(dir, { recursive: true });
59
+ console.log("");
60
+ console.log("Welcome to Caroushell!");
61
+ console.log(`Let's set up AI suggestions. You'll need an API endpoint URL, a key, and model id. These will be stored at ${configPath}`);
62
+ console.log("");
63
+ console.log("Some example endpoints you can paste:");
64
+ console.log(" - OpenRouter: https://openrouter.ai/api/v1");
65
+ console.log(" - OpenAI: https://api.openai.com/v1");
66
+ console.log(" - Google: https://generativelanguage.googleapis.com/v1beta/openai");
67
+ console.log("");
68
+ console.log("Press Ctrl+C any time to abort.\n");
69
+ const rl = readline_1.default.createInterface({
70
+ input: process.stdin,
71
+ output: process.stdout,
72
+ });
73
+ let apiUrl = "";
74
+ while (!apiUrl) {
75
+ const answer = (await prompt("API URL: ", rl)).trim();
76
+ if (answer) {
77
+ apiUrl = answer;
78
+ }
79
+ else {
80
+ console.log("Please enter a URL (example: https://openrouter.ai/api/v1)");
81
+ }
82
+ }
83
+ let apiKey = "";
84
+ while (!apiKey) {
85
+ const answer = (await prompt("API key: ", rl)).trim();
86
+ if (answer) {
87
+ apiKey = answer;
88
+ }
89
+ else {
90
+ console.log("Please enter an API key (the value stays local on this machine).");
91
+ }
92
+ }
93
+ const models = await (0, ai_suggester_1.listModels)(apiUrl, apiKey);
94
+ if (models.length > 0) {
95
+ console.log("Here are a few example model ids.");
96
+ for (const model of models.slice(0, 5)) {
97
+ console.log(` - ${model}`);
98
+ }
99
+ }
100
+ let model = "";
101
+ while (!model) {
102
+ const answer = (await prompt("Model (e.g. gpt-4o-mini, google/gemini-2.5-flash-lite): ", rl)).trim();
103
+ if (answer) {
104
+ model = answer;
105
+ }
106
+ else {
107
+ console.log("Please enter a model name (example: mistralai/mistral-small-24b-instruct-2501).");
108
+ }
109
+ }
110
+ rl.close();
111
+ const config = {
112
+ apiUrl,
113
+ apiKey,
114
+ model,
115
+ };
116
+ await fs_1.promises.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
117
+ console.log(`\nSaved config to ${configPath}`);
118
+ console.log("You can edit this file later if you want to switch providers.\n");
119
+ return config;
120
+ }
@@ -32,16 +32,17 @@ class HistorySuggester {
32
32
  async add(command) {
33
33
  if (!command.trim())
34
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");
35
+ if (this.items[this.items.length - 1] === command) {
36
+ // Deduplicate recent duplicate
37
+ return;
44
38
  }
39
+ this.items.push(command);
40
+ if (this.items.length > this.maxItems)
41
+ this.items.shift();
42
+ await fs_1.promises
43
+ .mkdir(path_1.default.dirname(this.filePath), { recursive: true })
44
+ .catch(() => { });
45
+ await fs_1.promises.writeFile(this.filePath, this.items.join("\n"), "utf8");
45
46
  }
46
47
  async suggest(carousel, maxDisplayed) {
47
48
  const input = carousel.getCurrentRow();
package/dist/keyboard.js CHANGED
@@ -7,6 +7,7 @@ const KEYMAP = {
7
7
  // Control keys
8
8
  '\u0003': { name: 'ctrl-c', ctrl: true }, // ^C
9
9
  '\u0004': { name: 'ctrl-d', ctrl: true }, // ^D
10
+ '\u0015': { name: 'ctrl-u', ctrl: true }, // ^U
10
11
  '\r': { name: 'enter' },
11
12
  '\n': { name: 'enter' },
12
13
  '\u007f': { name: 'backspace' }, // DEL
@@ -35,30 +36,52 @@ class Keyboard extends events_1.EventEmitter {
35
36
  constructor() {
36
37
  super(...arguments);
37
38
  this.active = false;
39
+ this.capturing = false;
38
40
  this.buffer = '';
41
+ this.stdin = process.stdin;
42
+ this.onData = (data) => this.handleData(data);
39
43
  }
40
44
  start() {
41
45
  if (this.active)
42
46
  return;
43
47
  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
- });
48
+ this.stdin.setEncoding('utf8');
49
+ this.enableCapture();
56
50
  }
57
51
  stop() {
58
52
  if (!this.active)
59
53
  return;
60
54
  this.active = false;
61
- this.emit('stop');
55
+ this.disableCapture();
56
+ }
57
+ pause() {
58
+ if (!this.active)
59
+ return;
60
+ this.disableCapture();
61
+ }
62
+ resume() {
63
+ if (!this.active)
64
+ return;
65
+ this.enableCapture();
66
+ }
67
+ enableCapture() {
68
+ if (this.capturing)
69
+ return;
70
+ if (this.stdin.isTTY)
71
+ this.stdin.setRawMode(true);
72
+ this.stdin.on('data', this.onData);
73
+ this.stdin.resume();
74
+ this.capturing = true;
75
+ }
76
+ disableCapture() {
77
+ if (!this.capturing)
78
+ return;
79
+ this.stdin.off('data', this.onData);
80
+ if (this.stdin.isTTY)
81
+ this.stdin.setRawMode(false);
82
+ this.stdin.pause();
83
+ this.buffer = '';
84
+ this.capturing = false;
62
85
  }
63
86
  handleData(data) {
64
87
  this.buffer += data;
package/dist/logs.js CHANGED
@@ -53,13 +53,19 @@ function timestamp(date = new Date()) {
53
53
  // local time iso string
54
54
  return date.toISOString();
55
55
  }
56
- async function logLine(message, when = new Date()) {
56
+ async function writeLogLine(message, when) {
57
57
  const dir = getLogDir();
58
58
  await ensureDir(dir);
59
59
  const file = getLogFilePath(when);
60
60
  const line = `[${timestamp(when)}] ${message}\n`;
61
61
  await fs_1.promises.appendFile(file, line, "utf8");
62
62
  }
63
+ function logLine(message, when = new Date()) {
64
+ // Fire-and-forget logging so callers do not need to await.
65
+ void writeLogLine(message, when).catch((err) => {
66
+ console.error("CRITICAL: Logger itself failed:", err.message);
67
+ });
68
+ }
63
69
  // Ensure the ~/.caroushell/logs folder exists early in app startup
64
70
  async function ensureLogFolderExists() {
65
71
  const dir = getLogDir();
package/dist/main.js CHANGED
@@ -2,10 +2,15 @@
2
2
  "use strict";
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  const app_1 = require("./app");
5
+ const hello_new_user_1 = require("./hello-new-user");
5
6
  const logs_1 = require("./logs");
7
+ const config_1 = require("./config");
6
8
  async function main() {
7
9
  await (0, logs_1.ensureLogFolderExists)();
8
10
  (0, logs_1.logLine)("Caroushell started");
11
+ if (!(await (0, config_1.doesConfigExist)())) {
12
+ await (0, hello_new_user_1.runHelloNewUserFlow)((0, config_1.getConfigPath)());
13
+ }
9
14
  const app = new app_1.App();
10
15
  await app.run();
11
16
  }
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runUserCommand = runUserCommand;
4
+ const child_process_1 = require("child_process");
5
+ const process_1 = require("process");
6
+ const isWin = process.platform === "win32";
7
+ const shellBinary = isWin ? "cmd.exe" : "/bin/bash";
8
+ const shellArgs = isWin ? ["/c"] : ["-lc"];
9
+ const builtInCommands = {
10
+ cd: async (args) => {
11
+ if (args.length === 1) {
12
+ process.stdout.write(process.cwd() + "\n");
13
+ return true;
14
+ }
15
+ const dest = expandVars(args[1]);
16
+ try {
17
+ process.chdir(dest);
18
+ }
19
+ catch (err) {
20
+ process.stderr.write(`cd: ${err.message}\n`);
21
+ return false;
22
+ }
23
+ return true;
24
+ },
25
+ exit: async () => {
26
+ (0, process_1.exit)(0);
27
+ return false;
28
+ },
29
+ };
30
+ function expandVars(input) {
31
+ let out = input;
32
+ if (isWin) {
33
+ // cmd-style %VAR% expansion
34
+ out = out.replace(/%([^%]+)%/g, (_m, name) => {
35
+ const v = process.env[String(name)];
36
+ return v !== undefined ? v : "";
37
+ });
38
+ }
39
+ else {
40
+ // POSIX-style $VAR and ${VAR} expansion
41
+ out = out.replace(/\$(\w+)|\${(\w+)}/g, (_m, a, b) => {
42
+ const name = a || b;
43
+ const v = process.env[name];
44
+ return v !== undefined ? v : "";
45
+ });
46
+ }
47
+ return out;
48
+ }
49
+ async function runUserCommand(command) {
50
+ const trimmed = command.trim();
51
+ if (!trimmed)
52
+ return false;
53
+ const args = command.split(/\s+/);
54
+ if (typeof args[0] === "string" && builtInCommands[args[0]]) {
55
+ return await builtInCommands[args[0]](args);
56
+ }
57
+ const proc = (0, child_process_1.spawn)(shellBinary, [...shellArgs, command], {
58
+ stdio: "inherit",
59
+ });
60
+ await new Promise((resolve, reject) => {
61
+ proc.on("error", reject);
62
+ proc.on("close", () => resolve());
63
+ });
64
+ return proc.exitCode === 0;
65
+ }
package/dist/terminal.js CHANGED
@@ -10,7 +10,9 @@ exports.colors = {
10
10
  reset: "\x1b[0m",
11
11
  white: "\x1b[37m",
12
12
  brightWhite: "\x1b[97m",
13
- dim: "\x1b[2m",
13
+ dimmest: "\x1b[2m",
14
+ dim: "\x1b[37m",
15
+ purple: "\x1b[95m",
14
16
  yellow: "\x1b[33m",
15
17
  };
16
18
  class Terminal {
@@ -1,12 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const ai_suggester_1 = require("./ai-suggester");
4
+ const config_1 = require("./config");
4
5
  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.");
6
+ const config = await (0, config_1.getConfig)();
7
+ if (!config.apiKey) {
8
+ console.warn("Warning: no API key configured. The answer may be empty.");
7
9
  }
10
+ const models = await (0, ai_suggester_1.listModels)(config.apiUrl || "", config.apiKey || "");
11
+ console.log("Available models:", models);
8
12
  const question = "What is the capital of France?";
9
- const answer = await (0, ai_suggester_1.generateContent)(question);
13
+ const answer = await (0, ai_suggester_1.generateContent)(question, {
14
+ apiKey: config.apiKey,
15
+ apiUrl: config.apiUrl,
16
+ model: config.model,
17
+ });
10
18
  console.log(`Q: ${question}`);
11
19
  console.log(`A: ${answer.trim()}`);
12
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caroushell",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Terminal carousel that suggests commands from history, config, and AI.",
5
5
  "type": "commonjs",
6
6
  "main": "dist/main.js",
@@ -40,6 +40,10 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^24.10.0",
43
+ "@types/shell-quote": "^1.7.5",
44
+ "@typescript-eslint/eslint-plugin": "^8.46.4",
45
+ "@typescript-eslint/parser": "^8.46.4",
46
+ "eslint": "^9.39.1",
43
47
  "tsx": "^4.19.2",
44
48
  "typescript": "^5.6.3"
45
49
  }