@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 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
- provider: openai
230
- model: gpt-4
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
- provider: openai
237
- model: gpt-4
238
- temperature: 0.7
239
- maxTokens: 128000
263
+ # output values: clipboard | stdout | prompt | readline
264
+ output: readline
240
265
  generate:
241
- provider: openai
242
- model: gpt-4
243
- temperature: 0.7
244
- maxTokens: 128000
266
+ # output values: clipboard | stdout | prompt | readline
267
+ output: readline
245
268
  describeTokens:
246
- provider: openai
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
- Test files use the `.test.ts` suffix. Evaluation tests use `.eval.test.ts` and live in the `evals/` directory.
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 standard unit tests
324
- bun run test
351
+ # Run all unit tests
352
+ make test-unit
325
353
 
326
- # Run evaluation tests
327
- bun run evals
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
- # Or via Make targets
330
- make test
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
- "name": "@napisani/scute",
3
- "version": "0.0.6",
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
- "coverage": "bun test tests/ --coverage",
46
- "evals": "bun test evals --pattern \\\"\\.eval\\.test\\.ts$\\\"",
47
- "prepublishOnly": "bun run lint && bun run test"
48
- },
49
- "dependencies": {
50
- "@opentui/core": "^0.1.75",
51
- "@opentui/react": "^0.1.75",
52
- "@tanstack/ai": "^0.2.2",
53
- "@tanstack/ai-anthropic": "^0.2.0",
54
- "@tanstack/ai-gemini": "^0.3.2",
55
- "@tanstack/ai-ollama": "^0.3.0",
56
- "@tanstack/ai-openai": "^0.2.1",
57
- "chalk": "^5.6.2",
58
- "commander": "^14.0.2",
59
- "dotenv": "^17.2.3",
60
- "js-yaml": "^4.1.1",
61
- "react": "^19.2.4",
62
- "shell-quote": "^1.8.3"
63
- },
64
- "devDependencies": {
65
- "@biomejs/biome": "2.3.12",
66
- "@testing-library/react": "^16.3.2",
67
- "@types/bun": "latest",
68
- "@types/js-yaml": "^4.0.9",
69
- "@types/jsdom": "^27.0.0",
70
- "@types/node": "^25.0.10",
71
- "@types/react": "^19.2.9",
72
- "@types/shell-quote": "^1.7.5",
73
- "happy-dom": "^20.5.0",
74
- "jsdom": "^28.0.0"
75
- },
76
- "peerDependencies": {
77
- "typescript": "^5"
78
- },
79
- "engines": {
80
- "bun": ">=1.0.0"
81
- },
82
- "os": [
83
- "darwin",
84
- "linux"
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
  }
@@ -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 { PromptName, ShellKeybindingAction, ThemeConfig } from "./schema";
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
- return { ...config.prompts[name] };
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[]> {
@@ -38,16 +38,39 @@ export const ProviderSchema = z.object({
38
38
 
39
39
  export type ProviderConfig = z.infer<typeof ProviderSchema>;
40
40
 
41
- export const PromptConfigSchema = z.object({
42
- provider: z.string().default(resolvedDefaultProvider),
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 type PromptConfig = z.infer<typeof PromptConfigSchema>;
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 buildDefaultPromptConfig() {
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: PromptConfigSchema.default(buildDefaultPromptConfig()),
189
- suggest: PromptConfigSchema.default(buildDefaultPromptConfig()),
190
- generate: PromptConfigSchema.default(buildDefaultPromptConfig()),
191
- describeTokens: PromptConfigSchema.default(buildDefaultPromptConfig()),
212
+ explain: PromptOverridesSchema.default({}),
213
+ suggest: PromptOverridesSchema.default({}),
214
+ generate: PromptOverridesSchema.default({}),
215
+ describeTokens: PromptOverridesSchema.default({}),
192
216
  })
193
217
  .default({
194
- explain: buildDefaultPromptConfig(),
195
- suggest: buildDefaultPromptConfig(),
196
- generate: buildDefaultPromptConfig(),
197
- describeTokens: buildDefaultPromptConfig(),
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
- const suggestion = extractFirstLine(raw);
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
- const suggestion = extractFirstLine(raw);
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;
@@ -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
 
@@ -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}`);
@@ -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}`);
@@ -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 { loadConfigFromPath, setConfigOverride } from "./config";
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";