ccx-relay 1.6.1 → 1.7.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 +5 -0
- package/bin/ccx-init.js +2 -1
- package/bin/ccx.js +40 -5
- package/package.json +1 -1
- package/src/gemini.js +73 -8
- package/src/input.js +49 -7
package/README.md
CHANGED
|
@@ -78,6 +78,11 @@ While the wrapped process is running:
|
|
|
78
78
|
- Review the rewritten line, then press **Enter** to submit it to the child process.
|
|
79
79
|
- A plain line with no trigger submits immediately on Enter, exactly as if `ccx` weren't there.
|
|
80
80
|
- Arrow keys, Ctrl+C, and other control sequences pass through untouched.
|
|
81
|
+
- **Multi-line prompts:** Shift+Enter adds a line break without submitting (same as in a plain Claude Code session).
|
|
82
|
+
`ccx` tracks the whole multi-line prompt internally, so the marker/Alt+M trigger enhances the entire thing, and a
|
|
83
|
+
multi-line rewrite gets spliced back in as multiple soft-broken lines rather than being submitted early.
|
|
84
|
+
- Typing ahead while a spinner is running (e.g. mashing Alt+M again) is queued and replayed once the enhancement
|
|
85
|
+
finishes, instead of leaking into the child mid-rewrite. Ctrl+C still interrupts immediately even while enhancing.
|
|
81
86
|
|
|
82
87
|
### Changing the marker
|
|
83
88
|
|
package/bin/ccx-init.js
CHANGED
|
@@ -72,9 +72,10 @@ async function runWizard() {
|
|
|
72
72
|
const key = await promptHidden(' API key: ');
|
|
73
73
|
if (!key) { console.log('No key entered. Aborting.'); process.exit(1); }
|
|
74
74
|
|
|
75
|
+
const testModel = key.startsWith('gsk_') ? 'llama-3.1-8b-instant' : 'gemini-2.5-flash';
|
|
75
76
|
process.stdout.write(' Testing key... ');
|
|
76
77
|
try {
|
|
77
|
-
await enhance('hello', { geminiApiKey: key, geminiModel:
|
|
78
|
+
await enhance('hello', { geminiApiKey: key, geminiModel: testModel, timeoutSeconds: 10 });
|
|
78
79
|
console.log('\x1b[32m✓ Valid\x1b[0m');
|
|
79
80
|
} catch (err) {
|
|
80
81
|
if (err instanceof RateLimitError) {
|
package/bin/ccx.js
CHANGED
|
@@ -116,10 +116,40 @@ process.stdout.on('resize', () => {
|
|
|
116
116
|
process.stdin.setRawMode(true);
|
|
117
117
|
process.stdin.resume();
|
|
118
118
|
|
|
119
|
+
// Windows Console API CONTROL_KEY_STATE flag (see README's win32-input-mode notes;
|
|
120
|
+
// must match SHIFT_PRESSED in src/input.js).
|
|
121
|
+
const SHIFT_PRESSED = 0x0010;
|
|
122
|
+
|
|
123
|
+
function win32KeyEvent(vk, uc, keyDown, cs) {
|
|
124
|
+
return `\x1b[${vk};0;${uc};${keyDown ? 1 : 0};${cs};1_`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A raw '\r'/'\n' byte written into the pty reads to the wrapped app as a plain
|
|
128
|
+
// Enter keypress, which submits instead of inserting a line break — so a
|
|
129
|
+
// multi-line rewrite (or the original multi-line prompt, on restore-after-error)
|
|
130
|
+
// would get cut off mid-line and the remainder typed as a new, separately
|
|
131
|
+
// submitted line. Encode each embedded newline as a synthetic Shift+Enter
|
|
132
|
+
// win32-input-mode sequence instead, matching how a real Shift+Enter keypress
|
|
133
|
+
// already arrives (see the SHIFT_PRESSED branch in src/input.js).
|
|
134
|
+
function writeTextPreservingLines(text) {
|
|
135
|
+
const lines = text.split('\n');
|
|
136
|
+
lines.forEach((line, idx) => {
|
|
137
|
+
if (line) ptyProcess.write(line);
|
|
138
|
+
if (idx < lines.length - 1) {
|
|
139
|
+
if (isWindows) {
|
|
140
|
+
ptyProcess.write(win32KeyEvent(13, 13, true, SHIFT_PRESSED));
|
|
141
|
+
ptyProcess.write(win32KeyEvent(13, 13, false, SHIFT_PRESSED));
|
|
142
|
+
} else {
|
|
143
|
+
ptyProcess.write('\n');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
119
149
|
const handler = createInputHandler({
|
|
120
150
|
marker: config.marker,
|
|
121
151
|
|
|
122
|
-
onEnhance: async (line, cursor) => {
|
|
152
|
+
onEnhance: async (line, cursor, token) => {
|
|
123
153
|
const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
|
|
124
154
|
handler.setBusy(true);
|
|
125
155
|
spinnerStart();
|
|
@@ -128,6 +158,10 @@ const handler = createInputHandler({
|
|
|
128
158
|
const context = contextLines.length > 0 ? contextLines.join('\n') : null;
|
|
129
159
|
improved = await enhance(toSend, config, context);
|
|
130
160
|
} catch (err) {
|
|
161
|
+
// The user may have Ctrl+C'd out (or otherwise moved on) while this was
|
|
162
|
+
// in flight — reset() bumps the epoch, so an old token here means
|
|
163
|
+
// whatever we'd write back no longer belongs on the current line.
|
|
164
|
+
if (!handler.isCurrent(token)) return;
|
|
131
165
|
let msg = 'Enhancement failed';
|
|
132
166
|
if (err instanceof RateLimitError) msg = 'Quota exceeded';
|
|
133
167
|
else if (err instanceof AuthError) msg = 'Invalid key — run ccx-init';
|
|
@@ -139,19 +173,20 @@ const handler = createInputHandler({
|
|
|
139
173
|
const charsAfterCursor = line.length - cursor;
|
|
140
174
|
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
141
175
|
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
142
|
-
|
|
143
|
-
handler.setBusy(false);
|
|
176
|
+
writeTextPreservingLines(toSend);
|
|
144
177
|
handler.setLine(toSend);
|
|
178
|
+
handler.setBusy(false);
|
|
145
179
|
return;
|
|
146
180
|
}
|
|
181
|
+
if (!handler.isCurrent(token)) return;
|
|
147
182
|
await spinnerStop('success', 'Enhanced');
|
|
148
183
|
// Move cursor to end of line then erase, write improved
|
|
149
184
|
const charsAfterCursor = line.length - cursor;
|
|
150
185
|
if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
|
|
151
186
|
ptyProcess.write(Buffer.alloc(line.length, 0x7f));
|
|
152
|
-
|
|
153
|
-
handler.setBusy(false);
|
|
187
|
+
writeTextPreservingLines(improved);
|
|
154
188
|
handler.setLine(improved);
|
|
189
|
+
handler.setBusy(false);
|
|
155
190
|
},
|
|
156
191
|
|
|
157
192
|
onSubmit: (_line) => {},
|
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.7.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": {
|
package/src/gemini.js
CHANGED
|
@@ -13,15 +13,25 @@ const SYSTEM_PROMPT =
|
|
|
13
13
|
'points, markdown formatting, quotes, explanations, or any commentary. ' +
|
|
14
14
|
'Output must contain nothing but the rewritten line itself.';
|
|
15
15
|
|
|
16
|
+
function isGroq(apiKey) { return apiKey.startsWith('gsk_'); }
|
|
17
|
+
|
|
16
18
|
function buildAuthHeaders(apiKey) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
// 401 Unauthorized when sent that way (confirmed against the live API).
|
|
21
|
-
return { 'x-goog-api-key': apiKey };
|
|
19
|
+
return isGroq(apiKey)
|
|
20
|
+
? { 'Authorization': `Bearer ${apiKey}` }
|
|
21
|
+
: { 'x-goog-api-key': apiKey };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function listModels(apiKey) {
|
|
25
|
+
if (isGroq(apiKey)) {
|
|
26
|
+
return [
|
|
27
|
+
'llama-3.1-8b-instant',
|
|
28
|
+
'llama-3.3-70b-versatile',
|
|
29
|
+
'gemma2-9b-it',
|
|
30
|
+
'llama3-8b-8192',
|
|
31
|
+
'llama-3.1-70b-versatile',
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
const controller = new AbortController();
|
|
26
36
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
27
37
|
try {
|
|
@@ -45,7 +55,56 @@ export async function listModels(apiKey) {
|
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
async function enhanceGroq(text, config, context) {
|
|
59
|
+
const { geminiApiKey: apiKey, geminiModel: model, timeoutSeconds } = config;
|
|
60
|
+
const url = 'https://api.groq.com/openai/v1/chat/completions';
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let timeoutId;
|
|
63
|
+
|
|
64
|
+
const userContent = context
|
|
65
|
+
? `Recent terminal context:\n${context}\n\nText:\n${text}`
|
|
66
|
+
: text;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
|
|
70
|
+
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
model,
|
|
77
|
+
messages: [
|
|
78
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
79
|
+
{ role: 'user', content: userContent },
|
|
80
|
+
],
|
|
81
|
+
max_tokens: 256,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
if (response.status === 429) throw new RateLimitError('Quota exceeded');
|
|
87
|
+
else if (response.status === 401 || response.status === 403) throw new AuthError('Invalid key — run ccx-init');
|
|
88
|
+
else if (response.status === 404) throw new ModelError('Model not found — run ccx-init');
|
|
89
|
+
else throw new NetworkError(`API error ${response.status}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
const result = data.choices[0].message.content.trim();
|
|
94
|
+
if (!result) throw new NetworkError('Empty response');
|
|
95
|
+
return result;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.name === 'AbortError') throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
|
|
98
|
+
if (err instanceof RateLimitError || err instanceof AuthError ||
|
|
99
|
+
err instanceof ModelError || err instanceof NetworkError ||
|
|
100
|
+
err instanceof TimeoutError) throw err;
|
|
101
|
+
throw new NetworkError('No connection');
|
|
102
|
+
} finally {
|
|
103
|
+
clearTimeout(timeoutId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function enhanceGemini(text, config, context) {
|
|
49
108
|
const { geminiApiKey, geminiModel, timeoutSeconds } = config;
|
|
50
109
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
|
|
51
110
|
const controller = new AbortController();
|
|
@@ -68,8 +127,8 @@ export async function enhance(text, config, context = null) {
|
|
|
68
127
|
if (!response.ok) {
|
|
69
128
|
const body = await response.text();
|
|
70
129
|
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
|
|
72
|
-
else if (response.status === 404) throw new ModelError('Model not found — run ccx
|
|
130
|
+
else if (response.status === 401 || response.status === 403) throw new AuthError('Invalid key — run ccx-init');
|
|
131
|
+
else if (response.status === 404) throw new ModelError('Model not found — run ccx-init');
|
|
73
132
|
else throw new NetworkError(`API error ${response.status}: ${body.substring(0, 200)}`);
|
|
74
133
|
}
|
|
75
134
|
|
|
@@ -87,3 +146,9 @@ export async function enhance(text, config, context = null) {
|
|
|
87
146
|
clearTimeout(timeoutId);
|
|
88
147
|
}
|
|
89
148
|
}
|
|
149
|
+
|
|
150
|
+
export async function enhance(text, config, context = null) {
|
|
151
|
+
return isGroq(config.geminiApiKey)
|
|
152
|
+
? enhanceGroq(text, config, context)
|
|
153
|
+
: enhanceGemini(text, config, context);
|
|
154
|
+
}
|
package/src/input.js
CHANGED
|
@@ -21,19 +21,42 @@ function parseCsiSeq(chunk, i) {
|
|
|
21
21
|
return { consumed: j + 1 - i, params, final };
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Windows Console API CONTROL_KEY_STATE flag (see README's win32-input-mode notes).
|
|
25
|
+
const SHIFT_PRESSED = 0x0010;
|
|
26
|
+
|
|
24
27
|
export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough, onCtrlC }) {
|
|
25
28
|
let lineBuffer = '';
|
|
26
29
|
let cursor = 0;
|
|
27
30
|
let busy = false;
|
|
31
|
+
let epoch = 0;
|
|
32
|
+
let pendingWhileBusy = [];
|
|
28
33
|
|
|
29
34
|
function reset() {
|
|
30
35
|
lineBuffer = '';
|
|
31
36
|
cursor = 0;
|
|
32
37
|
busy = false;
|
|
38
|
+
pendingWhileBusy = [];
|
|
39
|
+
epoch++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Invalidated by reset() (e.g. Ctrl+C) so a still-in-flight enhance that
|
|
43
|
+
// resolves afterward can tell its result is stale and discard it instead of
|
|
44
|
+
// writing over whatever the user has moved on to.
|
|
45
|
+
function isCurrent(token) {
|
|
46
|
+
return token === epoch;
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
function setBusy(b) {
|
|
50
|
+
const wasBusy = busy;
|
|
36
51
|
busy = b;
|
|
52
|
+
// Replay input that arrived mid-enhancement now that the line has been
|
|
53
|
+
// updated — see processChunk's busy branch for why it's queued instead
|
|
54
|
+
// of forwarded live.
|
|
55
|
+
if (wasBusy && !b && pendingWhileBusy.length > 0) {
|
|
56
|
+
const queued = pendingWhileBusy;
|
|
57
|
+
pendingWhileBusy = [];
|
|
58
|
+
for (const buf of queued) processChunk(buf);
|
|
59
|
+
}
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
// Sync internal buffer after external rewrite (e.g. after enhancement)
|
|
@@ -43,7 +66,21 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
43
66
|
}
|
|
44
67
|
|
|
45
68
|
function processChunk(chunk) {
|
|
46
|
-
if (busy) {
|
|
69
|
+
if (busy) {
|
|
70
|
+
// A lone Ctrl+C still gets through immediately so the wrapped app (and
|
|
71
|
+
// the user) aren't stuck waiting on a hung enhance call. Everything
|
|
72
|
+
// else is queued rather than forwarded raw: forwarding live would let
|
|
73
|
+
// e.g. a mashed Alt+M reach the child unintercepted, silently editing
|
|
74
|
+
// its buffer in a way ccx's lineBuffer/cursor snapshot never sees —
|
|
75
|
+
// desyncing the erase-and-replace math once the enhance completes.
|
|
76
|
+
if (chunk.length === 1 && chunk[0] === 0x03) {
|
|
77
|
+
onCtrlC();
|
|
78
|
+
onPassthrough(chunk);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
pendingWhileBusy.push(Buffer.from(chunk));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
47
84
|
|
|
48
85
|
let i = 0;
|
|
49
86
|
while (i < chunk.length) {
|
|
@@ -55,8 +92,13 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
55
92
|
if (seq !== null) {
|
|
56
93
|
if (seq.kd === 1) {
|
|
57
94
|
if (seq.vk === 13) {
|
|
58
|
-
if (
|
|
59
|
-
|
|
95
|
+
if (seq.cs & SHIFT_PRESSED) {
|
|
96
|
+
// Shift+Enter: soft line break in the wrapped app's own
|
|
97
|
+
// multi-line input, not a submit — keep composing.
|
|
98
|
+
lineBuffer = lineBuffer.slice(0, cursor) + '\n' + lineBuffer.slice(cursor);
|
|
99
|
+
cursor++;
|
|
100
|
+
} else if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
101
|
+
onEnhance(lineBuffer, cursor, epoch);
|
|
60
102
|
return;
|
|
61
103
|
} else {
|
|
62
104
|
onSubmit(lineBuffer);
|
|
@@ -77,7 +119,7 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
77
119
|
} else if (seq.vk === 35) {
|
|
78
120
|
cursor = lineBuffer.length;
|
|
79
121
|
} else if (seq.vk === 77 && (seq.cs & (0x0001 | 0x0002)) && !(seq.cs & (0x0004 | 0x0008))) {
|
|
80
|
-
if (lineBuffer.length > 0) { onEnhance(lineBuffer, cursor); return; }
|
|
122
|
+
if (lineBuffer.length > 0) { onEnhance(lineBuffer, cursor, epoch); return; }
|
|
81
123
|
}
|
|
82
124
|
}
|
|
83
125
|
onPassthrough(chunk.slice(i, i + seq.consumed));
|
|
@@ -88,7 +130,7 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
88
130
|
// 2. Alt+M (ESC m)
|
|
89
131
|
if (i + 1 < chunk.length && chunk[i + 1] === 0x6d) {
|
|
90
132
|
if (lineBuffer.length > 0) {
|
|
91
|
-
onEnhance(lineBuffer, cursor);
|
|
133
|
+
onEnhance(lineBuffer, cursor, epoch);
|
|
92
134
|
return;
|
|
93
135
|
} else {
|
|
94
136
|
onPassthrough(chunk.slice(i, i + 2));
|
|
@@ -116,7 +158,7 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
116
158
|
return;
|
|
117
159
|
} else if (byte === 0x0d) {
|
|
118
160
|
if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
|
|
119
|
-
onEnhance(lineBuffer, cursor);
|
|
161
|
+
onEnhance(lineBuffer, cursor, epoch);
|
|
120
162
|
return;
|
|
121
163
|
} else {
|
|
122
164
|
onSubmit(lineBuffer);
|
|
@@ -147,5 +189,5 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
|
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
|
|
150
|
-
return { processChunk, setBusy, reset, setLine };
|
|
192
|
+
return { processChunk, setBusy, reset, setLine, isCurrent };
|
|
151
193
|
}
|