ccx-relay 1.7.0 → 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 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 init
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 init --show # print current config (key masked)
50
- ccx init --reset # wipe config and start over
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 init` and set env vars directly (useful for CI):
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...
@@ -78,17 +78,22 @@ 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
 
84
- Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes needed.
89
+ Change `CCX_MARKER` via `ccx-init` or env var (default `;;`). No source changes needed.
85
90
 
86
91
  ## Testing it locally
87
92
 
88
93
  1. `npm install`
89
- 2. `npm run test` — runs `node --check` against the CLI entry point to catch syntax errors.
94
+ 2. `npm test` — runs the unit test suite (`node --test`) covering config loading, the Gemini client, and keystroke handling.
90
95
  3. `npm link`
91
- 4. Set up `.env` with a real `GEMINI_API_KEY`.
96
+ 4. `ccx-init` — set up your real `GEMINI_API_KEY` (this validates the key and lets you pick a model live).
92
97
  5. Sanity-check the relay mechanics with a safe command first, before pointing it at `claude`:
93
98
 
94
99
  ```bash
@@ -97,9 +102,9 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
97
102
  ```
98
103
 
99
104
  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 `[ccx] enhancing...` flash briefly, then the line rewritten in place
101
- (e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter again to run it as a normal
102
- shell command.
105
+ then press **Enter**. You should see a spinner flash briefly (`⠋ Enhancing prompt...`), then `✓ Enhanced`, then
106
+ the line rewritten in place (e.g. `Please fix the bug in server.js`) with nothing submitted yet. Press Enter
107
+ again to run it as a normal shell command.
103
108
 
104
109
  6. Once the mechanics check out, run it against the real target:
105
110
 
@@ -109,22 +114,18 @@ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes
109
114
 
110
115
  ## Known limitations
111
116
 
112
- - **Works best for single-line prompts typed straight through.** The line buffer is a plain JS string built up as
113
- you type; it does not model cursor position.
114
- - **Heavy mid-line editing via arrow keys is not fully tracked.** Escape sequences (arrow keys, Home/End, etc.) are
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.
117
+ - **Cursor position is tracked, including mid-line edits.** Left/Right/Home/End move an internal cursor that stays
118
+ in sync with inserts, backspace-at-cursor, and both plain-byte and win32-input-mode key encodings — so arrowing
119
+ back into the middle of a line and editing there is expected to work, including for the enhance trigger.
120
120
  - **Byte-wise, not codepoint-wise.** Input is processed one byte at a time; multi-byte UTF-8 characters (accents,
121
121
  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 invoking the hotkey.
123
- - **Status line display is best-effort.** `ccx` uses ANSI save/restore-cursor sequences to show and clear the
124
- `[ccx] enhancing...` message on its own line. This works in standard ANSI/VT terminals (including the VS Code
125
- integrated terminal) but isn't guaranteed on every terminal emulator.
122
+ child untouched when you're not triggering enhancement.
123
+ - **Status bar display is best-effort.** `ccx` draws the spinner/result to the last row of the terminal via ANSI
124
+ save/restore-cursor sequences. This works in standard ANSI/VT terminals (including the VS Code integrated
125
+ terminal) but isn't guaranteed on every terminal emulator, and a terminal resize mid-animation could leave a
126
+ stray line if the row count changes between draws.
126
127
  - **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 `CCX_MARKER` to something you don't otherwise type.
128
+ trailing space after it or change the marker to something you don't otherwise type (via `ccx-init` or `CCX_MARKER`).
128
129
 
129
130
  ## Windows-specific notes
130
131
 
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: 'gemini-2.5-flash', timeoutSeconds: 10 });
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
- ptyProcess.write(toSend);
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
- ptyProcess.write(improved);
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.7.0",
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": {
@@ -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
@@ -13,13 +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
- return apiKey.startsWith('AQ.')
19
+ return isGroq(apiKey)
18
20
  ? { 'Authorization': `Bearer ${apiKey}` }
19
21
  : { 'x-goog-api-key': apiKey };
20
22
  }
21
23
 
22
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
+
23
35
  const controller = new AbortController();
24
36
  const timeoutId = setTimeout(() => controller.abort(), 10000);
25
37
  try {
@@ -43,7 +55,56 @@ export async function listModels(apiKey) {
43
55
  }
44
56
  }
45
57
 
46
- export async function enhance(text, config, context = null) {
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) {
47
108
  const { geminiApiKey, geminiModel, timeoutSeconds } = config;
48
109
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
49
110
  const controller = new AbortController();
@@ -66,8 +127,8 @@ export async function enhance(text, config, context = null) {
66
127
  if (!response.ok) {
67
128
  const body = await response.text();
68
129
  if (response.status === 429) throw new RateLimitError('Quota exceeded');
69
- else if (response.status === 401 || response.status === 403) throw new AuthError('Invalid key — run ccx init');
70
- else if (response.status === 404) throw new ModelError('Model not found — run ccx init');
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');
71
132
  else throw new NetworkError(`API error ${response.status}: ${body.substring(0, 200)}`);
72
133
  }
73
134
 
@@ -85,3 +146,9 @@ export async function enhance(text, config, context = null) {
85
146
  clearTimeout(timeoutId);
86
147
  }
87
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) { onPassthrough(chunk); return; }
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 (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
59
- onEnhance(lineBuffer, cursor);
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
  }
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=;;