unramble 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +231 -2
- package/cli/unramble.mjs +136 -0
- package/hooks/keybindings.js +132 -0
- package/package.json +35 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayush Yadav
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,232 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/wordmark-dark.svg">
|
|
4
|
+
<img src="assets/wordmark.svg" alt="unramble" width="360">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<p align="center"><b>Press Ctrl+G and the rambling prompt you just dictated becomes the one you meant to type — in place, inside Claude Code, on the plan you already have.</b></p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://yadava5.github.io/unramble/"><b>Install in one click</b></a> ·
|
|
12
|
+
<a href="#what-it-does">What it does</a> ·
|
|
13
|
+
<a href="#plan-or-api-key">Plan or API key</a> ·
|
|
14
|
+
<a href="docs/BENCHMARKS.md">Benchmarks</a> ·
|
|
15
|
+
<a href="docs/HOW-IT-WORKS.md">How it works</a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://github.com/yadava5/unramble/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/yadava5/unramble/actions/workflows/ci.yml/badge.svg"></a>
|
|
20
|
+
<a href="https://www.npmjs.com/package/unramble"><img alt="npm" src="https://img.shields.io/npm/v/unramble"></a>
|
|
21
|
+
<a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
|
22
|
+
<img alt="Claude Code plugin" src="https://img.shields.io/badge/Claude%20Code-plugin-d97757">
|
|
23
|
+
<img alt="No API key needed" src="https://img.shields.io/badge/API%20key-not%20needed-2ea44f">
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="assets/demo.gif" alt="A rough dictated prompt is typed into Claude Code, Ctrl+G is pressed, the band reads refining…, and three seconds later the box holds a clean numbered prompt" width="820">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
You talk faster than you type, so you dictate. Then Claude Code gets *"uh no wait, not the
|
|
31
|
+
handler, the session middleware"* and takes every word literally. unramble rewrites the draft
|
|
32
|
+
before you send it: fillers and false starts gone, the correction applied, separate asks
|
|
33
|
+
numbered, standing rules under *Constraints*. Same meaning — nothing added, nothing you said
|
|
34
|
+
lost. Ctrl+G again puts your original back.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
**One click:** [**Install in Claude Code →**](https://yadava5.github.io/unramble/) opens Claude
|
|
39
|
+
Code in your terminal with the install command typed in; press Enter.
|
|
40
|
+
|
|
41
|
+
**In your terminal** (zsh, bash, PowerShell — not inside Claude Code; needs Node 18+):
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
npx unramble
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That installs the plugin and binds Ctrl+G in one step.
|
|
48
|
+
|
|
49
|
+
**Or inside Claude Code**, with no Node needed: start `claude`, then type these at its `❯`
|
|
50
|
+
prompt, one at a time. Claude Code has no shorter form for a plugin outside its own directory.
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
/plugin install unramble --marketplace yadava5/unramble
|
|
54
|
+
/unramble setup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Answer `y` to add the marketplace and pick a scope (user is the default). `setup` binds Ctrl+G;
|
|
58
|
+
it keeps a backup of your `keybindings.json` and never overwrites a key you bound yourself.
|
|
59
|
+
|
|
60
|
+
Then quit Claude Code and start a new session (`claude`), say or type a prompt, and press
|
|
61
|
+
**Ctrl+G**. Works with Claude Code 2.1.308 and every release after it: CI tests the newest
|
|
62
|
+
Claude Code every day on Linux, macOS and Windows. It always uses the newest Sonnet or Opus,
|
|
63
|
+
because it asks for the model by family, never by version. See
|
|
64
|
+
[Where it is tested](#where-it-is-tested).
|
|
65
|
+
|
|
66
|
+
## What it does
|
|
67
|
+
|
|
68
|
+
The demo above is a real rewrite (Sonnet on a Claude Code plan, 3.3 s). What was said:
|
|
69
|
+
|
|
70
|
+
> okay so um the login page is broken again I think, like when you hit submit it just spins
|
|
71
|
+
> forever. can you look at the auth handler, uh no wait, not the handler, the session
|
|
72
|
+
> middleware, that's what changed yesterday. and then once it works add a test for it so this
|
|
73
|
+
> doesn't happen again. oh and don't touch the database migrations, we're kind of mid deploy
|
|
74
|
+
|
|
75
|
+
What Claude Code gets:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
The login page is broken again, I think: when you hit submit, it spins forever.
|
|
79
|
+
|
|
80
|
+
1. Look at the session middleware, which is what changed yesterday.
|
|
81
|
+
2. Once it works, add a test for it so this doesn't happen again.
|
|
82
|
+
|
|
83
|
+
Constraints:
|
|
84
|
+
- Don't touch the database migrations; we're mid deploy.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
(It is not perfect: "kind of" mid deploy lost its hedge. The [benchmarks](docs/BENCHMARKS.md)
|
|
88
|
+
count that as a minor defect.)
|
|
89
|
+
|
|
90
|
+
| Key | Does |
|
|
91
|
+
|---|---|
|
|
92
|
+
| **Ctrl+G** | rewrite the draft (the band above the box says `refining…`) |
|
|
93
|
+
| **Ctrl+G** on the rewrite | put your original back, exactly |
|
|
94
|
+
| **Ctrl+G** while it runs | cancel; your draft stays |
|
|
95
|
+
|
|
96
|
+
### What it will not do
|
|
97
|
+
|
|
98
|
+
- **Send anything you did not ask it to.** Nothing leaves the box until you press Ctrl+G.
|
|
99
|
+
- **Lose a machine value.** A rewrite that drops a path, `--flag`, number, URL, `@ref` or
|
|
100
|
+
`[Pasted text]` chip is refused and your draft stays — unless you corrected that value on
|
|
101
|
+
the spot ("src/utils, no wait, src/lib").
|
|
102
|
+
- **Add a link or a command you never said.** Pasted text can carry instructions; a rewrite
|
|
103
|
+
that brings in a URL, `curl`/`wget`/`sudo`/`rm -…`, shell chaining or invisible characters
|
|
104
|
+
you did not say is refused. On 2,240 recorded rewrites this refused none.
|
|
105
|
+
- **Touch pasted logs or code.** They are masked before the model sees the draft and put back
|
|
106
|
+
byte for byte.
|
|
107
|
+
- **Answer your question or do the task.** "Can you check X?" stays a question for Claude Code.
|
|
108
|
+
- **Overwrite you.** If you typed while it ran, your text wins.
|
|
109
|
+
|
|
110
|
+
## Plan or API key
|
|
111
|
+
|
|
112
|
+
| Engine | Pays with | Setup | Default |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| `plan` | your Claude Code subscription, like any prompt | none | ✓ |
|
|
115
|
+
| `api` | your own Anthropic API key, per rewrite | [a separate opt-in step](docs/API-KEY.md) | |
|
|
116
|
+
|
|
117
|
+
The plan engine calls the model through your Claude Code session's own login
|
|
118
|
+
(`$.model.complete`), so there is no key anywhere in the plugin. Each rewrite is one short
|
|
119
|
+
call: ~3k tokens of rules (cached after the first rewrite) plus your words.
|
|
120
|
+
|
|
121
|
+
The API engine exists for people who would rather pay a fraction of a cent per rewrite than
|
|
122
|
+
spend plan usage. It stays off until you switch it on, the key lives in Claude Code's secure
|
|
123
|
+
storage, and a failed API call never falls back to your plan. Read
|
|
124
|
+
[docs/API-KEY.md](docs/API-KEY.md) — and the ~100 lines of
|
|
125
|
+
[`hooks/engine.ts`](hooks/engine.ts) — before you paste a key in.
|
|
126
|
+
|
|
127
|
+
## Settings
|
|
128
|
+
|
|
129
|
+
`/config` (the unramble rows), or `claude plugin configure unramble`:
|
|
130
|
+
|
|
131
|
+
| Setting | Default | |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| Engine | `plan` | `plan` or `api` |
|
|
134
|
+
| Model | `sonnet` | `sonnet` (fast, light on your plan) or `opus` (the most faithful) |
|
|
135
|
+
| Names you often say | empty | comma-separated names of your tools, agents and projects, so a dictation slip is spelled right |
|
|
136
|
+
| Anthropic API key | empty | read only when Engine is `api` |
|
|
137
|
+
|
|
138
|
+
`/unramble status` shows what is live.
|
|
139
|
+
|
|
140
|
+
## How good is it
|
|
141
|
+
|
|
142
|
+
Measured on 39 of the hardest real dictated prompts, two runs each, with Claude Opus judging
|
|
143
|
+
every rewrite against a fixed rubric ([method and full tables](docs/BENCHMARKS.md)):
|
|
144
|
+
|
|
145
|
+
| Model | Rewrites with no meaning change | Median time |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| Sonnet (default) | 76/78 | 3.0 s |
|
|
148
|
+
| Opus | 78/78 | 3.2 s |
|
|
149
|
+
|
|
150
|
+
That set is every case that ever failed during tuning, so it is harder than everyday speech.
|
|
151
|
+
It is also one person's speech, in English, about software; the [eval kit](eval/README.md)
|
|
152
|
+
runs the same check on yours. Haiku was tried and dropped (9 major errors in 17 prompts).
|
|
153
|
+
|
|
154
|
+
## Where it is tested
|
|
155
|
+
|
|
156
|
+
Ctrl+G reaches the plugin as whatever bytes the terminal sends for it, and terminals send it
|
|
157
|
+
two ways, so both were tried with a real keypress:
|
|
158
|
+
|
|
159
|
+
| Where | Ctrl+G arrives as | Result |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| kitty, macOS | kitty keyboard protocol | rewrite, restore, cancel, `/unramble status` ✓ |
|
|
162
|
+
| Terminal.app, macOS | legacy `0x07` | rewrite and restore ✓ |
|
|
163
|
+
| Ghostty 1.3, macOS | not measured | rewrite and restore ✓ |
|
|
164
|
+
| VS Code terminal, macOS | not measured | rewrite and restore ✓ (Ctrl+G reaches Claude Code, not VS Code's *Go to Line*) |
|
|
165
|
+
| Cursor terminal, macOS | not measured | rewrite and restore ✓ |
|
|
166
|
+
| Linux (Debian, tmux) | legacy `0x07` | key reaches the plugin and the model call goes out ✓ |
|
|
167
|
+
| Windows, Linux, macOS (CI) | — | install, Ctrl+G binding, uninstall and all tests, every push |
|
|
168
|
+
|
|
169
|
+
Not yet tried by hand: iTerm2, Windows Terminal, and Kiro's terminal (Kiro is built on the same
|
|
170
|
+
editor as VS Code and Cursor). If one does not work, please
|
|
171
|
+
[open an issue](https://github.com/yadava5/unramble/issues/new/choose).
|
|
172
|
+
|
|
173
|
+
## Limits
|
|
174
|
+
|
|
175
|
+
- **Terminal UI only.** The Ctrl+G chord reaches plugins in Claude Code's terminal interface
|
|
176
|
+
(`claude` run in a terminal, including the one inside VS Code or Cursor). The VS Code
|
|
177
|
+
extension's own chat panel and the desktop app are not supported.
|
|
178
|
+
- **A short window can hide the band.** In an 80×24 window with a tall status line, Claude Code
|
|
179
|
+
may not draw the `⌃G refine` row; Ctrl+G still works.
|
|
180
|
+
- **Long pastes stay as chips.** When a dictation app pastes a long block, Claude Code folds it
|
|
181
|
+
into a `[Pasted text #N]` chip that plugins cannot read (as of 2.1.308), so that text is kept as
|
|
182
|
+
is. Claude Code's built-in voice input, macOS Dictation and typing all work fully.
|
|
183
|
+
- **Garbled speech stays garbled.** If the transcription is wrong in a way context cannot fix
|
|
184
|
+
("Apple ID" for a project name), the rewrite keeps it as heard rather than guess. Add the
|
|
185
|
+
name to *Names you often say*.
|
|
186
|
+
- **The plugin API is early access.** CI runs the newest Claude Code every day to catch a
|
|
187
|
+
release that moves it.
|
|
188
|
+
|
|
189
|
+
## Update
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
npx unramble update
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Or have Claude Code keep it current: in `/plugin`, open the `unramble` marketplace and turn on
|
|
196
|
+
auto-update. Every change reaches `main` only through a reviewed pull request, so that is
|
|
197
|
+
what auto-update follows.
|
|
198
|
+
|
|
199
|
+
## Uninstall
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
npx unramble uninstall
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
That removes the plugin and takes back only the two `ctrl+g` bindings setup added. Without
|
|
206
|
+
Node: `claude plugin uninstall unramble@unramble`, then delete those two lines from
|
|
207
|
+
`keybindings.json` yourself.
|
|
208
|
+
|
|
209
|
+
## Where it came from
|
|
210
|
+
|
|
211
|
+
I dictate long prompts into Claude Code and wanted them organised before the model reads
|
|
212
|
+
them. It started as a personal `$VISUAL` script on 2026-10-17 and went through eight rule
|
|
213
|
+
versions in two days, each measured before it shipped; the history is in the git log and
|
|
214
|
+
[docs/BENCHMARKS.md](docs/BENCHMARKS.md). This public version is a rebuild that runs on your
|
|
215
|
+
plan instead of an API key. I used Claude Code to build it.
|
|
216
|
+
|
|
217
|
+
## Contributing
|
|
218
|
+
|
|
219
|
+
Reports of a rewrite that changed your meaning are the most useful thing you can send; there
|
|
220
|
+
is an [issue template](https://github.com/yadava5/unramble/issues/new/choose) for it. Pull
|
|
221
|
+
requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
222
|
+
|
|
223
|
+
## Security
|
|
224
|
+
|
|
225
|
+
Every change reaches `main` through a reviewed pull request, releases publish only from a
|
|
226
|
+
maintainer-approved tag with npm provenance, and CI fails any change that widens what the
|
|
227
|
+
shipped code can reach. [SECURITY.md](SECURITY.md) lists exactly what the plugin touches and how
|
|
228
|
+
to report a vulnerability privately.
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
[MIT](LICENSE) © 2026 Ayush Yadav
|
package/cli/unramble.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx unramble [install | update | uninstall | status] — sets the plugin up in one paste.
|
|
3
|
+
//
|
|
4
|
+
// install: claude plugin install unramble --marketplace yadava5/unramble, then binds Ctrl+G
|
|
5
|
+
// in keybindings.json (a backup is kept beside it).
|
|
6
|
+
// update: pulls the newest unramble from its marketplace.
|
|
7
|
+
// uninstall: removes the plugin and takes back only the keybindings install added.
|
|
8
|
+
// status: shows whether the plugin is installed and Ctrl+G is bound.
|
|
9
|
+
//
|
|
10
|
+
// No dependencies. It changes nothing but the plugin install and those two keybindings.
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from 'node:child_process'
|
|
13
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { isAbsolute, join, resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { isBound, parse, plan, serialize, unplan } from '../hooks/keybindings.js'
|
|
18
|
+
|
|
19
|
+
// UNRAMBLE_MARKETPLACE points the installer at a fork or a local checkout (CI uses it). On
|
|
20
|
+
// Windows the argument passes through cmd.exe, so only plain repo names and paths are taken.
|
|
21
|
+
const override = process.env.UNRAMBLE_MARKETPLACE
|
|
22
|
+
if (override && (!/^[\w@./:\\ ~#-]+$/.test(override) || override.startsWith('-'))) {
|
|
23
|
+
process.stderr.write('unramble: UNRAMBLE_MARKETPLACE must be owner/repo or a folder path\n')
|
|
24
|
+
process.exit(1)
|
|
25
|
+
}
|
|
26
|
+
const MARKETPLACE = override ? (existsSync(override) ? resolve(override) : override) : 'yadava5/unramble'
|
|
27
|
+
const PLUGIN = 'unramble'
|
|
28
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude')
|
|
29
|
+
if (!isAbsolute(configDir)) {
|
|
30
|
+
process.stderr.write(`unramble: CLAUDE_CONFIG_DIR must be an absolute path (got ${configDir})\n`)
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
const keybindings = join(configDir, 'keybindings.json')
|
|
34
|
+
const backup = `${keybindings}.unramble-backup`
|
|
35
|
+
|
|
36
|
+
const say = (line = '') => process.stdout.write(`${line}\n`)
|
|
37
|
+
const fail = line => {
|
|
38
|
+
process.stderr.write(`unramble: ${line}\n`)
|
|
39
|
+
process.exit(1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const windows = process.platform === 'win32'
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Runs `claude` with the person's own environment and terminal; true on success. It runs from
|
|
46
|
+
* the home folder: on Windows (where `claude` is a .cmd shim and needs the shell) cmd.exe looks
|
|
47
|
+
* in the current folder before PATH, so a `claude.cmd` in a cloned repo must never be the one run.
|
|
48
|
+
*/
|
|
49
|
+
function claude(args, { quiet = false } = {}) {
|
|
50
|
+
const r = spawnSync('claude', windows ? args.map(a => (a.includes(' ') ? `"${a}"` : a)) : args, {
|
|
51
|
+
cwd: homedir(),
|
|
52
|
+
stdio: quiet ? 'pipe' : 'inherit',
|
|
53
|
+
shell: windows,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
})
|
|
56
|
+
if (r.error?.code === 'ENOENT') {
|
|
57
|
+
fail('Claude Code is not on your PATH. Install it first: https://code.claude.com/docs/en/setup')
|
|
58
|
+
}
|
|
59
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readKeys() {
|
|
63
|
+
const text = existsSync(keybindings) ? readFileSync(keybindings, 'utf8') : null
|
|
64
|
+
try {
|
|
65
|
+
return { text, file: parse(text) }
|
|
66
|
+
} catch (err) {
|
|
67
|
+
fail(`${keybindings} is not valid keybindings JSON (${err.message}); left it alone.`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The first backup is the person's own file; a later install or uninstall never replaces it. */
|
|
72
|
+
function keepBackup(text) {
|
|
73
|
+
if (text !== null && !existsSync(backup)) copyFileSync(keybindings, backup)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bindKeys() {
|
|
77
|
+
const { text, file } = readKeys()
|
|
78
|
+
const next = plan(file)
|
|
79
|
+
if (next.kind === 'ready') return say('✓ Ctrl+G was already bound')
|
|
80
|
+
if (next.kind === 'conflict') {
|
|
81
|
+
say(`! Left keybindings alone: ${next.why}.`)
|
|
82
|
+
say(` Free that chord in ${keybindings}, then run /unramble setup in Claude Code.`)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
keepBackup(text)
|
|
86
|
+
writeFileSync(keybindings, serialize(next.file))
|
|
87
|
+
say(`✓ Bound Ctrl+G in ${keybindings}${text !== null ? ' (backup: keybindings.json.unramble-backup)' : ''}`)
|
|
88
|
+
for (const c of next.changes) say(` ${c}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function install() {
|
|
92
|
+
say(`Installing unramble from ${MARKETPLACE}…`)
|
|
93
|
+
if (!claude(['plugin', 'install', PLUGIN, '--marketplace', MARKETPLACE]).ok) {
|
|
94
|
+
fail('the plugin install did not finish; nothing else was changed.')
|
|
95
|
+
}
|
|
96
|
+
bindKeys()
|
|
97
|
+
say()
|
|
98
|
+
say('Done. Start a new Claude Code session, speak or type a rough prompt, and press Ctrl+G.')
|
|
99
|
+
say('It runs on your Claude Code plan. Settings: /config (unramble rows) or /unramble status.')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function uninstall() {
|
|
103
|
+
const removed = claude(['plugin', 'uninstall', `${PLUGIN}@${PLUGIN}`]).ok
|
|
104
|
+
if (removed) claude(['plugin', 'marketplace', 'remove', PLUGIN], { quiet: true })
|
|
105
|
+
const { text, file } = readKeys()
|
|
106
|
+
const back = unplan(file)
|
|
107
|
+
if (back.changes.length) {
|
|
108
|
+
keepBackup(text)
|
|
109
|
+
writeFileSync(keybindings, serialize(back.file))
|
|
110
|
+
say(`✓ Took back the Ctrl+G bindings in ${keybindings}`)
|
|
111
|
+
}
|
|
112
|
+
say(removed ? '✓ unramble is uninstalled' : '! The plugin was not installed (or the uninstall failed); see above.')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function update() {
|
|
116
|
+
claude(['plugin', 'marketplace', 'update', PLUGIN], { quiet: true })
|
|
117
|
+
if (!claude(['plugin', 'update', `${PLUGIN}@${PLUGIN}`]).ok) {
|
|
118
|
+
fail('the update did not finish; is unramble installed? Try: npx unramble')
|
|
119
|
+
}
|
|
120
|
+
say('✓ unramble is up to date. Start a new Claude Code session to load it.')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function status() {
|
|
124
|
+
const listed = claude(['plugin', 'list'], { quiet: true })
|
|
125
|
+
const installed = new RegExp(`\\b${PLUGIN}@`).test(listed.out)
|
|
126
|
+
say(`Plugin: ${installed ? 'installed' : 'not installed'}`)
|
|
127
|
+
say(`Ctrl+G: ${isBound(readKeys().file) ? 'bound' : 'not bound'} (${keybindings})`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const command = process.argv[2] ?? 'install'
|
|
131
|
+
const commands = { install, update, uninstall, status }
|
|
132
|
+
if (command === '--help' || command === '-h' || !(command in commands)) {
|
|
133
|
+
say('Usage: npx unramble [install | update | uninstall | status]')
|
|
134
|
+
process.exit(command in commands || command.startsWith('-') ? 0 : 1)
|
|
135
|
+
}
|
|
136
|
+
commands[command]()
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Ctrl+G reaches a plugin only through a band Button naming an engine keybinding action that
|
|
3
|
+
// no engine handler holds while the prompt is focused, and only when that chord is bound in the
|
|
4
|
+
// Global context. So setup binds Global ctrl+g to app:cycleDiffBase (handled only while the diff
|
|
5
|
+
// panel is open, where it keeps working) and unbinds Chat ctrl+g (by default the external
|
|
6
|
+
// editor, which stays on ctrl+x ctrl+e). A chord the person bound to something else is theirs:
|
|
7
|
+
// setup reports the conflict and changes nothing.
|
|
8
|
+
//
|
|
9
|
+
// Plain JavaScript on purpose: the plugin imports it, and so does the npx installer, which runs
|
|
10
|
+
// from a package cache where Node will not strip TypeScript.
|
|
11
|
+
|
|
12
|
+
export const ACTION = 'app:cycleDiffBase'
|
|
13
|
+
export const CHORD = 'ctrl+g'
|
|
14
|
+
const EDITOR = 'chat:externalEditor'
|
|
15
|
+
const EDITOR_CHORD = 'ctrl+x ctrl+e'
|
|
16
|
+
|
|
17
|
+
/** @typedef {Record<string, string | null>} Bindings */
|
|
18
|
+
/** @typedef {{ context: string, bindings: Bindings }} Block */
|
|
19
|
+
/** @typedef {{ bindings: Block[], [key: string]: unknown }} KeybindingsFile */
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{ kind: 'ready' }
|
|
22
|
+
* | { kind: 'change', file: KeybindingsFile, changes: string[] }
|
|
23
|
+
* | { kind: 'conflict', why: string }} SetupPlan
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @type {KeybindingsFile} */
|
|
27
|
+
const SKELETON = {
|
|
28
|
+
$schema: 'https://www.schemastore.org/claude-code-keybindings.json',
|
|
29
|
+
$docs: 'https://code.claude.com/docs/en/keybindings',
|
|
30
|
+
bindings: [],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {KeybindingsFile} file @returns {KeybindingsFile} */
|
|
34
|
+
const clone = file => JSON.parse(JSON.stringify(file))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Parses keybindings.json text; an absent file is an empty one. Throws on anything else.
|
|
38
|
+
* @param {string | null} text
|
|
39
|
+
* @returns {KeybindingsFile}
|
|
40
|
+
*/
|
|
41
|
+
export function parse(text) {
|
|
42
|
+
if (text === null || !text.trim()) return clone(SKELETON)
|
|
43
|
+
const data = JSON.parse(text)
|
|
44
|
+
if (!data || typeof data !== 'object' || !Array.isArray(data.bindings)) {
|
|
45
|
+
throw new Error('keybindings.json has no "bindings" list')
|
|
46
|
+
}
|
|
47
|
+
return data
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {KeybindingsFile} file @param {string} context @returns {Block | undefined} */
|
|
51
|
+
const blockOf = (file, context) =>
|
|
52
|
+
file.bindings.find(b => b && b.context === context && b.bindings && typeof b.bindings === 'object')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether Ctrl+G already reaches unramble.
|
|
56
|
+
* @param {KeybindingsFile} file
|
|
57
|
+
*/
|
|
58
|
+
export function isBound(file) {
|
|
59
|
+
return blockOf(file, 'Global')?.bindings[CHORD] === ACTION && blockOf(file, 'Chat')?.bindings[CHORD] === null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What setup would do to this file, without doing it.
|
|
64
|
+
* @param {KeybindingsFile} current
|
|
65
|
+
* @returns {SetupPlan}
|
|
66
|
+
*/
|
|
67
|
+
export function plan(current) {
|
|
68
|
+
if (isBound(current)) return { kind: 'ready' }
|
|
69
|
+
const globalNow = blockOf(current, 'Global')?.bindings[CHORD]
|
|
70
|
+
if (globalNow !== undefined && globalNow !== ACTION) {
|
|
71
|
+
return { kind: 'conflict', why: `ctrl+g is bound to "${globalNow}" in your Global keybindings` }
|
|
72
|
+
}
|
|
73
|
+
const chatNow = blockOf(current, 'Chat')?.bindings[CHORD]
|
|
74
|
+
if (chatNow !== undefined && chatNow !== null && chatNow !== EDITOR) {
|
|
75
|
+
return { kind: 'conflict', why: `ctrl+g is bound to "${chatNow}" in your Chat keybindings` }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const file = clone(current)
|
|
79
|
+
/** @type {string[]} */
|
|
80
|
+
const changes = []
|
|
81
|
+
/** @param {string} context @returns {Block} */
|
|
82
|
+
const ensure = context => {
|
|
83
|
+
let block = blockOf(file, context)
|
|
84
|
+
if (!block) {
|
|
85
|
+
block = { context, bindings: {} }
|
|
86
|
+
file.bindings.push(block)
|
|
87
|
+
}
|
|
88
|
+
return block
|
|
89
|
+
}
|
|
90
|
+
const global = ensure('Global')
|
|
91
|
+
if (global.bindings[CHORD] !== ACTION) {
|
|
92
|
+
global.bindings[CHORD] = ACTION
|
|
93
|
+
changes.push(`Global: ctrl+g → ${ACTION} (lets the unramble band take Ctrl+G)`)
|
|
94
|
+
}
|
|
95
|
+
const chat = ensure('Chat')
|
|
96
|
+
if (chat.bindings[CHORD] !== null) {
|
|
97
|
+
chat.bindings[CHORD] = null
|
|
98
|
+
changes.push('Chat: ctrl+g → unbound (it opened the external editor)')
|
|
99
|
+
}
|
|
100
|
+
if (!Object.values(chat.bindings).includes(EDITOR) && chat.bindings[EDITOR_CHORD] === undefined) {
|
|
101
|
+
chat.bindings[EDITOR_CHORD] = EDITOR
|
|
102
|
+
changes.push(`Chat: ${EDITOR_CHORD} → ${EDITOR} (the external editor keeps a key)`)
|
|
103
|
+
}
|
|
104
|
+
return { kind: 'change', file, changes }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Takes back exactly what setup added, and nothing the person set themselves: Global ctrl+g
|
|
109
|
+
* only while it still names unramble's action, Chat ctrl+g only while it is still unbound.
|
|
110
|
+
* The ctrl+x ctrl+e editor key stays; it is Claude Code's own action.
|
|
111
|
+
* @param {KeybindingsFile} current
|
|
112
|
+
* @returns {{ file: KeybindingsFile, changes: string[] }}
|
|
113
|
+
*/
|
|
114
|
+
export function unplan(current) {
|
|
115
|
+
const file = clone(current)
|
|
116
|
+
/** @type {string[]} */
|
|
117
|
+
const changes = []
|
|
118
|
+
const global = blockOf(file, 'Global')
|
|
119
|
+
if (global?.bindings[CHORD] === ACTION) {
|
|
120
|
+
delete global.bindings[CHORD]
|
|
121
|
+
changes.push('Global: ctrl+g removed')
|
|
122
|
+
}
|
|
123
|
+
const chat = blockOf(file, 'Chat')
|
|
124
|
+
if (chat && chat.bindings[CHORD] === null) {
|
|
125
|
+
delete chat.bindings[CHORD]
|
|
126
|
+
changes.push('Chat: ctrl+g back to its default (the external editor)')
|
|
127
|
+
}
|
|
128
|
+
return { file, changes }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {KeybindingsFile} file */
|
|
132
|
+
export const serialize = file => `${JSON.stringify(file, null, 2)}\n`
|
package/package.json
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "unramble",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Ctrl+G turns a rambling, dictated prompt into a clean, ordered one, in place, on your own Claude Code plan.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude-code-plugin",
|
|
8
|
+
"dictation",
|
|
9
|
+
"voice",
|
|
10
|
+
"prompt",
|
|
11
|
+
"rewrite"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/yadava5/unramble#readme",
|
|
14
|
+
"bugs": "https://github.com/yadava5/unramble/issues",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/yadava5/unramble.git"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Ayush Yadav (https://github.com/yadava5)",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"unramble": "cli/unramble.mjs"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"cli/unramble.mjs",
|
|
27
|
+
"hooks/keybindings.js"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node scripts/guard.mjs && claude plugin validate . && claude plugin test . && node --test cli/*.test.mjs scripts/*.test.mjs",
|
|
34
|
+
"test:cli": "node --test cli/*.test.mjs",
|
|
35
|
+
"guard": "node scripts/guard.mjs"
|
|
36
|
+
}
|
|
37
|
+
}
|