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 +20 -12
- package/dist/ai-suggester.js +88 -45
- package/dist/app.js +66 -34
- package/dist/carousel.js +39 -1
- package/dist/config.js +61 -5
- package/dist/hello-new-user.js +120 -0
- package/dist/history-suggester.js +10 -9
- package/dist/keyboard.js +36 -13
- package/dist/logs.js +7 -1
- package/dist/main.js +5 -0
- package/dist/spawner.js +65 -0
- package/dist/terminal.js +3 -1
- package/dist/test-generate.js +11 -3
- package/package.json +5 -1
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,
|
|
5
|
-
command without leaving the keyboard.
|
|
4
|
+
history, and AI suggestions as you type.
|
|
6
5
|
|
|
7
6
|
## Features
|
|
8
7
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
-
|
|
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
|
|
14
|
+
at different AI providers.
|
|
15
15
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
18
18
|
- Node.js 18 or newer.
|
|
19
|
-
-
|
|
20
|
-
|
|
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
|
-
"
|
|
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
|
|
62
|
-
npm run build
|
|
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
|
|
package/dist/ai-suggester.js
CHANGED
|
@@ -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 ||
|
|
9
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
75
|
+
headers: headers(args.apiKey),
|
|
28
76
|
body: JSON.stringify({
|
|
29
|
-
|
|
77
|
+
model: args.model,
|
|
78
|
+
temperature: args.temperature,
|
|
79
|
+
max_tokens: args.maxOutputTokens,
|
|
80
|
+
messages: [
|
|
30
81
|
{
|
|
31
|
-
role: "
|
|
32
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
44
|
-
"ctrl-c": () =>
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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.
|
|
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
|
|
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
|
}
|
package/dist/spawner.js
ADDED
|
@@ -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
package/dist/test-generate.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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.
|
|
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
|
}
|