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 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
- # Temporary Holding Version
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
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }