ccx-relay 1.5.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/.env.example ADDED
@@ -0,0 +1,12 @@
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=;;
package/README.md ADDED
@@ -0,0 +1,171 @@
1
+ # ccx
2
+
3
+ A transparent PTY wrapper for [Claude Code](https://docs.anthropic.com/claude/docs/claude-code) (or any interactive
4
+ CLI) that lets you refine your prompt with Gemini AI before you submit it — without leaving your normal terminal
5
+ session.
6
+
7
+ Run `ccx claude` instead of `claude`. Everything looks and behaves like a normal Claude Code session. When you want a
8
+ rough line cleaned up, append a trailing marker (default `;;`) and press Enter once — `ccx` sends what you typed
9
+ (minus the marker) to Gemini, gets back a grammatically corrected / clearer rewrite of the same intent, and swaps it
10
+ in-place on the line, as if you'd typed the better version yourself, without submitting it yet. Review it, then press
11
+ Enter again as usual to actually submit.
12
+
13
+ This marker-based trigger (rather than a keyboard shortcut) is deliberate: a hotkey has to survive three independent
14
+ layers of keybinding claims — the OS/global-hotkey tools, the terminal app (VS Code and its extensions), and the
15
+ wrapped CLI's own input handling — and on a sufficiently customized machine, every `Ctrl`/`Alt` combination worth
16
+ trying can already be spoken for by one of them. Plain typed text has no such competition.
17
+
18
+ ## Prerequisites
19
+
20
+ - **Node.js 18+** (needed for the built-in `fetch` used to call the Gemini API).
21
+ - **A Gemini API key** — get one at https://aistudio.google.com/apikey. As of mid-2026, Google issues new keys as
22
+ "Auth keys" starting with `AQ.` (the older `AIza`-prefixed "Standard" keys are being phased out through
23
+ September 2026). `ccx` sends the key via the `x-goog-api-key` header, which works for both key types.
24
+ - **Windows only:** `node-pty` is a native module and needs to be compiled during `npm install`. Make sure you have:
25
+ - Visual Studio Build Tools with the "Desktop development with C++" workload, and
26
+ - Python 3.x on your PATH.
27
+
28
+ If `npm install` fails while building `node-pty`, install the above and run `npm rebuild node-pty`.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ npm install -g ccx-relay
34
+ ```
35
+
36
+ ## Setup
37
+
38
+ Run the one-time setup wizard:
39
+
40
+ ```bash
41
+ ccx init
42
+ ```
43
+
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.
45
+
46
+ Other init commands:
47
+
48
+ ```bash
49
+ ccx init --show # print current config (key masked)
50
+ ccx init --reset # wipe config and start over
51
+ ```
52
+
53
+ ### Environment variable override
54
+
55
+ You can skip `ccx init` and set env vars directly (useful for CI):
56
+
57
+ ```bash
58
+ export GEMINI_API_KEY=AIza...
59
+ export GEMINI_MODEL=gemini-2.5-flash # optional
60
+ export CCX_MARKER=;; # optional
61
+ export CCX_TIMEOUT=8 # optional, seconds
62
+ ```
63
+
64
+ ## Usage
65
+
66
+ ```bash
67
+ ccx claude # launch Claude Code behind the ccx relay
68
+ ccx claude --resume # any extra args are passed straight through
69
+ ccx bash # or any other command — ccx just wraps argv
70
+ ```
71
+
72
+ While the wrapped process is running:
73
+
74
+ - Type normally — every keystroke is forwarded to the child process exactly as if `ccx` weren't there.
75
+ - **Trigger with marker:** type `;;` at the end of your line, then press **Enter**. ccx enhances the line and replaces it in place — this first Enter does *not* submit.
76
+ - **Trigger with shortcut:** press **Alt+M** at any point on a non-empty line to enhance immediately without a marker.
77
+ - After enhancement, a Braille spinner animates while Gemini rewrites your prompt, then shows `✓ Enhanced` on success or `✗ error message` on failure.
78
+ - Review the rewritten line, then press **Enter** to submit it to the child process.
79
+ - A plain line with no trigger submits immediately on Enter, exactly as if `ccx` weren't there.
80
+ - Arrow keys, Ctrl+C, and other control sequences pass through untouched.
81
+
82
+ ### Changing the marker
83
+
84
+ Change `CCX_MARKER` via `ccx init` or env var (default `;;`). No source changes needed.
85
+
86
+ ## Testing it locally
87
+
88
+ 1. `npm install`
89
+ 2. `npm run test` — runs `node --check` against the CLI entry point to catch syntax errors.
90
+ 3. `npm link`
91
+ 4. Set up `.env` with a real `GEMINI_API_KEY`.
92
+ 5. Sanity-check the relay mechanics with a safe command first, before pointing it at `claude`:
93
+
94
+ ```bash
95
+ ccx cmd # Windows Command Prompt
96
+ ccx powershell # or PowerShell
97
+ ```
98
+
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 `[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.
103
+
104
+ 6. Once the mechanics check out, run it against the real target:
105
+
106
+ ```bash
107
+ ccx claude
108
+ ```
109
+
110
+ ## Known limitations
111
+
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.
120
+ - **Byte-wise, not codepoint-wise.** Input is processed one byte at a time; multi-byte UTF-8 characters (accents,
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.
126
+ - **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
+
129
+ ## Windows-specific notes
130
+
131
+ - **`.cmd`/`.bat` shims:** `node-pty` spawns processes via Windows' `CreateProcess`, which cannot directly execute
132
+ `.cmd`/`.bat` files (this is how `claude` and many other npm-installed global CLIs are shimmed on Windows). `ccx`
133
+ works around this by routing the target command through `cmd.exe /c` on Windows. You shouldn't need to do anything
134
+ differently — `ccx claude` just works — but if you add your own wrapped commands, keep in mind they're run via
135
+ `cmd.exe`.
136
+ - **ConPTY vs. WSL:** on native Windows, `node-pty` uses ConPTY (Windows' native pseudoconsole API). This is
137
+ different from running inside WSL, where `node-pty` behaves like it does on Linux/macOS (real PTYs). Resize
138
+ events, signal delivery, and raw-mode behavior can differ subtly between the two — if you run `ccx` inside a WSL
139
+ terminal instead of a native Windows one, treat it as a separate environment to re-test.
140
+ - **"win32-input-mode" can replace plain keys with escape sequences.** Some CLIs (Claude Code included) enable a
141
+ ConPTY/Windows Terminal feature called win32-input-mode on startup, for full keyboard fidelity (e.g. to
142
+ distinguish Enter from Shift+Enter). Once enabled, the *real* host terminal stops sending some keys as plain
143
+ bytes and instead encodes them as `ESC[Vk;Sc;Uc;Kd;Cs;Rc_` (virtual-key code, scan code, Unicode char, key
144
+ down/up flag, control-key state, repeat count) — this was observed for both Enter (`Vk=13`) and Backspace
145
+ (`Vk=8`) while wrapping `claude`, arriving as a pair of sequences (key-down then key-up) in a single stdin read.
146
+ `ccx` decodes this format specifically to recognize Enter and Backspace; if you see other keys behaving oddly
147
+ when wrapping a different CLI on Windows, they may need the same treatment — check `parseWin32InputSeq` in
148
+ `bin/ccx.js`.
149
+ - **Ctrl+C:** in raw mode with ConPTY's virtual-terminal input enabled, Ctrl+C arrives as byte `0x03` through stdin
150
+ (like POSIX) rather than as a Windows console control event, so it's forwarded to the child like any other
151
+ keystroke rather than killing `ccx` itself.
152
+
153
+ ## Publishing
154
+
155
+ The npm package is named `ccx-relay` (`ccx` itself is already taken on npm). The command you run stays `ccx` — that
156
+ comes from the `bin` field, not the package name — so nothing about day-to-day usage changes.
157
+
158
+ ```bash
159
+ npm login
160
+ npm publish
161
+ ```
162
+
163
+ For subsequent releases:
164
+
165
+ ```bash
166
+ npm version patch # or minor / major
167
+ npm publish
168
+ ```
169
+
170
+ `npm version patch` bumps the version in `package.json`, creates a git commit and tag (if this is a git repo), which
171
+ you can then push along with the new publish.
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createInterface } from 'node:readline';
4
+ import { rmSync, existsSync } from 'node:fs';
5
+ import { load, save, configPath } from '../src/config.js';
6
+ import { enhance } from '../src/gemini.js';
7
+
8
+ const args = process.argv.slice(2);
9
+
10
+ // Hidden input helper for API key
11
+ async function promptHidden(question) {
12
+ return new Promise(resolve => {
13
+ process.stdout.write(question);
14
+ const rl = createInterface({ input: process.stdin, output: null, terminal: false });
15
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
16
+ let input = '';
17
+ process.stdin.on('data', function onData(buf) {
18
+ for (let i = 0; i < buf.length; i++) {
19
+ const byte = buf[i];
20
+ if (byte === 0x0d || byte === 0x0a) {
21
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
22
+ process.stdout.write('\n');
23
+ process.stdin.removeListener('data', onData);
24
+ rl.close();
25
+ resolve(input);
26
+ return;
27
+ } else if (byte === 0x7f || byte === 0x08) {
28
+ if (input.length > 0) { input = input.slice(0, -1); process.stdout.write('\b \b'); }
29
+ } else if (byte === 0x03) {
30
+ process.exit(0);
31
+ } else if (byte >= 0x20) {
32
+ input += String.fromCharCode(byte);
33
+ process.stdout.write('*');
34
+ }
35
+ }
36
+ });
37
+ });
38
+ }
39
+
40
+ // Regular prompt helper
41
+ async function prompt(rl, question) {
42
+ return new Promise(resolve => rl.question(question, resolve));
43
+ }
44
+
45
+ // Mask API key: show first 6 chars + ****...
46
+ function maskKey(key) {
47
+ if (!key) return '(not set)';
48
+ if (key.length <= 6) return key + '****...';
49
+ return key.slice(0, 6) + '****...';
50
+ }
51
+
52
+ // Show current config
53
+ function showConfig() {
54
+ const config = load();
55
+ const path = configPath();
56
+
57
+ console.log(`Config: ${path}\n`);
58
+ console.log(` geminiApiKey: ${maskKey(config.geminiApiKey)}`);
59
+ console.log(` geminiModel: ${config.geminiModel}`);
60
+ console.log(` marker: ${config.marker}`);
61
+ console.log(` timeoutSeconds: ${config.timeoutSeconds}`);
62
+ }
63
+
64
+ // Reset config
65
+ function resetConfig() {
66
+ const path = configPath();
67
+
68
+ if (existsSync(path)) {
69
+ rmSync(path);
70
+ console.log('Config reset. Run `ccx init` to set up again.');
71
+ } else {
72
+ console.log('No config file found — nothing to reset.');
73
+ }
74
+ }
75
+
76
+ // Interactive wizard
77
+ async function runWizard() {
78
+ console.log('\n Welcome to ccx setup\n');
79
+
80
+ // Step 1: Prompt for API key
81
+ const key = await promptHidden(' API key: ');
82
+ if (!key) {
83
+ console.log('No key entered. Aborting.');
84
+ process.exit(1);
85
+ }
86
+
87
+ // Step 2: Test the key
88
+ process.stdout.write(' Testing key... ');
89
+ try {
90
+ await enhance('hello', { geminiApiKey: key, geminiModel: 'gemini-2.5-flash', timeoutSeconds: 10 });
91
+ console.log('\x1b[32m✓ Valid\x1b[0m');
92
+ } catch (err) {
93
+ console.log(`\x1b[31m✗ Invalid key\x1b[0m — ${err.message}`);
94
+ process.exit(1);
95
+ }
96
+
97
+ // Step 3: Model picker
98
+ const models = [
99
+ 'gemini-2.5-flash',
100
+ 'gemini-1.5-flash',
101
+ 'gemini-2.5-pro'
102
+ ];
103
+
104
+ const rl = createInterface({
105
+ input: process.stdin,
106
+ output: process.stdout,
107
+ });
108
+
109
+ console.log('');
110
+ console.log(' Model:');
111
+ console.log(' 1) gemini-2.5-flash (default)');
112
+ console.log(' 2) gemini-1.5-flash');
113
+ console.log(' 3) gemini-2.5-pro');
114
+
115
+ const modelChoice = await prompt(rl, ' Choice [1]: ');
116
+ const modelIndex = modelChoice.trim() === '' || modelChoice.trim() === '1' ? 0 :
117
+ modelChoice.trim() === '2' ? 1 :
118
+ modelChoice.trim() === '3' ? 2 : 0;
119
+ const model = models[modelIndex];
120
+
121
+ // Step 4: Trigger marker
122
+ const markerAnswer = await prompt(rl, ' Trigger marker [;;]: ');
123
+ const marker = markerAnswer.trim() || ';;';
124
+
125
+ // Step 5: Timeout seconds
126
+ const timeoutAnswer = await prompt(rl, ' Timeout seconds [8]: ');
127
+ const timeoutSeconds = timeoutAnswer.trim() === '' ? 8 : (parseInt(timeoutAnswer, 10) || 8);
128
+
129
+ rl.close();
130
+
131
+ // Step 6: Save config
132
+ save({ geminiApiKey: key, geminiModel: model, marker, timeoutSeconds });
133
+
134
+ // Step 7: Success message
135
+ console.log(`\n Config saved to ${configPath()}`);
136
+ console.log(' Run `ccx claude` to start.\n');
137
+ }
138
+
139
+ // Main dispatch
140
+ if (args.length === 0) {
141
+ runWizard();
142
+ } else if (args[0] === '--show') {
143
+ showConfig();
144
+ } else if (args[0] === '--reset') {
145
+ resetConfig();
146
+ } else {
147
+ console.error(`Unknown command: ${args[0]}`);
148
+ process.exit(1);
149
+ }
package/bin/ccx.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ import { load } from '../src/config.js';
5
+ import { enhance, RateLimitError, AuthError, ModelError, TimeoutError, NetworkError } from '../src/gemini.js';
6
+ import { start as spinnerStart, stop as spinnerStop, clear as spinnerClear } from '../src/ui.js';
7
+ import { createInputHandler } from '../src/input.js';
8
+
9
+ let pty;
10
+ try {
11
+ pty = (await import('node-pty')).default;
12
+ } catch (err) {
13
+ process.stderr.write(
14
+ '[ccx] Failed to load node-pty. On Windows install "Desktop development with C++"\n' +
15
+ 'workload (Visual Studio Build Tools) and Python, then run: npm rebuild node-pty\n' +
16
+ `Original error: ${err.message}\n`
17
+ );
18
+ process.exit(1);
19
+ }
20
+
21
+ const config = load();
22
+
23
+ if (!config.geminiApiKey) {
24
+ process.stderr.write('[ccx] No API key found. Run `ccx init` to set up.\n');
25
+ process.exit(1);
26
+ }
27
+
28
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
29
+ process.stderr.write('[ccx] ccx must be run in an interactive terminal.\n');
30
+ process.exit(1);
31
+ }
32
+
33
+ const argv = process.argv.slice(2);
34
+ const targetCmd = argv[0] || 'claude';
35
+ const targetArgs= argv.slice(1);
36
+
37
+ const isWindows = process.platform === 'win32';
38
+ const shellFile = isWindows ? (process.env.COMSPEC || 'cmd.exe') : targetCmd;
39
+ const shellArgs = isWindows ? ['/d', '/s', '/c', [targetCmd, ...targetArgs].join(' ')] : targetArgs;
40
+
41
+ const ptyProcess = pty.spawn(shellFile, shellArgs, {
42
+ name: 'xterm-color',
43
+ cols: process.stdout.columns || 80,
44
+ rows: process.stdout.rows || 24,
45
+ cwd: process.cwd(),
46
+ env: process.env,
47
+ });
48
+
49
+ ptyProcess.onData(data => process.stdout.write(data));
50
+
51
+ process.stdout.on('resize', () => {
52
+ ptyProcess.resize(process.stdout.columns, process.stdout.rows);
53
+ });
54
+
55
+ process.stdin.setRawMode(true);
56
+ process.stdin.resume();
57
+
58
+ const handler = createInputHandler({
59
+ marker: config.marker,
60
+
61
+ onEnhance: async (line) => {
62
+ // line may include trailing marker (;;) for the ;; trigger — strip it
63
+ const toSend = line.endsWith(config.marker) ? line.slice(0, -config.marker.length) : line;
64
+ handler.setBusy(true);
65
+ spinnerStart();
66
+ let improved;
67
+ try {
68
+ improved = await enhance(toSend, config);
69
+ } catch (err) {
70
+ let msg = 'Enhancement failed';
71
+ if (err instanceof RateLimitError) msg = 'Quota exceeded';
72
+ else if (err instanceof AuthError) msg = 'Invalid key — run ccx init';
73
+ else if (err instanceof ModelError) msg = 'Model not found — run ccx init';
74
+ else if (err instanceof TimeoutError) msg = `Timed out after ${config.timeoutSeconds}s — original restored`;
75
+ else if (err instanceof NetworkError) msg = 'No connection — original restored';
76
+ await spinnerStop('error', msg);
77
+ // Erase full echoed text (including marker if present), restore clean original
78
+ ptyProcess.write(Buffer.alloc(line.length, 0x7f));
79
+ ptyProcess.write(toSend);
80
+ handler.setBusy(false);
81
+ return;
82
+ }
83
+ await spinnerStop('success', 'Enhanced');
84
+ // Erase full echoed text (including marker), write improved
85
+ ptyProcess.write(Buffer.alloc(line.length, 0x7f));
86
+ ptyProcess.write(improved);
87
+ handler.setBusy(false);
88
+ },
89
+
90
+ onSubmit: (_line) => {
91
+ // Enter already forwarded by input.js passthrough
92
+ },
93
+
94
+ onPassthrough: (chunk) => ptyProcess.write(chunk),
95
+
96
+ onCtrlC: () => {
97
+ spinnerClear();
98
+ handler.reset();
99
+ },
100
+ });
101
+
102
+ process.stdin.on('data', chunk => handler.processChunk(chunk));
103
+
104
+ function cleanupAndExit(code) {
105
+ try { if (process.stdin.isTTY) process.stdin.setRawMode(false); } catch (_) {}
106
+ process.exit(code == null ? 0 : code);
107
+ }
108
+
109
+ ptyProcess.onExit(({ exitCode }) => cleanupAndExit(exitCode));
110
+ process.on('SIGINT', () => {});
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "type": "module",
3
+ "name": "ccx-relay",
4
+ "version": "1.5.0",
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
+ "main": "bin/ccx.js",
7
+ "bin": {
8
+ "ccx": "bin/ccx.js",
9
+ "ccx-init": "bin/ccx-init.js"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src",
14
+ "README.md",
15
+ ".env.example"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test test/**/*.test.js",
19
+ "prepublishOnly": "npm test"
20
+ },
21
+ "keywords": [
22
+ "cli",
23
+ "claude",
24
+ "claude-code",
25
+ "gemini",
26
+ "pty",
27
+ "terminal",
28
+ "ai",
29
+ "prompt-engineering"
30
+ ],
31
+ "author": "Your Name <you@example.com>",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/unhiredcoder/ccx-relay.git"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "node-pty": "^1.0.0"
42
+ }
43
+ }
package/src/config.js ADDED
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const defaults = {
6
+ geminiApiKey: null,
7
+ geminiModel: 'gemini-2.5-flash',
8
+ marker: ';;',
9
+ timeoutSeconds: 8,
10
+ };
11
+
12
+ /**
13
+ * Returns the path to the config.json file.
14
+ * Windows: %APPDATA%/ccx/config.json
15
+ * macOS/Linux: ~/.config/ccx/config.json
16
+ */
17
+ export function configPath() {
18
+ let configDir;
19
+
20
+ if (process.platform === 'win32') {
21
+ configDir = path.join(
22
+ process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
23
+ 'ccx'
24
+ );
25
+ } else {
26
+ configDir = path.join(
27
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'),
28
+ 'ccx'
29
+ );
30
+ }
31
+
32
+ return path.join(configDir, 'config.json');
33
+ }
34
+
35
+ /**
36
+ * Loads user configuration from multiple sources with priority:
37
+ * 1. Environment variables
38
+ * 2. config.json file (if exists)
39
+ * 3. Hardcoded defaults
40
+ */
41
+ export function load() {
42
+ const result = { ...defaults };
43
+
44
+ // Layer 2: Read from config.json if it exists
45
+ try {
46
+ const configFilePath = configPath();
47
+ if (fs.existsSync(configFilePath)) {
48
+ const fileContent = fs.readFileSync(configFilePath, 'utf8');
49
+ const fileConfig = JSON.parse(fileContent);
50
+ Object.assign(result, fileConfig);
51
+ }
52
+ } catch (error) {
53
+ // If file doesn't exist or fails to parse, use defaults silently
54
+ }
55
+
56
+ // Layer 1: Environment variables override everything
57
+ if (process.env.GEMINI_API_KEY) {
58
+ result.geminiApiKey = process.env.GEMINI_API_KEY;
59
+ }
60
+ if (process.env.GEMINI_MODEL) {
61
+ result.geminiModel = process.env.GEMINI_MODEL;
62
+ }
63
+ const rawMarker = (process.env.CCX_MARKER !== undefined ? process.env.CCX_MARKER : null) || result.marker || defaults.marker;
64
+ result.marker = rawMarker.length > 0 ? rawMarker : defaults.marker;
65
+
66
+ const rawTimeout = Number(process.env.CCX_TIMEOUT || result.timeoutSeconds || defaults.timeoutSeconds);
67
+ result.timeoutSeconds = (isFinite(rawTimeout) && rawTimeout > 0) ? rawTimeout : defaults.timeoutSeconds;
68
+
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * Saves a partial config patch to disk.
74
+ * Creates directory if needed and merges with existing config.
75
+ */
76
+ export function save(patch) {
77
+ const configFilePath = configPath();
78
+ const configDir = path.dirname(configFilePath);
79
+
80
+ // Create directory if it doesn't exist
81
+ fs.mkdirSync(configDir, { recursive: true });
82
+
83
+ // Read existing config or use empty object if file doesn't exist
84
+ let existing = {};
85
+ try {
86
+ if (fs.existsSync(configFilePath)) {
87
+ const fileContent = fs.readFileSync(configFilePath, 'utf8');
88
+ existing = JSON.parse(fileContent);
89
+ }
90
+ } catch (error) {
91
+ // If file doesn't exist or fails to parse, start with empty object
92
+ }
93
+
94
+ // Deep merge patch into existing
95
+ const merged = { ...existing, ...patch };
96
+
97
+ // Write pretty-printed JSON with 2-space indent
98
+ fs.writeFileSync(configFilePath, JSON.stringify(merged, null, 2) + '\n');
99
+ }
package/src/gemini.js ADDED
@@ -0,0 +1,80 @@
1
+ export class RateLimitError extends Error {}
2
+ export class AuthError extends Error {}
3
+ export class ModelError extends Error {}
4
+ export class TimeoutError extends Error {}
5
+ export class NetworkError extends Error {}
6
+
7
+ const PROMPT_PREFIX =
8
+ 'Rewrite the following text to be grammatically correct and clearer, ' +
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. ' +
12
+ 'Do not offer multiple options or alternatives. Do not add headings, bullet ' +
13
+ 'points, markdown formatting, quotes, explanations, or any commentary. ' +
14
+ 'Output must contain nothing but the rewritten line itself.\n\nText:\n';
15
+
16
+ export async function enhance(text, config) {
17
+ const { geminiApiKey, geminiModel, timeoutSeconds } = config;
18
+
19
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent`;
20
+ const controller = new AbortController();
21
+ let timeoutId;
22
+
23
+ try {
24
+ timeoutId = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
25
+
26
+ const response = await fetch(url, {
27
+ method: 'POST',
28
+ signal: controller.signal,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'x-goog-api-key': geminiApiKey,
32
+ },
33
+ body: JSON.stringify({
34
+ contents: [{ parts: [{ text: PROMPT_PREFIX + text }] }],
35
+ }),
36
+ });
37
+
38
+ // Handle bad HTTP status codes
39
+ if (!response.ok) {
40
+ const body = await response.text();
41
+ if (response.status === 429) {
42
+ throw new RateLimitError('Quota exceeded');
43
+ } else if (response.status === 401 || response.status === 403) {
44
+ throw new AuthError('Invalid key — run ccx init');
45
+ } else if (response.status === 404) {
46
+ throw new ModelError('Model not found — run ccx init');
47
+ } else {
48
+ const truncated = body.substring(0, 200);
49
+ throw new NetworkError(`API error ${response.status}: ${truncated}`);
50
+ }
51
+ }
52
+
53
+ // Parse response
54
+ const data = await response.json();
55
+ const result = data.candidates[0].content.parts.map(p => p.text || '').join('').trim();
56
+
57
+ if (!result) {
58
+ throw new NetworkError('Gemini returned empty response');
59
+ }
60
+
61
+ return result;
62
+ } catch (err) {
63
+ // Handle fetch errors
64
+ if (err.name === 'AbortError') {
65
+ throw new TimeoutError(`Timed out after ${timeoutSeconds}s`);
66
+ }
67
+
68
+ // Re-throw custom errors
69
+ if (err instanceof RateLimitError || err instanceof AuthError ||
70
+ err instanceof ModelError || err instanceof NetworkError ||
71
+ err instanceof TimeoutError) {
72
+ throw err;
73
+ }
74
+
75
+ // Wrap other errors as NetworkError
76
+ throw new NetworkError('No connection');
77
+ } finally {
78
+ clearTimeout(timeoutId);
79
+ }
80
+ }
package/src/input.js ADDED
@@ -0,0 +1,128 @@
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
+ function parseWin32InputSeq(chunk, i) {
13
+ if (chunk[i + 1] !== 0x5b) return null;
14
+ let j = i + 2;
15
+ const start = j;
16
+ while (j < chunk.length && ((chunk[j] >= 0x30 && chunk[j] <= 0x39) || chunk[j] === 0x3b)) j++;
17
+ if (j >= chunk.length || chunk[j] !== 0x5f || j === start) return null;
18
+ const parts = chunk.slice(start, j).toString('ascii').split(';').map(Number);
19
+ if (parts.length !== 6 || parts.some(Number.isNaN)) return null;
20
+ const [vk, sc, uc, kd, cs, rc] = parts;
21
+ return { consumed: j + 1 - i, vk, sc, uc, kd, cs, rc };
22
+ }
23
+
24
+ /**
25
+ * @param {{ marker: string, onEnhance: Function, onSubmit: Function, onPassthrough: Function, onCtrlC: Function }} opts
26
+ * @returns {{ processChunk(chunk: Buffer): void, setBusy(b: boolean): void, reset(): void }}
27
+ */
28
+ export function createInputHandler({ marker, onEnhance, onSubmit, onPassthrough, onCtrlC }) {
29
+ let lineBuffer = '';
30
+ let busy = false;
31
+
32
+ function reset() {
33
+ lineBuffer = '';
34
+ busy = false;
35
+ }
36
+
37
+ function setBusy(b) {
38
+ busy = b;
39
+ }
40
+
41
+ function processChunk(chunk) {
42
+ // When busy, forward everything as-is
43
+ if (busy) {
44
+ onPassthrough(chunk);
45
+ return;
46
+ }
47
+
48
+ let i = 0;
49
+ while (i < chunk.length) {
50
+ const byte = chunk[i];
51
+
52
+ if (byte === 0x1b) {
53
+ // Try win32-input-mode sequence first
54
+ const seq = parseWin32InputSeq(chunk, i);
55
+ if (seq !== null) {
56
+ if (seq.kd === 1) {
57
+ if (seq.vk === 13) {
58
+ // Win32 Enter
59
+ if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
60
+ onEnhance(lineBuffer);
61
+ return;
62
+ } else {
63
+ onSubmit(lineBuffer);
64
+ lineBuffer = '';
65
+ }
66
+ } else if (seq.vk === 8) {
67
+ // Win32 Backspace
68
+ if (lineBuffer.length > 0) lineBuffer = lineBuffer.slice(0, -1);
69
+ } else if (seq.vk === 77 && (seq.cs & (0x0001 | 0x0002)) && !(seq.cs & (0x0004 | 0x0008))) {
70
+ // Win32 Alt+M
71
+ if (lineBuffer.length > 0) {
72
+ onEnhance(lineBuffer);
73
+ return;
74
+ }
75
+ }
76
+ }
77
+ onPassthrough(chunk.slice(i, i + seq.consumed));
78
+ i += seq.consumed;
79
+ continue;
80
+ }
81
+
82
+ // No win32 sequence — check for standard Alt+M (ESC followed by 0x6d)
83
+ if (i + 1 < chunk.length && chunk[i + 1] === 0x6d) {
84
+ if (lineBuffer.length > 0) {
85
+ onEnhance(lineBuffer);
86
+ return;
87
+ } else {
88
+ onPassthrough(chunk.slice(i, i + 2));
89
+ i += 2;
90
+ }
91
+ } else {
92
+ // Forward rest untouched
93
+ onPassthrough(chunk.slice(i));
94
+ return;
95
+ }
96
+ } else if (byte === 0x0d) {
97
+ // Enter
98
+ if (lineBuffer.endsWith(marker) && lineBuffer.trim().length > marker.length) {
99
+ onEnhance(lineBuffer);
100
+ return;
101
+ } else {
102
+ onSubmit(lineBuffer);
103
+ onPassthrough(Buffer.from([byte]));
104
+ lineBuffer = '';
105
+ }
106
+ i++;
107
+ } else if (byte === 0x03) {
108
+ // Ctrl+C
109
+ onCtrlC();
110
+ onPassthrough(Buffer.from([byte]));
111
+ lineBuffer = '';
112
+ i++;
113
+ } else if (byte === 0x7f || byte === 0x08) {
114
+ // Backspace
115
+ lineBuffer = lineBuffer.slice(0, -1);
116
+ onPassthrough(Buffer.from([byte]));
117
+ i++;
118
+ } else {
119
+ // Printable char
120
+ lineBuffer += String.fromCharCode(byte);
121
+ onPassthrough(Buffer.from([byte]));
122
+ i++;
123
+ }
124
+ }
125
+ }
126
+
127
+ return { processChunk, setBusy, reset };
128
+ }
package/src/ui.js ADDED
@@ -0,0 +1,65 @@
1
+ const BRAILLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
2
+ const FRAME_INTERVAL = 80; // ms
3
+
4
+ let spinnerInterval = null;
5
+ let currentFrame = 0;
6
+
7
+ /**
8
+ * Draws the status line on stderr with cursor save/restore
9
+ */
10
+ function drawStatusLine(content) {
11
+ const ansi = `\x1b[s\r\n\x1b[2K${content}\x1b[u`;
12
+ process.stderr.write(ansi);
13
+ }
14
+
15
+ /**
16
+ * Starts animating the Braille spinner on stderr
17
+ */
18
+ export function start() {
19
+ // Reset frame counter and stop any existing interval
20
+ currentFrame = 0;
21
+ if (spinnerInterval) {
22
+ clearInterval(spinnerInterval);
23
+ }
24
+
25
+ spinnerInterval = setInterval(() => {
26
+ const frame = BRAILLE_FRAMES[currentFrame % BRAILLE_FRAMES.length];
27
+ drawStatusLine(`${frame} Enhancing prompt...`);
28
+ currentFrame++;
29
+ }, FRAME_INTERVAL);
30
+ }
31
+
32
+ /**
33
+ * Stops the spinner and shows a final status message
34
+ */
35
+ export async function stop(state, message) {
36
+ // Clear the spinner interval
37
+ if (spinnerInterval) {
38
+ clearInterval(spinnerInterval);
39
+ spinnerInterval = null;
40
+ }
41
+
42
+ // Draw final state based on success/error
43
+ const symbol = state === 'success' ? '✓' : '✗';
44
+ const colorCode = state === 'success' ? '32' : '31';
45
+ const statusLine = `\x1b[${colorCode}m${symbol} ${message}\x1b[0m`;
46
+
47
+ drawStatusLine(statusLine);
48
+
49
+ // Wait 400ms
50
+ await new Promise(r => setTimeout(r, 400));
51
+
52
+ // Clear the status line
53
+ drawStatusLine('');
54
+ }
55
+
56
+ /**
57
+ * Immediately clears the status line and stops spinner
58
+ */
59
+ export function clear() {
60
+ if (spinnerInterval) {
61
+ clearInterval(spinnerInterval);
62
+ spinnerInterval = null;
63
+ }
64
+ drawStatusLine('');
65
+ }