@napisani/scute 0.0.6 → 0.0.7
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 +156 -25
- package/bin/scute +0 -0
- package/package.json +87 -84
- package/src/config/index.ts +14 -2
- package/src/config/schema.ts +36 -12
- package/src/core/command-output.ts +119 -0
- package/src/core/llm.ts +3 -4
- package/src/core/prompts.ts +8 -8
- package/src/core/shells/bash.ts +4 -0
- package/src/core/shells/sh.ts +4 -0
- package/src/core/shells/zsh.ts +4 -0
- package/src/index.ts +17 -1
package/README.md
CHANGED
|
@@ -139,15 +139,32 @@ Notes:
|
|
|
139
139
|
- `dotenv/config` is loaded at startup, so values in `.env` will be respected.
|
|
140
140
|
- Provider env vars (e.g., `OPENAI_API_KEY`) merge into `providers` and override matching entries.
|
|
141
141
|
|
|
142
|
+
### Prompt defaults
|
|
143
|
+
|
|
144
|
+
Use `promptDefaults` to set shared values for all prompts. Any field omitted on a prompt inherits the value from `promptDefaults`. You can still override individual prompts under `prompts`, including `output` via `prompts.<name>.output`.
|
|
145
|
+
|
|
146
|
+
### Output channels
|
|
147
|
+
|
|
148
|
+
Output channels control how scute emits results when `--output` is not provided.
|
|
149
|
+
|
|
150
|
+
- `clipboard`: writes the result to the system clipboard using `clipboardCommand` (or auto-detected clipboard tool). Falls back to stdout if no clipboard command is found.
|
|
151
|
+
- `stdout`: prints the result to stdout with a trailing newline.
|
|
152
|
+
- `prompt`: renders the result at the bottom of the terminal without altering your current input line.
|
|
153
|
+
- `readline`: replaces the current input line (bash/zsh integration).
|
|
154
|
+
|
|
155
|
+
`--output <channel>` always overrides the config for a single invocation.
|
|
156
|
+
|
|
142
157
|
### Minimal config example
|
|
143
158
|
|
|
144
159
|
```yaml
|
|
145
160
|
# ~/.config/scute/config.yaml
|
|
146
161
|
|
|
147
162
|
# Use a compact view for the token editor
|
|
163
|
+
# viewMode values: horizontal | vertical
|
|
148
164
|
viewMode: horizontal
|
|
149
165
|
|
|
150
166
|
# Define at least one provider for prompts
|
|
167
|
+
# provider name values: openai | anthropic | gemini | ollama
|
|
151
168
|
providers:
|
|
152
169
|
- name: openai
|
|
153
170
|
apiKey: ${OPENAI_API_KEY}
|
|
@@ -165,12 +182,14 @@ shellKeybindings:
|
|
|
165
182
|
# ~/.config/scute/config.yaml
|
|
166
183
|
|
|
167
184
|
# Layout for the interactive token editor
|
|
185
|
+
# viewMode values: horizontal | vertical
|
|
168
186
|
viewMode: horizontal # horizontal -> annotated view, vertical -> list view
|
|
169
187
|
|
|
170
188
|
# Clipboard command for output channel "clipboard"
|
|
171
189
|
clipboardCommand: "pbcopy"
|
|
172
190
|
|
|
173
191
|
# Providers used by prompts (env vars override these)
|
|
192
|
+
# provider name values: openai | anthropic | gemini | ollama
|
|
174
193
|
providers:
|
|
175
194
|
- name: openai
|
|
176
195
|
apiKey: ${OPENAI_API_KEY}
|
|
@@ -223,30 +242,31 @@ theme:
|
|
|
223
242
|
tokenDescription: "#CDD6F4"
|
|
224
243
|
markerColor: "#CDD6F4"
|
|
225
244
|
|
|
245
|
+
# Prompt defaults (apply to all prompts unless overridden)
|
|
246
|
+
promptDefaults:
|
|
247
|
+
# provider values: openai | anthropic | gemini | ollama
|
|
248
|
+
provider: openai
|
|
249
|
+
model: gpt-4
|
|
250
|
+
temperature: 0.7
|
|
251
|
+
maxTokens: 128000
|
|
252
|
+
userPrompt: ""
|
|
253
|
+
systemPromptOverride: ""
|
|
254
|
+
# output values: clipboard | stdout | prompt | readline
|
|
255
|
+
output: readline
|
|
256
|
+
|
|
226
257
|
# Prompt behavior per command
|
|
227
258
|
prompts:
|
|
228
259
|
explain:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
temperature: 0.7
|
|
232
|
-
maxTokens: 128000
|
|
233
|
-
userPrompt: ""
|
|
234
|
-
systemPromptOverride: ""
|
|
260
|
+
# output values: clipboard | stdout | prompt | readline
|
|
261
|
+
output: prompt
|
|
235
262
|
suggest:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
temperature: 0.7
|
|
239
|
-
maxTokens: 128000
|
|
263
|
+
# output values: clipboard | stdout | prompt | readline
|
|
264
|
+
output: readline
|
|
240
265
|
generate:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
temperature: 0.7
|
|
244
|
-
maxTokens: 128000
|
|
266
|
+
# output values: clipboard | stdout | prompt | readline
|
|
267
|
+
output: readline
|
|
245
268
|
describeTokens:
|
|
246
|
-
|
|
247
|
-
model: gpt-4
|
|
248
|
-
temperature: 0.7
|
|
249
|
-
maxTokens: 128000
|
|
269
|
+
# Internal prompt used by the token editor
|
|
250
270
|
```
|
|
251
271
|
|
|
252
272
|
### Environment variables
|
|
@@ -317,17 +337,128 @@ The command prints a JSON payload containing the merged configuration and releva
|
|
|
317
337
|
|
|
318
338
|
## Testing
|
|
319
339
|
|
|
320
|
-
|
|
340
|
+
Scute uses three complementary testing strategies:
|
|
341
|
+
|
|
342
|
+
1. **Unit Tests** — Fast, deterministic tests for core logic
|
|
343
|
+
2. **Eval Tests** — AI-powered evaluations that validate LLM output quality
|
|
344
|
+
3. **PTY E2E Tests** — End-to-end shell integration tests in real terminals
|
|
345
|
+
|
|
346
|
+
### Unit Tests
|
|
347
|
+
|
|
348
|
+
Unit tests use Bun's built-in test runner and live in the `tests/` directory. They cover core logic, utilities, and component behavior without external dependencies.
|
|
321
349
|
|
|
322
350
|
```sh
|
|
323
|
-
# Run
|
|
324
|
-
|
|
351
|
+
# Run all unit tests
|
|
352
|
+
make test-unit
|
|
325
353
|
|
|
326
|
-
# Run
|
|
327
|
-
|
|
354
|
+
# Run with coverage
|
|
355
|
+
make test-coverage
|
|
356
|
+
|
|
357
|
+
# Run a specific test file
|
|
358
|
+
bun test tests/core/output.test.ts
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**Key unit test areas:**
|
|
362
|
+
|
|
363
|
+
| File | Tests |
|
|
364
|
+
|------|-------|
|
|
365
|
+
| `tests/core/output.test.ts` | Output channel routing (stdout, clipboard, prompt, readline) |
|
|
366
|
+
| `tests/shells.test.ts` | Shell integration (bash/zsh/sh keybindings) |
|
|
367
|
+
| `tests/build-command.test.ts` | Build command argument resolution |
|
|
368
|
+
| `tests/config-overlay.test.ts` | Configuration merging and precedence |
|
|
369
|
+
| `tests/hooks/useVimMode.test.ts` | TUI vim navigation and editing |
|
|
370
|
+
|
|
371
|
+
### Eval Tests
|
|
372
|
+
|
|
373
|
+
Eval tests use the `.eval.test.ts` suffix and live in the `evals/` directory. These tests make real LLM calls and validate that the AI produces sensible output for specific tasks.
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
# Run all eval tests
|
|
377
|
+
make test-evals
|
|
378
|
+
|
|
379
|
+
# Run a specific eval
|
|
380
|
+
bun test evals/suggest-command.eval.test.ts
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Available evals:**
|
|
384
|
+
|
|
385
|
+
| File | Validates |
|
|
386
|
+
|------|-----------|
|
|
387
|
+
| `evals/suggest-command.eval.test.ts` | Suggest produces valid shell commands |
|
|
388
|
+
| `evals/explain-command.eval.test.ts` | Explain produces helpful explanations |
|
|
389
|
+
| `evals/ai-connectivity.eval.test.ts` | Provider connectivity and API responses |
|
|
390
|
+
| `evals/fetch-token-descriptions.eval.test.ts` | Token description fetching |
|
|
391
|
+
|
|
392
|
+
> **Note:** Eval tests require valid provider credentials (e.g., `OPENAI_API_KEY`) and make real API calls.
|
|
393
|
+
|
|
394
|
+
### PTY E2E Tests
|
|
395
|
+
|
|
396
|
+
PTY tests open a real pseudo-terminal, spawn a clean shell, initialize scute, and run scripted scenarios. These validate the full integration: shell keybindings, output channels, and TUI behavior in an actual terminal environment.
|
|
397
|
+
|
|
398
|
+
**Prerequisites:**
|
|
399
|
+
- `python3`
|
|
400
|
+
- `bun` (for `./bin/scute`)
|
|
401
|
+
- Provider credentials (e.g., `OPENAI_API_KEY`)
|
|
402
|
+
|
|
403
|
+
**Configuration:**
|
|
404
|
+
- Default config: `configs/agent-pty.yml` (uses OpenAI GPT-4o-mini, writes clipboard to `/tmp/scute-clipboard.txt`)
|
|
405
|
+
- Override with `--config <path>`
|
|
406
|
+
|
|
407
|
+
**Running scenarios:**
|
|
408
|
+
|
|
409
|
+
```sh
|
|
410
|
+
# Run ALL scenarios and get a pass/fail summary
|
|
411
|
+
make test-pty
|
|
412
|
+
|
|
413
|
+
# Run a single scenario by name
|
|
414
|
+
make test-pty-one SCENARIO=suggest-stdout
|
|
415
|
+
make test-pty-one SCENARIO=explain-stdout
|
|
416
|
+
make test-pty-one SCENARIO=clipboard-file
|
|
417
|
+
|
|
418
|
+
# Or run directly with extra options
|
|
419
|
+
scripts/agent/run-one suggest-stdout --shell zsh
|
|
420
|
+
scripts/agent/run-all --config configs/openai-config.yml --quiet
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
**Available scenarios:**
|
|
424
|
+
|
|
425
|
+
| Scenario | Tests |
|
|
426
|
+
|----------|-------|
|
|
427
|
+
| `suggest-stdout` | `scute suggest` via CLI with stdout output |
|
|
428
|
+
| `suggest-readline` | Alt+G keybinding replaces readline buffer |
|
|
429
|
+
| `explain-stdout` | `scute explain` via CLI with stdout output |
|
|
430
|
+
| `explain-keybinding` | Ctrl+E keybinding shows prompt hint |
|
|
431
|
+
| `build-stdout` | TUI opens, submit with Enter |
|
|
432
|
+
| `generate-stdout` | `scute generate` via CLI with stdout output |
|
|
433
|
+
| `clipboard-file` | Clipboard output writes to file |
|
|
434
|
+
|
|
435
|
+
**Logs:** Each scenario writes a log to `/tmp/scute-pty-<scenario>.log` for debugging.
|
|
436
|
+
|
|
437
|
+
**Scenario step types:** Each scenario is a JSON file with steps like:
|
|
438
|
+
|
|
439
|
+
```json
|
|
440
|
+
{
|
|
441
|
+
"prompt": "scute-test$ ",
|
|
442
|
+
"steps": [
|
|
443
|
+
{"send_line": "export PS1=\"scute-test$ \""},
|
|
444
|
+
{"wait_for_prompt": true},
|
|
445
|
+
{"send_line": "scute suggest \"git sta\" --output stdout"},
|
|
446
|
+
{"wait_for_prompt": true, "timeout": 15},
|
|
447
|
+
{"assert_output_contains": "git"}
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Available steps: `send_line`, `send_text`, `send_keys`, `sleep`, `wait_for_prompt`, `wait_for_text`, `assert_output_contains`, `assert_file_exists`, `assert_file_contains`, `clear_buffer`, `drain`, `delete_file`.
|
|
453
|
+
|
|
454
|
+
**Agent prompt templates:**
|
|
455
|
+
|
|
456
|
+
```text
|
|
457
|
+
Run scripts/agent/run-one suggest-stdout. If it fails, read /tmp/scute-pty-suggest-stdout.log (last 120 lines) and diagnose the issue.
|
|
458
|
+
```
|
|
328
459
|
|
|
329
|
-
|
|
330
|
-
|
|
460
|
+
```text
|
|
461
|
+
Run scripts/agent/run-all and report the pass/fail summary. For any failures, include the last 120 lines of the corresponding log file.
|
|
331
462
|
```
|
|
332
463
|
|
|
333
464
|
## Usage
|
package/bin/scute
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,86 +1,89 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
2
|
+
"name": "@napisani/scute",
|
|
3
|
+
"version": "0.0.7",
|
|
4
|
+
"description": "AI-powered shell assistant",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"shell",
|
|
9
|
+
"ai",
|
|
10
|
+
"explain",
|
|
11
|
+
"bash",
|
|
12
|
+
"zsh",
|
|
13
|
+
"suggestion",
|
|
14
|
+
"tui"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/napisani/scute",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/napisani/scute/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/napisani/scute.git"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Nick Pisani",
|
|
26
|
+
"main": "src/index.ts",
|
|
27
|
+
"bin": {
|
|
28
|
+
"scute": "bin/scute"
|
|
29
|
+
},
|
|
30
|
+
"directories": {
|
|
31
|
+
"test": "tests"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"bin/",
|
|
35
|
+
"src/",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "bun run build:bin",
|
|
41
|
+
"build:bin": "bun build ./src/index.ts --compile --outfile dist/scute",
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"lint": "bunx tsc --noEmit",
|
|
44
|
+
"test": "bun test tests/",
|
|
45
|
+
"test:coverage": "bun test tests/ --coverage",
|
|
46
|
+
"test:unit": "bun test tests/",
|
|
47
|
+
"test:evals": "bun test evals --pattern \\\"\\.eval\\.test\\.ts$\\\"",
|
|
48
|
+
"test:pty": "python3 scripts/agent/run-all",
|
|
49
|
+
"test:pty:one": "python3 scripts/agent/run-one",
|
|
50
|
+
"prepublishOnly": "bun run lint && bun run test"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@opentui/core": "^0.1.75",
|
|
54
|
+
"@opentui/react": "^0.1.75",
|
|
55
|
+
"@tanstack/ai": "^0.2.2",
|
|
56
|
+
"@tanstack/ai-anthropic": "^0.2.0",
|
|
57
|
+
"@tanstack/ai-gemini": "^0.3.2",
|
|
58
|
+
"@tanstack/ai-ollama": "^0.3.0",
|
|
59
|
+
"@tanstack/ai-openai": "^0.2.1",
|
|
60
|
+
"chalk": "^5.6.2",
|
|
61
|
+
"commander": "^14.0.2",
|
|
62
|
+
"dotenv": "^17.2.3",
|
|
63
|
+
"js-yaml": "^4.1.1",
|
|
64
|
+
"react": "^19.2.4",
|
|
65
|
+
"shell-quote": "^1.8.3"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@biomejs/biome": "2.3.12",
|
|
69
|
+
"@testing-library/react": "^16.3.2",
|
|
70
|
+
"@types/bun": "latest",
|
|
71
|
+
"@types/js-yaml": "^4.0.9",
|
|
72
|
+
"@types/jsdom": "^27.0.0",
|
|
73
|
+
"@types/node": "^25.0.10",
|
|
74
|
+
"@types/react": "^19.2.9",
|
|
75
|
+
"@types/shell-quote": "^1.7.5",
|
|
76
|
+
"happy-dom": "^20.5.0",
|
|
77
|
+
"jsdom": "^28.0.0"
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"typescript": "^5"
|
|
81
|
+
},
|
|
82
|
+
"engines": {
|
|
83
|
+
"bun": ">=1.0.0"
|
|
84
|
+
},
|
|
85
|
+
"os": [
|
|
86
|
+
"darwin",
|
|
87
|
+
"linux"
|
|
88
|
+
]
|
|
86
89
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -5,8 +5,14 @@ import path from "node:path";
|
|
|
5
5
|
import yaml from "js-yaml";
|
|
6
6
|
import { SUPPORTED_PROVIDERS } from "../core/constants";
|
|
7
7
|
import { getEnv, resetEnvGetter, setEnvGetter } from "../core/environment";
|
|
8
|
+
import type { OutputChannel } from "../core/output";
|
|
8
9
|
import type { TokenType } from "../core/shells/common";
|
|
9
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
PromptName,
|
|
12
|
+
PromptOverridesConfig,
|
|
13
|
+
ShellKeybindingAction,
|
|
14
|
+
ThemeConfig,
|
|
15
|
+
} from "./schema";
|
|
10
16
|
import { type Config, ConfigSchema, ShellKeybindingActions } from "./schema";
|
|
11
17
|
|
|
12
18
|
const CONFIG_DIR = path.join(os.homedir(), ".config", "scute");
|
|
@@ -189,7 +195,13 @@ export function getThemeColorFor(attr: ThemeColorAttribute): string {
|
|
|
189
195
|
}
|
|
190
196
|
|
|
191
197
|
export function getPromptConfig(name: PromptName) {
|
|
192
|
-
|
|
198
|
+
const defaults = config.promptDefaults;
|
|
199
|
+
const overrides = config.prompts[name] as PromptOverridesConfig;
|
|
200
|
+
return { ...defaults, ...overrides };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getPromptOutput(name: PromptName): OutputChannel | undefined {
|
|
204
|
+
return getPromptConfig(name).output as OutputChannel | undefined;
|
|
193
205
|
}
|
|
194
206
|
|
|
195
207
|
export function getShellKeybindings(): Record<ShellKeybindingAction, string[]> {
|
package/src/config/schema.ts
CHANGED
|
@@ -38,16 +38,39 @@ export const ProviderSchema = z.object({
|
|
|
38
38
|
|
|
39
39
|
export type ProviderConfig = z.infer<typeof ProviderSchema>;
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
const OutputChannelSchema = z.enum([
|
|
42
|
+
"clipboard",
|
|
43
|
+
"stdout",
|
|
44
|
+
"prompt",
|
|
45
|
+
"readline",
|
|
46
|
+
]);
|
|
47
|
+
export type OutputChannelConfig = z.infer<typeof OutputChannelSchema>;
|
|
48
|
+
|
|
49
|
+
export const PromptDefaultsSchema = z.object({
|
|
50
|
+
provider: z.enum(SUPPORTED_PROVIDERS).default(resolvedDefaultProvider),
|
|
43
51
|
model: z.string().default(resolvedDefaultModel),
|
|
44
52
|
temperature: z.number().default(DEFAULT_TEMPERATURE),
|
|
45
53
|
maxTokens: z.number().default(DEFAULT_MAX_TOKENS),
|
|
46
54
|
userPrompt: z.string().optional(),
|
|
47
55
|
systemPromptOverride: z.string().optional(),
|
|
56
|
+
output: OutputChannelSchema.optional(),
|
|
48
57
|
});
|
|
49
58
|
|
|
50
|
-
export
|
|
59
|
+
export const PromptOverridesSchema = z
|
|
60
|
+
.object({
|
|
61
|
+
provider: z.enum(SUPPORTED_PROVIDERS).optional(),
|
|
62
|
+
model: z.string().optional(),
|
|
63
|
+
temperature: z.number().optional(),
|
|
64
|
+
maxTokens: z.number().optional(),
|
|
65
|
+
userPrompt: z.string().optional(),
|
|
66
|
+
systemPromptOverride: z.string().optional(),
|
|
67
|
+
output: OutputChannelSchema.optional(),
|
|
68
|
+
})
|
|
69
|
+
.default({});
|
|
70
|
+
|
|
71
|
+
export type PromptDefaultsConfig = z.infer<typeof PromptDefaultsSchema>;
|
|
72
|
+
export type PromptOverridesConfig = z.infer<typeof PromptOverridesSchema>;
|
|
73
|
+
export type PromptConfig = PromptDefaultsConfig;
|
|
51
74
|
|
|
52
75
|
export const KeybindingsSchema = z
|
|
53
76
|
.object({
|
|
@@ -148,7 +171,7 @@ export const ShellKeybindingActions = [
|
|
|
148
171
|
] as const;
|
|
149
172
|
export type ShellKeybindingAction = (typeof ShellKeybindingActions)[number];
|
|
150
173
|
|
|
151
|
-
function
|
|
174
|
+
function buildDefaultPromptDefaults() {
|
|
152
175
|
return {
|
|
153
176
|
provider: resolvedDefaultProvider,
|
|
154
177
|
model: resolvedDefaultModel,
|
|
@@ -183,18 +206,19 @@ export const ConfigSchema = z.object({
|
|
|
183
206
|
build: "Ctrl+G",
|
|
184
207
|
suggest: "Alt+G",
|
|
185
208
|
}),
|
|
209
|
+
promptDefaults: PromptDefaultsSchema.default(buildDefaultPromptDefaults()),
|
|
186
210
|
prompts: z
|
|
187
211
|
.object({
|
|
188
|
-
explain:
|
|
189
|
-
suggest:
|
|
190
|
-
generate:
|
|
191
|
-
describeTokens:
|
|
212
|
+
explain: PromptOverridesSchema.default({}),
|
|
213
|
+
suggest: PromptOverridesSchema.default({}),
|
|
214
|
+
generate: PromptOverridesSchema.default({}),
|
|
215
|
+
describeTokens: PromptOverridesSchema.default({}),
|
|
192
216
|
})
|
|
193
217
|
.default({
|
|
194
|
-
explain:
|
|
195
|
-
suggest:
|
|
196
|
-
generate:
|
|
197
|
-
describeTokens:
|
|
218
|
+
explain: {},
|
|
219
|
+
suggest: {},
|
|
220
|
+
generate: {},
|
|
221
|
+
describeTokens: {},
|
|
198
222
|
}),
|
|
199
223
|
});
|
|
200
224
|
export type PromptName = keyof z.infer<typeof ConfigSchema>["prompts"];
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
function stripAnsiSequences(text: string): string {
|
|
2
|
+
let output = "";
|
|
3
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
4
|
+
const char = text[i];
|
|
5
|
+
if (char !== "\u001b") {
|
|
6
|
+
output += char;
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
const next = text[i + 1];
|
|
10
|
+
if (next === "[") {
|
|
11
|
+
i += 1;
|
|
12
|
+
while (i + 1 < text.length) {
|
|
13
|
+
i += 1;
|
|
14
|
+
const code = text.charCodeAt(i);
|
|
15
|
+
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) {
|
|
16
|
+
break;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (next === "]") {
|
|
22
|
+
i += 1;
|
|
23
|
+
while (i + 1 < text.length) {
|
|
24
|
+
i += 1;
|
|
25
|
+
if (text.charCodeAt(i) === 7) {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return output;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stripControlCharacters(text: string): string {
|
|
35
|
+
const normalized = text.replace(/\r\n?/g, "\n");
|
|
36
|
+
let output = "";
|
|
37
|
+
for (let i = 0; i < normalized.length; i += 1) {
|
|
38
|
+
const code = normalized.charCodeAt(i);
|
|
39
|
+
if (
|
|
40
|
+
(code >= 0 && code <= 8) ||
|
|
41
|
+
code === 11 ||
|
|
42
|
+
code === 12 ||
|
|
43
|
+
(code >= 14 && code <= 31) ||
|
|
44
|
+
code === 127
|
|
45
|
+
) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
output += normalized[i];
|
|
49
|
+
}
|
|
50
|
+
return output;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractFencedContent(text: string): string {
|
|
54
|
+
const fenceMatch = text.match(/```[a-zA-Z0-9_-]*\n([\s\S]*?)```/);
|
|
55
|
+
if (fenceMatch?.[1]) {
|
|
56
|
+
return fenceMatch[1];
|
|
57
|
+
}
|
|
58
|
+
return text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function stripLinePrefix(line: string): string {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
const labelMatch = trimmed.match(
|
|
64
|
+
/^(command|cmd|suggestion|answer)\s*:\s*(.*)$/i,
|
|
65
|
+
);
|
|
66
|
+
if (labelMatch?.[2]) {
|
|
67
|
+
return labelMatch[2].trim();
|
|
68
|
+
}
|
|
69
|
+
if (trimmed.startsWith("$ ") || trimmed.startsWith("> ")) {
|
|
70
|
+
return trimmed.slice(2).trimStart();
|
|
71
|
+
}
|
|
72
|
+
if (trimmed.startsWith("# ")) {
|
|
73
|
+
return trimmed.slice(2).trimStart();
|
|
74
|
+
}
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isLabelLine(line: string): boolean {
|
|
79
|
+
return /:$/.test(line.trim());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stripWrappingBackticks(line: string): string {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (/^`[^`]+`$/.test(trimmed)) {
|
|
85
|
+
return trimmed.slice(1, -1).trim();
|
|
86
|
+
}
|
|
87
|
+
return trimmed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pickCommandLine(lines: string[]): string {
|
|
91
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
92
|
+
const line = lines[i];
|
|
93
|
+
if (!line) continue;
|
|
94
|
+
const trimmed = line.trim();
|
|
95
|
+
if (!trimmed) continue;
|
|
96
|
+
if (trimmed.startsWith("```")) continue;
|
|
97
|
+
if (isLabelLine(trimmed)) continue;
|
|
98
|
+
const normalized = stripWrappingBackticks(stripLinePrefix(trimmed));
|
|
99
|
+
if (normalized) {
|
|
100
|
+
return normalized;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeCommandLine(line: string): string {
|
|
107
|
+
return line.replace(/\t/g, " ").trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function sanitizeCommandOutput(text: string): string | null {
|
|
111
|
+
if (!text) return null;
|
|
112
|
+
const withoutAnsi = stripAnsiSequences(text);
|
|
113
|
+
const withoutControl = stripControlCharacters(withoutAnsi);
|
|
114
|
+
const content = extractFencedContent(withoutControl);
|
|
115
|
+
const lines = content.split("\n");
|
|
116
|
+
const picked = pickCommandLine(lines);
|
|
117
|
+
const normalized = normalizeCommandLine(picked);
|
|
118
|
+
return normalized.length ? normalized : null;
|
|
119
|
+
}
|
package/src/core/llm.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getProviderBaseUrl,
|
|
11
11
|
} from "../config";
|
|
12
12
|
import type { PromptName } from "../config/schema";
|
|
13
|
+
import { sanitizeCommandOutput } from "./command-output";
|
|
13
14
|
import { buildCommandContext } from "./llm-context";
|
|
14
15
|
import { logDebug } from "./logger";
|
|
15
16
|
import { buildManPageContext, type ManPage } from "./manpage";
|
|
@@ -172,8 +173,7 @@ export async function suggest(commandLine: string): Promise<string | null> {
|
|
|
172
173
|
userPrompt,
|
|
173
174
|
getSuggestSystemPrompt(),
|
|
174
175
|
);
|
|
175
|
-
|
|
176
|
-
return suggestion.length ? suggestion : raw.trim() || null;
|
|
176
|
+
return sanitizeCommandOutput(raw);
|
|
177
177
|
} catch (error) {
|
|
178
178
|
logDebug("suggest:error", error);
|
|
179
179
|
return null;
|
|
@@ -238,8 +238,7 @@ export async function generateCommand(prompt: string): Promise<string | null> {
|
|
|
238
238
|
trimmed,
|
|
239
239
|
getGenerateSystemPrompt(),
|
|
240
240
|
);
|
|
241
|
-
|
|
242
|
-
return suggestion.length ? suggestion : raw.trim() || null;
|
|
241
|
+
return sanitizeCommandOutput(raw);
|
|
243
242
|
} catch (error) {
|
|
244
243
|
logDebug("generate:error", error);
|
|
245
244
|
return null;
|
package/src/core/prompts.ts
CHANGED
|
@@ -15,11 +15,11 @@ You will be provided with a JSON payload containing:
|
|
|
15
15
|
- "input": the partial ${shell} command the user has typed (may include inline comments that describe intent)
|
|
16
16
|
- "tokens": an array of parsed tokens for the current input
|
|
17
17
|
- "context": concise excerpts from relevant man pages and documentation
|
|
18
|
-
Your job is to produce the single best complete ${shell} command that satisfies the described intent.
|
|
19
|
-
- You may replace or reorder the tokens as needed; do not merely append text.
|
|
20
|
-
- Honor any intent described in comments or obvious from the tokens.
|
|
21
|
-
- Return exactly one command line with no trailing commentary, explanations, or markdown.
|
|
22
|
-
- Do not include surrounding quotes, code fences, or additional notes.
|
|
18
|
+
Your job is to produce the single best complete ${shell} command that satisfies the described intent.
|
|
19
|
+
- You may replace or reorder the tokens as needed; do not merely append text.
|
|
20
|
+
- Honor any intent described in comments or obvious from the tokens.
|
|
21
|
+
- Return exactly one command line with no trailing commentary, explanations, or markdown.
|
|
22
|
+
- Do not include surrounding quotes, code fences, prompt characters (like "$"), or additional notes.
|
|
23
23
|
`;
|
|
24
24
|
}
|
|
25
25
|
export function getExplainSystemPrompt(): string {
|
|
@@ -42,9 +42,9 @@ export function getGenerateSystemPrompt(): string {
|
|
|
42
42
|
return `
|
|
43
43
|
${getRoleVerbiage()}
|
|
44
44
|
You will be provided with a natural language prompt describing a task.
|
|
45
|
-
Your task is to generate the single, most likely ${shell} command that achieves the user's goal.
|
|
46
|
-
The shell command can be multiple commands combined with pipes, conditionals, or other shell operators as needed, but it must be a single line.
|
|
47
|
-
ONLY return the command itself, with no additional explanation or formatting.
|
|
45
|
+
Your task is to generate the single, most likely ${shell} command that achieves the user's goal.
|
|
46
|
+
The shell command can be multiple commands combined with pipes, conditionals, or other shell operators as needed, but it must be a single line.
|
|
47
|
+
ONLY return the command itself, with no additional explanation or formatting (no prompt characters like "$" or "> ").
|
|
48
48
|
`;
|
|
49
49
|
}
|
|
50
50
|
|
package/src/core/shells/bash.ts
CHANGED
|
@@ -121,6 +121,10 @@ export const bashShellHelper: ShellHelper = {
|
|
|
121
121
|
// Bash uses READLINE_LINE for the current input line
|
|
122
122
|
// Use ANSI sequences to clear the current line and replace it
|
|
123
123
|
const normalizedText = normalizeReadlineText(text);
|
|
124
|
+
if (!process.stdout.isTTY) {
|
|
125
|
+
process.stdout.write(normalizedText);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
124
128
|
const clearLine = "\x1b[2K"; // Clear entire line
|
|
125
129
|
const carriageReturn = "\r"; // Move cursor to beginning of line
|
|
126
130
|
process.stdout.write(`${carriageReturn}${clearLine}${normalizedText}`);
|
package/src/core/shells/sh.ts
CHANGED
|
@@ -108,6 +108,10 @@ export const shShellHelper: ShellHelper = {
|
|
|
108
108
|
// Standard sh uses READLINE_LINE (when readline is available)
|
|
109
109
|
// Use ANSI sequences to clear the current line and replace it
|
|
110
110
|
const normalizedText = normalizeReadlineText(text);
|
|
111
|
+
if (!process.stdout.isTTY) {
|
|
112
|
+
process.stdout.write(normalizedText);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
111
115
|
const clearLine = "\x1b[2K"; // Clear entire line
|
|
112
116
|
const carriageReturn = "\r"; // Move cursor to beginning of line
|
|
113
117
|
process.stdout.write(`${carriageReturn}${clearLine}${normalizedText}`);
|
package/src/core/shells/zsh.ts
CHANGED
|
@@ -126,6 +126,10 @@ export const zshShellHelper: ShellHelper = {
|
|
|
126
126
|
// Zsh uses BUFFER for the current input line
|
|
127
127
|
// Use ANSI sequences to clear the current line and replace it
|
|
128
128
|
const normalizedText = normalizeReadlineText(text);
|
|
129
|
+
if (!process.stdout.isTTY) {
|
|
130
|
+
process.stdout.write(normalizedText);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
129
133
|
const clearLine = "\x1b[2K"; // Clear entire line
|
|
130
134
|
const carriageReturn = "\r"; // Move cursor to beginning of line
|
|
131
135
|
process.stdout.write(`${carriageReturn}${clearLine}${normalizedText}`);
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,11 @@ import { explain } from "./commands/explain";
|
|
|
8
8
|
import { generate } from "./commands/generate";
|
|
9
9
|
import { init } from "./commands/init";
|
|
10
10
|
import { suggest } from "./commands/suggest";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
getPromptOutput,
|
|
13
|
+
loadConfigFromPath,
|
|
14
|
+
setConfigOverride,
|
|
15
|
+
} from "./config";
|
|
12
16
|
import type { OutputChannel } from "./core/output";
|
|
13
17
|
|
|
14
18
|
const program = new Command();
|
|
@@ -42,6 +46,18 @@ function resolveOutputChannel(
|
|
|
42
46
|
}
|
|
43
47
|
return requested as OutputChannel;
|
|
44
48
|
}
|
|
49
|
+
if (
|
|
50
|
+
commandName === "suggest" ||
|
|
51
|
+
commandName === "explain" ||
|
|
52
|
+
commandName === "generate"
|
|
53
|
+
) {
|
|
54
|
+
const promptOutput = getPromptOutput(
|
|
55
|
+
commandName as "suggest" | "explain" | "generate",
|
|
56
|
+
);
|
|
57
|
+
if (promptOutput) {
|
|
58
|
+
return promptOutput;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
45
61
|
if (commandName === "suggest") return "readline";
|
|
46
62
|
if (commandName === "explain") return "prompt";
|
|
47
63
|
if (commandName === "build") return "readline";
|