ccx-relay 1.5.1 → 1.6.1
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 +19 -23
- package/bin/ccx-init.js +27 -40
- package/bin/ccx.js +80 -15
- package/package.json +2 -3
- package/src/gemini.js +48 -43
- package/src/input.js +67 -44
- package/src/ui.js +7 -38
- package/.env.example +0 -12
package/README.md
CHANGED
|
@@ -38,7 +38,7 @@ npm install -g ccx-relay
|
|
|
38
38
|
Run the one-time setup wizard:
|
|
39
39
|
|
|
40
40
|
```bash
|
|
41
|
-
ccx
|
|
41
|
+
ccx-init
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
This will prompt for your Gemini API key (get one at https://aistudio.google.com/apikey), validate it, let you pick a model, and save config to your user directory (`%APPDATA%\ccx\config.json` on Windows, `~/.config/ccx/config.json` on macOS/Linux). Config survives npm upgrades.
|
|
@@ -46,13 +46,13 @@ This will prompt for your Gemini API key (get one at https://aistudio.google.com
|
|
|
46
46
|
Other init commands:
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
ccx
|
|
50
|
-
ccx
|
|
49
|
+
ccx-init --show # print current config (key masked)
|
|
50
|
+
ccx-init --reset # wipe config and start over
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
### Environment variable override
|
|
54
54
|
|
|
55
|
-
You can skip `ccx
|
|
55
|
+
You can skip `ccx-init` and set env vars directly (useful for CI):
|
|
56
56
|
|
|
57
57
|
```bash
|
|
58
58
|
export GEMINI_API_KEY=AIza...
|
|
@@ -81,14 +81,14 @@ While the wrapped process is running:
|
|
|
81
81
|
|
|
82
82
|
### Changing the marker
|
|
83
83
|
|
|
84
|
-
Change `CCX_MARKER` via `ccx
|
|
84
|
+
Change `CCX_MARKER` via `ccx-init` or env var (default `;;`). No source changes needed.
|
|
85
85
|
|
|
86
86
|
## Testing it locally
|
|
87
87
|
|
|
88
88
|
1. `npm install`
|
|
89
|
-
2. `npm
|
|
89
|
+
2. `npm test` — runs the unit test suite (`node --test`) covering config loading, the Gemini client, and keystroke handling.
|
|
90
90
|
3. `npm link`
|
|
91
|
-
4.
|
|
91
|
+
4. `ccx-init` — set up your real `GEMINI_API_KEY` (this validates the key and lets you pick a model live).
|
|
92
92
|
5. Sanity-check the relay mechanics with a safe command first, before pointing it at `claude`:
|
|
93
93
|
|
|
94
94
|
```bash
|
|
@@ -97,9 +97,9 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
|
|
|
97
97
|
```
|
|
98
98
|
|
|
99
99
|
You should see a completely normal shell. Type a sentence with a typo, e.g. `pls fix teh bug in server.js;;`,
|
|
100
|
-
then press **Enter**. You should see
|
|
101
|
-
(e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter
|
|
102
|
-
shell command.
|
|
100
|
+
then press **Enter**. You should see a spinner flash briefly (`⠋ Enhancing prompt...`), then `✓ Enhanced`, then
|
|
101
|
+
the line rewritten in place (e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter
|
|
102
|
+
again to run it as a normal shell command.
|
|
103
103
|
|
|
104
104
|
6. Once the mechanics check out, run it against the real target:
|
|
105
105
|
|
|
@@ -109,22 +109,18 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
|
|
|
109
109
|
|
|
110
110
|
## Known limitations
|
|
111
111
|
|
|
112
|
-
- **
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
detected and forwarded to the child untouched so navigation still *works* visually, but `ccx`'s internal buffer
|
|
116
|
-
does not move its own "cursor" — it only appends on printable input and pops on backspace. If you arrow back into
|
|
117
|
-
the middle of a line and start editing there, the buffer sent to Gemini (and the backspace count used to erase the
|
|
118
|
-
line) can drift out of sync with what's actually on screen. Worth testing carefully before relying on it for
|
|
119
|
-
heavily-edited multi-line pastes.
|
|
112
|
+
- **Cursor position is tracked, including mid-line edits.** Left/Right/Home/End move an internal cursor that stays
|
|
113
|
+
in sync with inserts, backspace-at-cursor, and both plain-byte and win32-input-mode key encodings — so arrowing
|
|
114
|
+
back into the middle of a line and editing there is expected to work, including for the enhance trigger.
|
|
120
115
|
- **Byte-wise, not codepoint-wise.** Input is processed one byte at a time; multi-byte UTF-8 characters (accents,
|
|
121
116
|
emoji, non-Latin scripts) may not round-trip through the buffer correctly, though they are still forwarded to the
|
|
122
|
-
child untouched when you're not
|
|
123
|
-
- **Status
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
child untouched when you're not triggering enhancement.
|
|
118
|
+
- **Status bar display is best-effort.** `ccx` draws the spinner/result to the last row of the terminal via ANSI
|
|
119
|
+
save/restore-cursor sequences. This works in standard ANSI/VT terminals (including the VS Code integrated
|
|
120
|
+
terminal) but isn't guaranteed on every terminal emulator, and a terminal resize mid-animation could leave a
|
|
121
|
+
stray line if the row count changes between draws.
|
|
126
122
|
- **The marker itself is reserved.** If you genuinely need a prompt to end with `;;` literally, either add a
|
|
127
|
-
trailing space after it or change
|
|
123
|
+
trailing space after it or change the marker to something you don't otherwise type (via `ccx-init` or `CCX_MARKER`).
|
|
128
124
|
|
|
129
125
|
## Windows-specific notes
|
|
130
126
|
|
package/bin/ccx-init.js
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
import { createInterface } from 'node:readline';
|
|
4
4
|
import { rmSync, existsSync } from 'node:fs';
|
|
5
5
|
import { load, save, configPath } from '../src/config.js';
|
|
6
|
-
import { enhance } from '../src/gemini.js';
|
|
6
|
+
import { enhance, listModels, RateLimitError } from '../src/gemini.js';
|
|
7
7
|
|
|
8
8
|
const args = process.argv.slice(2);
|
|
9
9
|
|
|
10
|
-
// Hidden input helper for API key
|
|
11
10
|
async function promptHidden(question) {
|
|
12
11
|
return new Promise(resolve => {
|
|
13
12
|
process.stdout.write(question);
|
|
@@ -37,23 +36,19 @@ async function promptHidden(question) {
|
|
|
37
36
|
});
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
// Regular prompt helper
|
|
41
39
|
async function prompt(rl, question) {
|
|
42
40
|
return new Promise(resolve => rl.question(question, resolve));
|
|
43
41
|
}
|
|
44
42
|
|
|
45
|
-
// Mask API key: show first 6 chars + ****...
|
|
46
43
|
function maskKey(key) {
|
|
47
44
|
if (!key) return '(not set)';
|
|
48
45
|
if (key.length <= 6) return key + '****...';
|
|
49
46
|
return key.slice(0, 6) + '****...';
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
// Show current config
|
|
53
49
|
function showConfig() {
|
|
54
50
|
const config = load();
|
|
55
51
|
const path = configPath();
|
|
56
|
-
|
|
57
52
|
console.log(`Config: ${path}\n`);
|
|
58
53
|
console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
|
|
59
54
|
console.log(` geminiModel: ${config.geminiModel}`);
|
|
@@ -61,82 +56,74 @@ function showConfig() {
|
|
|
61
56
|
console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
|
|
62
57
|
}
|
|
63
58
|
|
|
64
|
-
// Reset config
|
|
65
59
|
function resetConfig() {
|
|
66
60
|
const path = configPath();
|
|
67
|
-
|
|
68
61
|
if (existsSync(path)) {
|
|
69
62
|
rmSync(path);
|
|
70
|
-
console.log('Config reset. Run `ccx
|
|
63
|
+
console.log('Config reset. Run `ccx-init` to set up again.');
|
|
71
64
|
} else {
|
|
72
65
|
console.log('No config file found — nothing to reset.');
|
|
73
66
|
}
|
|
74
67
|
}
|
|
75
68
|
|
|
76
|
-
// Interactive wizard
|
|
77
69
|
async function runWizard() {
|
|
78
70
|
console.log('\n Welcome to ccx setup\n');
|
|
79
71
|
|
|
80
|
-
// Step 1: Prompt for API key
|
|
81
72
|
const key = await promptHidden(' API key: ');
|
|
82
|
-
if (!key) {
|
|
83
|
-
console.log('No key entered. Aborting.');
|
|
84
|
-
process.exit(1);
|
|
85
|
-
}
|
|
73
|
+
if (!key) { console.log('No key entered. Aborting.'); process.exit(1); }
|
|
86
74
|
|
|
87
|
-
// Step 2: Test the key
|
|
88
75
|
process.stdout.write(' Testing key... ');
|
|
89
76
|
try {
|
|
90
77
|
await enhance('hello', { geminiApiKey: key, geminiModel: 'gemini-2.5-flash', timeoutSeconds: 10 });
|
|
91
78
|
console.log('\x1b[32m✓ Valid\x1b[0m');
|
|
92
79
|
} catch (err) {
|
|
93
|
-
|
|
94
|
-
|
|
80
|
+
if (err instanceof RateLimitError) {
|
|
81
|
+
console.log('\x1b[32m✓ Valid\x1b[0m \x1b[2m(quota exceeded — key authenticated)\x1b[0m');
|
|
82
|
+
} else {
|
|
83
|
+
console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
95
86
|
}
|
|
96
87
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
88
|
+
// Fetch available models, fall back to hardcoded list
|
|
89
|
+
let models;
|
|
90
|
+
process.stdout.write(' Fetching available models... ');
|
|
91
|
+
try {
|
|
92
|
+
const all = await listModels(key);
|
|
93
|
+
models = all.filter(m => m.includes('gemini')).slice(0, 10);
|
|
94
|
+
if (models.length === 0) throw new Error('empty');
|
|
95
|
+
console.log(`\x1b[32m✓\x1b[0m ${models.length} found`);
|
|
96
|
+
} catch (_) {
|
|
97
|
+
models = ['gemini-2.5-flash', 'gemini-1.5-flash', 'gemini-2.5-pro'];
|
|
98
|
+
console.log('(using defaults)');
|
|
99
|
+
}
|
|
103
100
|
|
|
104
|
-
const rl = createInterface({
|
|
105
|
-
input: process.stdin,
|
|
106
|
-
output: process.stdout,
|
|
107
|
-
});
|
|
101
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
102
|
|
|
109
103
|
console.log('');
|
|
110
104
|
console.log(' Model:');
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
105
|
+
models.forEach((m, i) => {
|
|
106
|
+
console.log(` ${i + 1}) ${m}${i === 0 ? ' (default)' : ''}`);
|
|
107
|
+
});
|
|
114
108
|
|
|
115
109
|
const modelChoice = await prompt(rl, ' Choice [1]: ');
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
modelChoice.trim() === '3' ? 2 : 0;
|
|
110
|
+
const idx = parseInt(modelChoice.trim(), 10);
|
|
111
|
+
const modelIndex = (!idx || idx < 1 || idx > models.length) ? 0 : idx - 1;
|
|
119
112
|
const model = models[modelIndex];
|
|
120
113
|
|
|
121
|
-
// Step 4: Trigger marker
|
|
122
114
|
const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
|
|
123
115
|
const marker = markerAnswer.trim() || ';;';
|
|
124
116
|
|
|
125
|
-
// Step 5: Timeout seconds
|
|
126
117
|
const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
|
|
127
118
|
const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
|
|
128
119
|
|
|
129
120
|
rl.close();
|
|
130
121
|
|
|
131
|
-
// Step 6: Save config
|
|
132
122
|
save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
|
|
133
|
-
|
|
134
|
-
// Step 7: Success message
|
|
135
123
|
console.log(`\n Config saved to ${configPath()}`);
|
|
136
124
|
console.log(' Run `ccx claude` to start.\n');
|
|
137
125
|
}
|
|
138
126
|
|
|
139
|
-
// Main dispatch
|
|
140
127
|
if (args.length === 0) {
|
|
141
128
|
runWizard();
|
|
142
129
|
} else if (args[0] === '--show') {
|
package/bin/ccx.js
CHANGED
|
@@ -6,6 +6,49 @@ import { enhance, RateLimitError, AuthError, ModelError, TimeoutError, NetworkEr
|
|
|
6
6
|
import { start as spinnerStart, stop as spinnerStop, clear as spinnerClear } from '../src/ui.js';
|
|
7
7
|
import { createInputHandler } from '../src/input.js';
|
|
8
8
|
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
|
|
11
|
+
// Shell tab-completion scripts
|
|
12
|
+
if (argv[0] === 'completion') {
|
|
13
|
+
const shell = argv[1] || 'bash';
|
|
14
|
+
const scripts = {
|
|
15
|
+
bash: [
|
|
16
|
+
'_ccx_completion() {',
|
|
17
|
+
' local cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
18
|
+
' COMPREPLY=($(compgen -W "claude bash zsh powershell cmd completion" -- "$cur"))',
|
|
19
|
+
'}',
|
|
20
|
+
'complete -F _ccx_completion ccx',
|
|
21
|
+
].join('\n'),
|
|
22
|
+
zsh: [
|
|
23
|
+
'#compdef ccx',
|
|
24
|
+
'_ccx() {',
|
|
25
|
+
' local -a cmds',
|
|
26
|
+
' cmds=(claude bash zsh powershell cmd completion)',
|
|
27
|
+
' _describe \'command\' cmds',
|
|
28
|
+
'}',
|
|
29
|
+
'_ccx',
|
|
30
|
+
].join('\n'),
|
|
31
|
+
powershell: [
|
|
32
|
+
'Register-ArgumentCompleter -Native -CommandName ccx -ScriptBlock {',
|
|
33
|
+
' param($wordToComplete, $commandAst, $cursorPosition)',
|
|
34
|
+
' @(\'claude\',\'bash\',\'zsh\',\'powershell\',\'cmd\',\'completion\') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {',
|
|
35
|
+
' [System.Management.Automation.CompletionResult]::new($_, $_, \'ParameterValue\', $_)',
|
|
36
|
+
' }',
|
|
37
|
+
'}',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
};
|
|
40
|
+
if (!scripts[shell]) {
|
|
41
|
+
process.stderr.write(`Unknown shell: ${shell}. Use: bash, zsh, powershell\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
process.stdout.write(scripts[shell] + '\n');
|
|
45
|
+
process.stdout.write('\n# To activate, add to your shell profile:\n');
|
|
46
|
+
if (shell === 'bash') process.stdout.write('# eval "$(ccx completion bash)"\n');
|
|
47
|
+
else if (shell === 'zsh') process.stdout.write('# eval "$(ccx completion zsh)"\n');
|
|
48
|
+
else if (shell === 'powershell') process.stdout.write('# Invoke-Expression (ccx completion powershell)\n');
|
|
49
|
+
process.exit(0);
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
let pty;
|
|
10
53
|
try {
|
|
11
54
|
pty = (await import('node-pty')).default;
|
|
@@ -21,7 +64,7 @@ try {
|
|
|
21
64
|
const config = load();
|
|
22
65
|
|
|
23
66
|
if (!config.geminiApiKey) {
|
|
24
|
-
process.stderr.write('[ccx] No API key found. Run `ccx
|
|
67
|
+
process.stderr.write('[ccx] No API key found. Run `ccx-init` to set up.\n');
|
|
25
68
|
process.exit(1);
|
|
26
69
|
}
|
|
27
70
|
|
|
@@ -30,9 +73,8 @@ if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
|
30
73
|
process.exit(1);
|
|
31
74
|
}
|
|
32
75
|
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const targetArgs= argv.slice(1);
|
|
76
|
+
const targetCmd = argv[0] || 'claude';
|
|
77
|
+
const targetArgs = argv.slice(1);
|
|
36
78
|
|
|
37
79
|
const isWindows = process.platform === 'win32';
|
|
38
80
|
const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
|
|
@@ -46,7 +88,26 @@ const ptyProcess = pty.spawn(shellFile, shellArgs, {
|
|
|
46
88
|
env: process.env,
|
|
47
89
|
});
|
|
48
90
|
|
|
49
|
-
|
|
91
|
+
// Ring buffer of recent PTY output lines for context-aware enhancement
|
|
92
|
+
const CONTEXT_MAX = 20;
|
|
93
|
+
const contextLines = [];
|
|
94
|
+
|
|
95
|
+
function stripAnsi(s) {
|
|
96
|
+
return s
|
|
97
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
|
|
98
|
+
.replace(/\x1b\][^\x07]*\x07/g, '')
|
|
99
|
+
.replace(/\r/g, '');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
ptyProcess.onData(data => {
|
|
103
|
+
process.stdout.write(data);
|
|
104
|
+
for (const line of stripAnsi(data).split('\n')) {
|
|
105
|
+
if (line.trim()) {
|
|
106
|
+
contextLines.push(line.trim());
|
|
107
|
+
if (contextLines.length > CONTEXT_MAX) contextLines.shift();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
50
111
|
|
|
51
112
|
process.stdout.on('resize', () => {
|
|
52
113
|
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
@@ -58,38 +119,42 @@ process.stdin.resume();
|
|
|
58
119
|
const handler = createInputHandler({
|
|
59
120
|
marker: config.marker,
|
|
60
121
|
|
|
61
|
-
onEnhance: async (line) => {
|
|
62
|
-
// line may include trailing marker (;;) for the ;; trigger — strip it
|
|
122
|
+
onEnhance: async (line, cursor) => {
|
|
63
123
|
const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
|
|
64
124
|
handler.setBusy(true);
|
|
65
125
|
spinnerStart();
|
|
66
126
|
let improved;
|
|
67
127
|
try {
|
|
68
|
-
|
|
128
|
+
const context = contextLines.length > 0 ? contextLines.join('\n') : null;
|
|
129
|
+
improved = await enhance(toSend, config, context);
|
|
69
130
|
} catch (err) {
|
|
70
131
|
let msg = 'Enhancement failed';
|
|
71
132
|
if (err instanceof RateLimitError) msg = 'Quota exceeded';
|
|
72
|
-
else if (err instanceof AuthError)
|
|
73
|
-
else if (err instanceof ModelError)
|
|
133
|
+
else if (err instanceof AuthError) msg = 'Invalid key — run ccx-init';
|
|
134
|
+
else if (err instanceof ModelError) msg = 'Model not found — run ccx-init';
|
|
74
135
|
else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
|
|
75
136
|
else if (err instanceof NetworkError) msg = 'No connection — original restored';
|
|
76
137
|
await spinnerStop('error', msg);
|
|
77
|
-
//
|
|
138
|
+
// Move cursor to end of line then erase, restore original
|
|
139
|
+
const charsAfterCursor = line.length - cursor;
|
|
140
|
+
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
78
141
|
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
79
142
|
ptyProcess.write(toSend);
|
|
80
143
|
handler.setBusy(false);
|
|
144
|
+
handler.setLine(toSend);
|
|
81
145
|
return;
|
|
82
146
|
}
|
|
83
147
|
await spinnerStop('success', 'Enhanced');
|
|
84
|
-
//
|
|
148
|
+
// Move cursor to end of line then erase, write improved
|
|
149
|
+
const charsAfterCursor = line.length - cursor;
|
|
150
|
+
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
85
151
|
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
86
152
|
ptyProcess.write(improved);
|
|
87
153
|
handler.setBusy(false);
|
|
154
|
+
handler.setLine(improved);
|
|
88
155
|
},
|
|
89
156
|
|
|
90
|
-
onSubmit: (_line) => {
|
|
91
|
-
// Enter already forwarded by input.js passthrough
|
|
92
|
-
},
|
|
157
|
+
onSubmit: (_line) => {},
|
|
93
158
|
|
|
94
159
|
onPassthrough: (chunk) => ptyProcess.write(chunk),
|
|
95
160
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "ccx-relay",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.1",
|
|
5
5
|
"description": "Transparent PTY wrapper for Claude Code (or any CLI) that lets you refine your prompt with Gemini AI before submitting it, without leaving your terminal session.",
|
|
6
6
|
"main": "bin/ccx.js",
|
|
7
7
|
"bin": {
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"bin",
|
|
13
13
|
"src",
|
|
14
|
-
"README.md"
|
|
15
|
-
".env.example"
|
|
14
|
+
"README.md"
|
|
16
15
|
],
|
|
17
16
|
"scripts": {
|
|
18
17
|
"test": "node --test test/**/*.test.js",
|
package/src/gemini.js
CHANGED
|
@@ -4,79 +4,84 @@ export class ModelError extends Error {}
|
|
|
4
4
|
export class TimeoutError extends Error {}
|
|
5
5
|
export class NetworkError extends Error {}
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const SYSTEM_PROMPT =
|
|
8
8
|
'Rewrite the following text to be grammatically correct and clearer, ' +
|
|
9
9
|
'while preserving its original intent and meaning exactly. ' +
|
|
10
10
|
'This is a single line typed into a terminal prompt, not a chat message - ' +
|
|
11
11
|
'respond with EXACTLY ONE rewritten version as a single plain-text line. ' +
|
|
12
12
|
'Do not offer multiple options or alternatives. Do not add headings, bullet ' +
|
|
13
13
|
'points, markdown formatting, quotes, explanations, or any commentary. ' +
|
|
14
|
-
'Output must contain nothing but the rewritten line itself
|
|
14
|
+
'Output must contain nothing but the rewritten line itself.';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
function buildAuthHeaders(apiKey) {
|
|
17
|
+
// The x-goog-api-key header authenticates both "Auth key" (AQ.-prefixed)
|
|
18
|
+
// and legacy "Standard key" (AIza-prefixed) formats. Authorization: Bearer
|
|
19
|
+
// does NOT work here - AQ. keys are not OAuth2 access tokens and return
|
|
20
|
+
// 401 Unauthorized when sent that way (confirmed against the live API).
|
|
21
|
+
return { 'x-goog-api-key': apiKey };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function listModels(apiKey) {
|
|
25
|
+
const controller = new AbortController();
|
|
26
|
+
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(
|
|
29
|
+
'https://generativelanguage.googleapis.com/v1beta/models',
|
|
30
|
+
{
|
|
31
|
+
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(apiKey) },
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
if (!response.ok) throw new AuthError('Could not fetch model list');
|
|
36
|
+
const data = await response.json();
|
|
37
|
+
return (data.models || [])
|
|
38
|
+
.filter(m => m.supportedGenerationMethods?.includes('generateContent'))
|
|
39
|
+
.map(m => m.name.replace('models/', ''));
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err instanceof AuthError) throw err;
|
|
42
|
+
throw new NetworkError('Could not fetch model list');
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
18
47
|
|
|
48
|
+
export async function enhance(text, config, context = null) {
|
|
49
|
+
const { geminiApiKey, geminiModel, timeoutSeconds } = config;
|
|
19
50
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
|
|
20
51
|
const controller = new AbortController();
|
|
21
52
|
let timeoutId;
|
|
22
53
|
|
|
54
|
+
const promptText = context
|
|
55
|
+
? `${SYSTEM_PROMPT}\n\nRecent terminal context:\n${context}\n\nText:\n${text}`
|
|
56
|
+
: `${SYSTEM_PROMPT}\n\nText:\n${text}`;
|
|
57
|
+
|
|
23
58
|
try {
|
|
24
59
|
timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
|
|
25
60
|
|
|
26
|
-
const authHeaders = geminiApiKey.startsWith('AQ.')
|
|
27
|
-
? { 'Authorization': `Bearer ${geminiApiKey}` }
|
|
28
|
-
: { 'x-goog-api-key': geminiApiKey };
|
|
29
|
-
|
|
30
61
|
const response = await fetch(url, {
|
|
31
62
|
method: 'POST',
|
|
32
63
|
signal: controller.signal,
|
|
33
|
-
headers: {
|
|
34
|
-
|
|
35
|
-
...authHeaders,
|
|
36
|
-
},
|
|
37
|
-
body: JSON.stringify({
|
|
38
|
-
contents: [{ parts: [{ text: PROMPT_PREFIX + text }] }],
|
|
39
|
-
}),
|
|
64
|
+
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders(geminiApiKey) },
|
|
65
|
+
body: JSON.stringify({ contents: [{ parts: [{ text: promptText }] }] }),
|
|
40
66
|
});
|
|
41
67
|
|
|
42
|
-
// Handle bad HTTP status codes
|
|
43
68
|
if (!response.ok) {
|
|
44
69
|
const body = await response.text();
|
|
45
|
-
if (response.status === 429)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
} else if (response.status === 404) {
|
|
50
|
-
throw new ModelError('Model not found — run ccx init');
|
|
51
|
-
} else {
|
|
52
|
-
const truncated = body.substring(0, 200);
|
|
53
|
-
throw new NetworkError(`API error ${response.status}: ${truncated}`);
|
|
54
|
-
}
|
|
70
|
+
if (response.status === 429) throw new RateLimitError('Quota exceeded');
|
|
71
|
+
else if (response.status === 401 || response.status === 403) throw new AuthError('Invalid key — run ccx init');
|
|
72
|
+
else if (response.status === 404) throw new ModelError('Model not found — run ccx init');
|
|
73
|
+
else throw new NetworkError(`API error ${response.status}: ${body.substring(0, 200)}`);
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
// Parse response
|
|
58
76
|
const data = await response.json();
|
|
59
77
|
const result = data.candidates[0].content.parts.map(p => p.text || '').join('').trim();
|
|
60
|
-
|
|
61
|
-
if (!result) {
|
|
62
|
-
throw new NetworkError('Gemini returned empty response');
|
|
63
|
-
}
|
|
64
|
-
|
|
78
|
+
if (!result) throw new NetworkError('Gemini returned empty response');
|
|
65
79
|
return result;
|
|
66
80
|
} catch (err) {
|
|
67
|
-
|
|
68
|
-
if (err.name === 'AbortError') {
|
|
69
|
-
throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Re-throw custom errors
|
|
81
|
+
if (err.name === 'AbortError') throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
|
|
73
82
|
if (err instanceof RateLimitError || err instanceof AuthError ||
|
|
74
83
|
err instanceof ModelError || err instanceof NetworkError ||
|
|
75
|
-
err instanceof TimeoutError)
|
|
76
|
-
throw err;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Wrap other errors as NetworkError
|
|
84
|
+
err instanceof TimeoutError) throw err;
|
|
80
85
|
throw new NetworkError('No connection');
|
|
81
86
|
} finally {
|
|
82
87
|
clearTimeout(timeoutId);
|
package/src/input.js
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* src/input.js — pure stdin parser with Alt+M and ;; triggers
|
|
3
|
-
* No fetch, no stderr writes, no process.exit
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Parses ESC [ {digits/semicolons} _ sequences (win32-input-mode)
|
|
8
|
-
* Returns { consumed, vk, sc, uc, kd, cs, rc } or null
|
|
9
|
-
* @param {Buffer} chunk
|
|
10
|
-
* @param {number} i - index of ESC byte
|
|
11
|
-
*/
|
|
12
1
|
function parseWin32InputSeq(chunk, i) {
|
|
13
2
|
if (chunk[i + 1] !== 0x5b) return null;
|
|
14
3
|
let j = i + 2;
|
|
@@ -21,16 +10,25 @@ function parseWin32InputSeq(chunk, i) {
|
|
|
21
10
|
return { consumed: j + 1 - i, vk, sc, uc, kd, cs, rc };
|
|
22
11
|
}
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
// Parses standard CSI sequences: ESC [ params final
|
|
14
|
+
function parseCsiSeq(chunk, i) {
|
|
15
|
+
if (i + 1 >= chunk.length || chunk[i + 1] !== 0x5b) return null;
|
|
16
|
+
let j = i + 2;
|
|
17
|
+
while (j < chunk.length && chunk[j] >= 0x20 && chunk[j] <= 0x3f) j++;
|
|
18
|
+
if (j >= chunk.length || chunk[j] < 0x40 || chunk[j] > 0x7e) return null;
|
|
19
|
+
const params = chunk.slice(i + 2, j).toString('ascii');
|
|
20
|
+
const final = String.fromCharCode(chunk[j]);
|
|
21
|
+
return { consumed: j + 1 - i, params, final };
|
|
22
|
+
}
|
|
23
|
+
|
|
28
24
|
export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough, onCtrlC }) {
|
|
29
25
|
let lineBuffer = '';
|
|
26
|
+
let cursor = 0;
|
|
30
27
|
let busy = false;
|
|
31
28
|
|
|
32
29
|
function reset() {
|
|
33
30
|
lineBuffer = '';
|
|
31
|
+
cursor = 0;
|
|
34
32
|
busy = false;
|
|
35
33
|
}
|
|
36
34
|
|
|
@@ -38,40 +36,48 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
38
36
|
busy = b;
|
|
39
37
|
}
|
|
40
38
|
|
|
39
|
+
// Sync internal buffer after external rewrite (e.g. after enhancement)
|
|
40
|
+
function setLine(str) {
|
|
41
|
+
lineBuffer = str;
|
|
42
|
+
cursor = str.length;
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
function processChunk(chunk) {
|
|
42
|
-
|
|
43
|
-
if (busy) {
|
|
44
|
-
onPassthrough(chunk);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
46
|
+
if (busy) { onPassthrough(chunk); return; }
|
|
47
47
|
|
|
48
48
|
let i = 0;
|
|
49
49
|
while (i < chunk.length) {
|
|
50
50
|
const byte = chunk[i];
|
|
51
51
|
|
|
52
52
|
if (byte === 0x1b) {
|
|
53
|
-
//
|
|
53
|
+
// 1. Win32 input mode sequence
|
|
54
54
|
const seq = parseWin32InputSeq(chunk, i);
|
|
55
55
|
if (seq !== null) {
|
|
56
56
|
if (seq.kd === 1) {
|
|
57
57
|
if (seq.vk === 13) {
|
|
58
|
-
// Win32 Enter
|
|
59
58
|
if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
60
|
-
onEnhance(lineBuffer);
|
|
59
|
+
onEnhance(lineBuffer, cursor);
|
|
61
60
|
return;
|
|
62
61
|
} else {
|
|
63
62
|
onSubmit(lineBuffer);
|
|
64
63
|
lineBuffer = '';
|
|
64
|
+
cursor = 0;
|
|
65
65
|
}
|
|
66
66
|
} else if (seq.vk === 8) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
// Win32 Alt+M
|
|
71
|
-
if (lineBuffer.length > 0) {
|
|
72
|
-
onEnhance(lineBuffer);
|
|
73
|
-
return;
|
|
67
|
+
if (cursor > 0) {
|
|
68
|
+
lineBuffer = lineBuffer.slice(0, cursor - 1) + lineBuffer.slice(cursor);
|
|
69
|
+
cursor--;
|
|
74
70
|
}
|
|
71
|
+
} else if (seq.vk === 37) {
|
|
72
|
+
cursor = Math.max(0, cursor - 1);
|
|
73
|
+
} else if (seq.vk === 39) {
|
|
74
|
+
cursor = Math.min(lineBuffer.length, cursor + 1);
|
|
75
|
+
} else if (seq.vk === 36) {
|
|
76
|
+
cursor = 0;
|
|
77
|
+
} else if (seq.vk === 35) {
|
|
78
|
+
cursor = lineBuffer.length;
|
|
79
|
+
} else if (seq.vk === 77 && (seq.cs & (0x0001 | 0x0002)) && !(seq.cs & (0x0004 | 0x0008))) {
|
|
80
|
+
if (lineBuffer.length > 0) { onEnhance(lineBuffer, cursor); return; }
|
|
75
81
|
}
|
|
76
82
|
}
|
|
77
83
|
onPassthrough(chunk.slice(i, i + seq.consumed));
|
|
@@ -79,50 +85,67 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
79
85
|
continue;
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
//
|
|
88
|
+
// 2. Alt+M (ESC m)
|
|
83
89
|
if (i + 1 < chunk.length && chunk[i + 1] === 0x6d) {
|
|
84
90
|
if (lineBuffer.length > 0) {
|
|
85
|
-
onEnhance(lineBuffer);
|
|
91
|
+
onEnhance(lineBuffer, cursor);
|
|
86
92
|
return;
|
|
87
93
|
} else {
|
|
88
94
|
onPassthrough(chunk.slice(i, i + 2));
|
|
89
95
|
i += 2;
|
|
90
96
|
}
|
|
91
|
-
|
|
92
|
-
// Forward rest untouched
|
|
93
|
-
onPassthrough(chunk.slice(i));
|
|
94
|
-
return;
|
|
97
|
+
continue;
|
|
95
98
|
}
|
|
99
|
+
|
|
100
|
+
// 3. Standard CSI sequences (arrows, Home, End, etc.)
|
|
101
|
+
const csi = parseCsiSeq(chunk, i);
|
|
102
|
+
if (csi !== null) {
|
|
103
|
+
if (csi.final === 'D' && !csi.params) cursor = Math.max(0, cursor - 1);
|
|
104
|
+
else if (csi.final === 'C' && !csi.params) cursor = Math.min(lineBuffer.length, cursor + 1);
|
|
105
|
+
else if (csi.final === 'H' && !csi.params) cursor = 0;
|
|
106
|
+
else if (csi.final === 'F' && !csi.params) cursor = lineBuffer.length;
|
|
107
|
+
else if (csi.final === '~' && csi.params === '1') cursor = 0;
|
|
108
|
+
else if (csi.final === '~' && csi.params === '4') cursor = lineBuffer.length;
|
|
109
|
+
onPassthrough(chunk.slice(i, i + csi.consumed));
|
|
110
|
+
i += csi.consumed;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 4. Unknown ESC sequence — forward rest of chunk
|
|
115
|
+
onPassthrough(chunk.slice(i));
|
|
116
|
+
return;
|
|
96
117
|
} else if (byte === 0x0d) {
|
|
97
|
-
// Enter
|
|
98
118
|
if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
99
|
-
onEnhance(lineBuffer);
|
|
119
|
+
onEnhance(lineBuffer, cursor);
|
|
100
120
|
return;
|
|
101
121
|
} else {
|
|
102
122
|
onSubmit(lineBuffer);
|
|
103
123
|
onPassthrough(Buffer.from([byte]));
|
|
104
124
|
lineBuffer = '';
|
|
125
|
+
cursor = 0;
|
|
105
126
|
}
|
|
106
127
|
i++;
|
|
107
128
|
} else if (byte === 0x03) {
|
|
108
|
-
// Ctrl+C
|
|
109
129
|
onCtrlC();
|
|
110
130
|
onPassthrough(Buffer.from([byte]));
|
|
111
131
|
lineBuffer = '';
|
|
132
|
+
cursor = 0;
|
|
112
133
|
i++;
|
|
113
134
|
} else if (byte === 0x7f || byte === 0x08) {
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
if (cursor > 0) {
|
|
136
|
+
lineBuffer = lineBuffer.slice(0, cursor - 1) + lineBuffer.slice(cursor);
|
|
137
|
+
cursor--;
|
|
138
|
+
}
|
|
116
139
|
onPassthrough(Buffer.from([byte]));
|
|
117
140
|
i++;
|
|
118
141
|
} else {
|
|
119
|
-
|
|
120
|
-
|
|
142
|
+
lineBuffer = lineBuffer.slice(0, cursor) + String.fromCharCode(byte) + lineBuffer.slice(cursor);
|
|
143
|
+
cursor++;
|
|
121
144
|
onPassthrough(Buffer.from([byte]));
|
|
122
145
|
i++;
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
}
|
|
126
149
|
|
|
127
|
-
return { processChunk, setBusy, reset };
|
|
150
|
+
return { processChunk, setBusy, reset, setLine };
|
|
128
151
|
}
|
package/src/ui.js
CHANGED
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
const BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
2
|
-
const FRAME_INTERVAL = 80;
|
|
2
|
+
const FRAME_INTERVAL = 80;
|
|
3
3
|
|
|
4
4
|
let spinnerInterval = null;
|
|
5
5
|
let currentFrame = 0;
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Draws the status line on stderr with cursor save/restore
|
|
9
|
-
*/
|
|
10
7
|
function drawStatusLine(content) {
|
|
11
|
-
const
|
|
12
|
-
process.stderr.write(
|
|
8
|
+
const row = process.stdout.rows || 24;
|
|
9
|
+
process.stderr.write(`\x1b[s\x1b[${row};1H\x1b[2K${content}\x1b[u`);
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
/**
|
|
16
|
-
* Starts animating the Braille spinner on stderr
|
|
17
|
-
*/
|
|
18
12
|
export function start() {
|
|
19
|
-
// Reset frame counter and stop any existing interval
|
|
20
13
|
currentFrame = 0;
|
|
21
|
-
if (spinnerInterval)
|
|
22
|
-
clearInterval(spinnerInterval);
|
|
23
|
-
}
|
|
24
|
-
|
|
14
|
+
if (spinnerInterval) clearInterval(spinnerInterval);
|
|
25
15
|
spinnerInterval = setInterval(() => {
|
|
26
16
|
const frame = BRAILLE_FRAMES[currentFrame % BRAILLE_FRAMES.length];
|
|
27
17
|
drawStatusLine(`${frame} Enhancing prompt...`);
|
|
@@ -29,37 +19,16 @@ export function start() {
|
|
|
29
19
|
}, FRAME_INTERVAL);
|
|
30
20
|
}
|
|
31
21
|
|
|
32
|
-
/**
|
|
33
|
-
* Stops the spinner and shows a final status message
|
|
34
|
-
*/
|
|
35
22
|
export async function stop(state, message) {
|
|
36
|
-
|
|
37
|
-
if (spinnerInterval) {
|
|
38
|
-
clearInterval(spinnerInterval);
|
|
39
|
-
spinnerInterval = null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Draw final state based on success/error
|
|
23
|
+
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
|
43
24
|
const symbol = state === 'success' ? '✓' : '✗';
|
|
44
25
|
const colorCode = state === 'success' ? '32' : '31';
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
drawStatusLine(statusLine);
|
|
48
|
-
|
|
49
|
-
// Wait 400ms
|
|
26
|
+
drawStatusLine(`\x1b[${colorCode}m${symbol} ${message}\x1b[0m`);
|
|
50
27
|
await new Promise(r => setTimeout(r, 400));
|
|
51
|
-
|
|
52
|
-
// Clear the status line
|
|
53
28
|
drawStatusLine('');
|
|
54
29
|
}
|
|
55
30
|
|
|
56
|
-
/**
|
|
57
|
-
* Immediately clears the status line and stops spinner
|
|
58
|
-
*/
|
|
59
31
|
export function clear() {
|
|
60
|
-
if (spinnerInterval) {
|
|
61
|
-
clearInterval(spinnerInterval);
|
|
62
|
-
spinnerInterval = null;
|
|
63
|
-
}
|
|
32
|
+
if (spinnerInterval) { clearInterval(spinnerInterval); spinnerInterval = null; }
|
|
64
33
|
drawStatusLine('');
|
|
65
34
|
}
|
package/.env.example
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# Google Gemini API key (required).
|
|
2
|
-
# Get one at https://aistudio.google.com/apikey
|
|
3
|
-
GEMINI_API_KEY=
|
|
4
|
-
|
|
5
|
-
# Gemini model to use for prompt rewriting (optional).
|
|
6
|
-
# Default: gemini-3.5-flash
|
|
7
|
-
GEMINI_MODEL=gemini-3.5-flash
|
|
8
|
-
|
|
9
|
-
# Trailing marker that triggers "enhance current line" when you press Enter
|
|
10
|
-
# (optional). Type your prompt, append this marker, then press Enter once to
|
|
11
|
-
# enhance (not submit) - press Enter again to actually submit. Default: ;;
|
|
12
|
-
CCX_MARKER=;;
|