ccx-relay 1.8.0 → 2.1.0

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/bin/ccx-init.js CHANGED
@@ -7,126 +7,222 @@ import { enhance, listModels, RateLimitError } from '../src/gemini.js';
7
7
 
8
8
  const args = process.argv.slice(2);
9
9
 
10
- async function promptHidden(question) {
10
+ // ── ANSI ──────────────────────────────────────────────────────────────────────
11
+ const c = {
12
+ reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
13
+ green: '\x1b[32m', red: '\x1b[31m', cyan: '\x1b[36m', gray: '\x1b[90m',
14
+ };
15
+ const s = {
16
+ active: `${c.cyan}◆${c.reset}`, done: `${c.gray}◇${c.reset}`,
17
+ pipe: `${c.gray}│${c.reset}`, end: `${c.gray}└${c.reset}`,
18
+ ok: `${c.green}✓${c.reset}`, err: `${c.red}✗${c.reset}`,
19
+ };
20
+
21
+ // ── Banner ────────────────────────────────────────────────────────────────────
22
+ function banner() {
23
+ const title = ' ccx-relay · prompt relay setup ';
24
+ const bar = '─'.repeat(title.length);
25
+ console.log(`\n ╭${bar}╮`);
26
+ console.log(` │${title}│`);
27
+ console.log(` ╰${bar}╯\n`);
28
+ }
29
+
30
+ // ── Primitives ────────────────────────────────────────────────────────────────
31
+ const pipe = () => console.log(s.pipe);
32
+ const info = (m) => console.log(`${s.pipe} ${c.dim}${m}${c.reset}`);
33
+ const good = (m) => console.log(`${s.pipe} ${s.ok} ${m}`);
34
+ const bad = (m) => console.log(`${s.pipe} ${s.err} ${c.red}${m}${c.reset}`);
35
+ const head = (label) => console.log(`${s.active} ${c.bold}${label}${c.reset}`);
36
+
37
+ // ── Hidden input (●●● masking) ────────────────────────────────────────────────
38
+ async function promptHidden() {
11
39
  return new Promise(resolve => {
12
- process.stdout.write(question);
40
+ process.stdout.write(`${s.pipe} `);
13
41
  const rl = createInterface({ input: process.stdin, output: null, terminal: false });
14
42
  if (process.stdin.isTTY) process.stdin.setRawMode(true);
15
43
  let input = '';
16
44
  process.stdin.on('data', function onData(buf) {
17
45
  for (let i = 0; i < buf.length; i++) {
18
- const byte = buf[i];
19
- if (byte === 0x0d || byte === 0x0a) {
46
+ const b = buf[i];
47
+ if (b === 0x0d || b === 0x0a) {
20
48
  if (process.stdin.isTTY) process.stdin.setRawMode(false);
21
49
  process.stdout.write('\n');
22
50
  process.stdin.removeListener('data', onData);
23
51
  rl.close();
24
52
  resolve(input);
25
53
  return;
26
- } else if (byte === 0x7f || byte === 0x08) {
54
+ } else if (b === 0x7f || b === 0x08) {
27
55
  if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
28
- } else if (byte === 0x03) {
56
+ } else if (b === 0x03) {
29
57
  process.exit(0);
30
- } else if (byte >= 0x20) {
31
- input += String.fromCharCode(byte);
32
- process.stdout.write('*');
58
+ } else if (b >= 0x20) {
59
+ input += String.fromCharCode(b);
60
+ process.stdout.write(`${c.dim}●${c.reset}`);
33
61
  }
34
62
  }
35
63
  });
36
64
  });
37
65
  }
38
66
 
39
- async function prompt(rl, question) {
40
- return new Promise(resolve => rl.question(question, resolve));
41
- }
42
-
43
- function maskKey(key) {
44
- if (!key) return '(not set)';
45
- if (key.length <= 6) return key + '****...';
46
- return key.slice(0, 6) + '****...';
67
+ // ── Plain prompt ──────────────────────────────────────────────────────────────
68
+ async function ask(question, defaultVal = '') {
69
+ return new Promise(resolve => {
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
71
+ rl.question(question, answer => {
72
+ rl.close();
73
+ resolve(answer.trim() || defaultVal);
74
+ });
75
+ });
47
76
  }
48
77
 
49
- function showConfig() {
50
- const config = load();
51
- const path = configPath();
52
- console.log(`Config: ${path}\n`);
53
- console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
54
- console.log(` geminiModel: ${config.geminiModel}`);
55
- console.log(` marker: ${config.marker}`);
56
- console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
78
+ // ── Spinner ───────────────────────────────────────────────────────────────────
79
+ const FRAMES = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
80
+ function spin(msg) {
81
+ let f = 0;
82
+ process.stdout.write(`${s.pipe} `);
83
+ const id = setInterval(() => {
84
+ process.stdout.write(`\r${s.pipe} ${c.dim}${FRAMES[f++ % FRAMES.length]}${c.reset} ${msg}`);
85
+ }, 80);
86
+ return () => { clearInterval(id); process.stdout.write('\r\x1b[2K'); };
57
87
  }
58
88
 
59
- function resetConfig() {
60
- const path = configPath();
61
- if (existsSync(path)) {
62
- rmSync(path);
63
- console.log('Config reset. Run `ccx-init` to set up again.');
64
- } else {
65
- console.log('No config file found nothing to reset.');
66
- }
89
+ // ── Model metadata ────────────────────────────────────────────────────────────
90
+ const MODEL_META = {
91
+ 'llama-3.1-8b-instant': { note: 'fastest', quota: '14,400/day' },
92
+ 'llama-3.3-70b-versatile': { note: 'smarter', quota: ' 1,000/day' },
93
+ 'gemma2-9b-it': { note: 'balanced', quota: '14,400/day' },
94
+ 'llama3-8b-8192': { note: 'classic', quota: '14,400/day' },
95
+ 'llama-3.1-70b-versatile': { note: 'large', quota: ' 1,000/day' },
96
+ 'gemini-2.5-flash': { note: 'latest', quota: 'limited' },
97
+ 'gemini-2.0-flash-lite': { note: 'fast', quota: 'limited' },
98
+ 'gemini-1.5-flash': { note: 'stable', quota: '1,500/day' },
99
+ 'gemini-2.5-pro': { note: 'powerful', quota: 'limited' },
100
+ };
101
+
102
+ function modelRow(m, i, isDefault) {
103
+ const meta = MODEL_META[m] || { note: '', quota: '' };
104
+ const num = `${i + 1}`.padStart(2);
105
+ const name = m.padEnd(34);
106
+ const note = meta.note.padEnd(10);
107
+ const tag = isDefault ? ` ${c.dim}← default${c.reset}` : '';
108
+ return `${s.pipe} ${c.dim}${num}${c.reset} ${name}${c.dim}${note} ${meta.quota}${c.reset}${tag}`;
67
109
  }
68
110
 
111
+ // ── Wizard ────────────────────────────────────────────────────────────────────
69
112
  async function runWizard() {
70
- console.log('\n Welcome to ccx setup\n');
71
-
72
- const key = await promptHidden(' API key: ');
73
- if (!key) { console.log('No key entered. Aborting.'); process.exit(1); }
74
-
113
+ banner();
114
+
115
+ // ── Key ──
116
+ head('API key');
117
+ info('Groq (gsk_...) → 14,400 req/day free ← recommended');
118
+ info('Gemini (AIza...) → limited free tier');
119
+ pipe();
120
+
121
+ const key = await promptHidden();
122
+ if (!key) { bad('No key entered.'); process.exit(1); }
123
+ const provider = key.startsWith('gsk_') ? 'Groq' : 'Gemini';
124
+ pipe();
125
+
126
+ // ── Validate ──
127
+ head(`Validating ${provider} key`);
128
+ const stopValidate = spin('Connecting...');
75
129
  const testModel = key.startsWith('gsk_') ? 'llama-3.1-8b-instant' : 'gemini-2.5-flash';
76
- process.stdout.write(' Testing key... ');
77
130
  try {
78
131
  await enhance('hello', { geminiApiKey: key, geminiModel: testModel, timeoutSeconds: 10 });
79
- console.log('\x1b[32m✓ Valid\x1b[0m');
132
+ stopValidate();
133
+ good(`${provider} key valid`);
80
134
  } catch (err) {
81
135
  if (err instanceof RateLimitError) {
82
- console.log('\x1b[32m✓ Valid\x1b[0m \x1b[2m(quota exceeded — key authenticated)\x1b[0m');
136
+ stopValidate();
137
+ good(`${provider} key valid ${c.dim}(quota hit — authenticated)${c.reset}`);
83
138
  } else {
84
- console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
139
+ stopValidate();
140
+ bad(`Invalid key — ${err.message}`);
141
+ console.log(`${s.end} Run ${c.bold}ccx-init${c.reset} again with a valid key.\n`);
85
142
  process.exit(1);
86
143
  }
87
144
  }
145
+ pipe();
88
146
 
89
- // Fetch available models, fall back to hardcoded list
147
+ // ── Models ──
148
+ head('Model');
149
+ const stopFetch = spin('Fetching available models...');
90
150
  let models;
91
- process.stdout.write(' Fetching available models... ');
92
151
  try {
93
152
  const all = await listModels(key);
94
- models = all.filter(m => m.includes('gemini')).slice(0, 10);
95
- if (models.length === 0) throw new Error('empty');
96
- console.log(`\x1b[32m✓\x1b[0m ${models.length} found`);
153
+ models = all.slice(0, 8);
154
+ if (!models.length) throw new Error('empty');
155
+ stopFetch();
97
156
  } catch (_) {
98
- models = ['gemini-2.5-flash', 'gemini-1.5-flash', 'gemini-2.5-pro'];
99
- console.log('(using defaults)');
157
+ stopFetch();
158
+ models = key.startsWith('gsk_')
159
+ ? ['llama-3.1-8b-instant', 'llama-3.3-70b-versatile', 'gemma2-9b-it', 'llama3-8b-8192']
160
+ : ['gemini-2.5-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-flash', 'gemini-2.5-pro'];
100
161
  }
101
162
 
102
- const rl = createInterface({ input: process.stdin, output: process.stdout });
163
+ models.forEach((m, i) => console.log(modelRow(m, i, i === 0)));
164
+ pipe();
103
165
 
104
- console.log('');
105
- console.log(' Model:');
106
- models.forEach((m, i) => {
107
- console.log(` ${i + 1}) ${m}${i === 0 ? ' (default)' : ''}`);
108
- });
109
-
110
- const modelChoice = await prompt(rl, ' Choice [1]: ');
111
- const idx = parseInt(modelChoice.trim(), 10);
112
- const modelIndex = (!idx || idx < 1 || idx > models.length) ? 0 : idx - 1;
166
+ const modelInput = await ask(`${s.pipe} Choice ${c.dim}[1]${c.reset}: `, '1');
167
+ const mIdx = parseInt(modelInput, 10);
168
+ const modelIndex = (!mIdx || mIdx < 1 || mIdx > models.length) ? 0 : mIdx - 1;
113
169
  const model = models[modelIndex];
170
+ good(model);
171
+ pipe();
172
+
173
+ // ── Marker ──
174
+ head('Trigger marker');
175
+ info('Type at end of your prompt + Enter to enhance (Alt+M also works anywhere)');
176
+ const marker = await ask(`${s.pipe} Default ${c.dim}[;;]${c.reset}: `, ';;');
177
+ pipe();
178
+
179
+ // ── Timeout ──
180
+ head('Request timeout');
181
+ info('Seconds before giving up and restoring original text');
182
+ const timeoutInput = await ask(`${s.pipe} Default ${c.dim}[8]${c.reset}: `, '8');
183
+ const timeoutSeconds = parseInt(timeoutInput, 10) || 8;
184
+ pipe();
185
+
186
+ // ── Save ──
187
+ save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
188
+ const path = configPath();
189
+ console.log(`${s.end} ${s.ok} ${c.bold}${c.green}Config saved${c.reset}`);
190
+ console.log(` ${c.dim}${path}${c.reset}`);
191
+ console.log(`\n Run ${c.bold}ccx claude${c.reset} to start\n`);
192
+ }
114
193
 
115
- const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
116
- const marker = markerAnswer.trim() || ';;';
117
-
118
- const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
119
- const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
194
+ // ── Sub-commands ──────────────────────────────────────────────────────────────
195
+ function maskKey(key) {
196
+ if (!key) return '(not set)';
197
+ return key.slice(0, 6) + '****...';
198
+ }
120
199
 
121
- rl.close();
200
+ function showConfig() {
201
+ const cfg = load();
202
+ const path = configPath();
203
+ const provider = cfg.geminiApiKey?.startsWith('gsk_') ? 'Groq' : 'Gemini';
204
+ banner();
205
+ console.log(`${s.pipe} ${c.dim}Path${c.reset} ${path}`);
206
+ console.log(`${s.pipe} ${c.dim}Provider${c.reset} ${provider}`);
207
+ console.log(`${s.pipe} ${c.dim}Key${c.reset} ${maskKey(cfg.geminiApiKey)}`);
208
+ console.log(`${s.pipe} ${c.dim}Model${c.reset} ${cfg.geminiModel}`);
209
+ console.log(`${s.pipe} ${c.dim}Marker${c.reset} ${cfg.marker}`);
210
+ console.log(`${s.end} ${c.dim}Timeout${c.reset} ${cfg.timeoutSeconds}s\n`);
211
+ }
122
212
 
123
- save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
124
- console.log(`\n Config saved to ${configPath()}`);
125
- console.log(' Run `ccx claude` to start.\n');
213
+ function resetConfig() {
214
+ const path = configPath();
215
+ if (existsSync(path)) {
216
+ rmSync(path);
217
+ console.log(`${s.ok} Config reset. Run ${c.bold}ccx-init${c.reset} to set up again.`);
218
+ } else {
219
+ console.log('No config file found — nothing to reset.');
220
+ }
126
221
  }
127
222
 
223
+ // ── Entry ─────────────────────────────────────────────────────────────────────
128
224
  if (args.length === 0) {
129
- runWizard();
225
+ runWizard().catch(err => { console.error(err.message); process.exit(1); });
130
226
  } else if (args[0] === '--show') {
131
227
  showConfig();
132
228
  } else if (args[0] === '--reset') {
package/bin/ccx.js CHANGED
@@ -116,6 +116,20 @@ process.stdout.on('resize', () => {
116
116
  process.stdin.setRawMode(true);
117
117
  process.stdin.resume();
118
118
 
119
+ function eraseInput(line, cursor) {
120
+ const parts = line.split('\n');
121
+ const extraLines = parts.length - 1;
122
+ if (extraLines === 0) {
123
+ const charsAfterCursor = line.length - cursor;
124
+ if (charsAfterCursor > 0) ptyProcess.write(`\x1b[${charsAfterCursor}C`);
125
+ ptyProcess.write(Buffer.alloc(line.length, 0x7f));
126
+ } else {
127
+ ptyProcess.write('\x1b[2K');
128
+ for (let i = 0; i < extraLines; i++) ptyProcess.write('\x1b[1A\x1b[2K');
129
+ ptyProcess.write('\x1b[G');
130
+ }
131
+ }
132
+
119
133
  const handler = createInputHandler({
120
134
  marker: config.marker,
121
135
 
@@ -135,20 +149,14 @@ const handler = createInputHandler({
135
149
  else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
136
150
  else if (err instanceof NetworkError) msg = 'No connection — original restored';
137
151
  await spinnerStop('error', msg);
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`);
141
- ptyProcess.write(Buffer.alloc(line.length, 0x7f));
152
+ eraseInput(line, cursor);
142
153
  ptyProcess.write(toSend);
143
154
  handler.setBusy(false);
144
155
  handler.setLine(toSend);
145
156
  return;
146
157
  }
147
158
  await spinnerStop('success', 'Enhanced');
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`);
151
- ptyProcess.write(Buffer.alloc(line.length, 0x7f));
159
+ eraseInput(line, cursor);
152
160
  ptyProcess.write(improved);
153
161
  handler.setBusy(false);
154
162
  handler.setLine(improved);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "ccx-relay",
4
- "version": "1.8.0",
4
+ "version": "2.1.0",
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
@@ -7,11 +7,11 @@ export class NetworkError extends Error {}
7
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
- 'This is a single line typed into a terminal prompt, not a chat message - ' +
11
- 'respond with EXACTLY ONE rewritten version as a single plain-text line. ' +
10
+ 'This is text typed into a terminal prompt it may be one line or multiple lines. ' +
11
+ 'Respond with EXACTLY ONE rewritten version preserving the same line structure and count. ' +
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 text itself.';
15
15
 
16
16
  function isGroq(apiKey) { return apiKey.startsWith('gsk_'); }
17
17
 
@@ -78,7 +78,7 @@ async function enhanceGroq(text, config, context) {
78
78
  { role: 'system', content: SYSTEM_PROMPT },
79
79
  { role: 'user', content: userContent },
80
80
  ],
81
- max_tokens: 256,
81
+ max_tokens: 2048,
82
82
  }),
83
83
  });
84
84
 
package/src/input.js CHANGED
@@ -55,7 +55,11 @@ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough,
55
55
  if (seq !== null) {
56
56
  if (seq.kd === 1) {
57
57
  if (seq.vk === 13) {
58
- if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
58
+ if (seq.cs & 0x0010) {
59
+ // Shift+Enter: append newline, accumulate multi-line buffer
60
+ lineBuffer += '\n';
61
+ cursor = lineBuffer.length;
62
+ } else if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
59
63
  onEnhance(lineBuffer, cursor);
60
64
  return;
61
65
  } else {