spell-cli 0.1.2

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 Ali
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 ADDED
@@ -0,0 +1,77 @@
1
+ # spell
2
+
3
+ `spell` is a tiny local command-line helper that prompt you the correct spelling of the word.
4
+
5
+ I built it for local use for me instead of going the my browser consitinilty: type a word, get the corrected version, and optionally copy it to your clipboard.
6
+
7
+ ## What it does
8
+
9
+ - Corrects one word at a time
10
+ - Keeps working when the word is already correct
11
+ - Can show a short definition with `-d`
12
+ - Supports clipboard copy on Linux and macOS
13
+ - Lets you save clipboard behavior as a default (`--copy-default on|off`)
14
+
15
+ ## Install
16
+
17
+ ### npm (recommended)
18
+
19
+ ```bash
20
+ npm install -g spell-cli
21
+ ```
22
+
23
+ Then run:
24
+
25
+ ```bash
26
+ spell appertunity
27
+ # opportunity
28
+ ```
29
+
30
+ ### From source (GitHub)
31
+
32
+ ```bash
33
+ git clone https://github.com/alisufayan/spell-cli.git
34
+ cd spell
35
+ npm install
36
+ npm link
37
+ ```
38
+
39
+ `npm link` makes `spell` available globally on your machine.
40
+
41
+ ## Usage
42
+
43
+ ```bash
44
+ # basic correction
45
+ spell appertunity
46
+ # opportunity
47
+
48
+ # already-correct words still work
49
+ spell extraordinary
50
+ # extraordinary
51
+
52
+ # definition mode
53
+ spell -d extordinary
54
+ # extraordinary
55
+ # adjective
56
+ # 1.
57
+ # very unusual or remarkable
58
+
59
+ # one-time clipboard behavior
60
+ spell --copy opportunity
61
+ spell --no-copy opportunity
62
+
63
+ # persistent default clipboard behavior
64
+ spell --copy-default on
65
+ spell --copy-default off
66
+ ```
67
+
68
+ ## Notes
69
+
70
+ - `-d` uses `dictionaryapi.dev` only when definition mode is requested.
71
+ - Clipboard commands:
72
+ - macOS: `pbcopy`
73
+ - Linux: `wl-copy`, fallback to `xclip`, fallback to `xsel`
74
+
75
+ ## Roadmap
76
+
77
+ - Homebrew install support
package/bin/spell.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { parseArgs, printHelp } = require("../src/args");
5
+ const { readConfig, writeConfig } = require("../src/config");
6
+ const { loadSpeller, correctWord } = require("../src/speller");
7
+ const { copyToClipboard } = require("../src/clipboard");
8
+ const { fetchDefinition } = require("../src/dictionary");
9
+ const packageJson = require("../package.json");
10
+
11
+ async function main() {
12
+ try {
13
+ const parsed = parseArgs(process.argv.slice(2));
14
+
15
+ if (parsed.action === "help") {
16
+ printHelp();
17
+ process.exit(parsed.exitCode);
18
+ }
19
+
20
+ if (parsed.action === "version") {
21
+ console.log(packageJson.version);
22
+ return;
23
+ }
24
+
25
+ if (parsed.action === "set-copy-default") {
26
+ const current = readConfig();
27
+ current.copy = parsed.value === "on";
28
+ writeConfig(current);
29
+ console.log(`Default clipboard copy is ${parsed.value}.`);
30
+ return;
31
+ }
32
+
33
+ const loaded = await loadSpeller();
34
+ const corrected = correctWord(loaded.speller, loaded.words, parsed.word);
35
+ console.log(corrected);
36
+
37
+ const config = readConfig();
38
+ const shouldCopy = parsed.copyOverride !== undefined ? parsed.copyOverride : Boolean(config.copy);
39
+ if (shouldCopy) {
40
+ const copied = copyToClipboard(corrected);
41
+ if (!copied.ok) {
42
+ console.error(`Warning: ${copied.reason}`);
43
+ }
44
+ }
45
+
46
+ if (parsed.define) {
47
+ const definition = await fetchDefinition(corrected);
48
+ if (!definition) {
49
+ console.error("Definition not found.");
50
+ return;
51
+ }
52
+
53
+ console.log(definition.partOfSpeech);
54
+ definition.definitions.forEach((def, i) => {
55
+ console.log(`${i + 1}. ${def}`);
56
+ });
57
+ }
58
+ } catch (error) {
59
+ console.error(error.message || String(error));
60
+ process.exitCode = 1;
61
+ }
62
+ }
63
+
64
+ main();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "spell-cli",
3
+ "version": "0.1.2",
4
+ "description": "Simple local spelling CLI with optional definitions and clipboard copy",
5
+ "bin": {
6
+ "spell": "bin/spell.js"
7
+ },
8
+ "files": [
9
+ "bin",
10
+ "src"
11
+ ],
12
+ "type": "commonjs",
13
+ "scripts": {
14
+ "start": "node bin/spell.js"
15
+ },
16
+ "keywords": [
17
+ "spell",
18
+ "spelling",
19
+ "cli",
20
+ "dictionary"
21
+ ],
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "dictionary-en": "^4.0.0",
25
+ "nspell": "^2.1.5"
26
+ }
27
+ }
package/src/args.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+
3
+ const HELP_TEXT = `spell - tiny local spelling helper
4
+
5
+ Usage:
6
+ spell <word>
7
+ spell -d <word>
8
+ spell -c <word>
9
+ spell --no-copy <word>
10
+ spell --copy-default on
11
+ spell --copy-default off
12
+
13
+ Options:
14
+ -d, --define Show part of speech and first definition
15
+ -c, --copy Copy corrected word to clipboard for this run
16
+ --no-copy Disable clipboard copy for this run
17
+ --copy-default Set persistent clipboard default (on/off)
18
+ -h, --help Show help
19
+ -v, --version Show version`;
20
+
21
+ function printHelp() {
22
+ console.log(HELP_TEXT);
23
+ }
24
+
25
+ function parseArgs(args) {
26
+ if (args.length === 0) {
27
+ return { action: "help", exitCode: 1 };
28
+ }
29
+
30
+ if (args.length === 1 && (args[0] === "-h" || args[0] === "--help")) {
31
+ return { action: "help", exitCode: 0 };
32
+ }
33
+
34
+ if (args.length === 1 && (args[0] === "-v" || args[0] === "--version")) {
35
+ return { action: "version" };
36
+ }
37
+
38
+ if (args[0] === "--copy-default") {
39
+ if (args.length !== 2 || (args[1] !== "on" && args[1] !== "off")) {
40
+ throw new Error("Usage: spell --copy-default on|off");
41
+ }
42
+
43
+ return { action: "set-copy-default", value: args[1] };
44
+ }
45
+
46
+ let define = false;
47
+ let copyOverride;
48
+ let word;
49
+
50
+ for (const arg of args) {
51
+ if (arg === "-d" || arg === "--define") {
52
+ define = true;
53
+ continue;
54
+ }
55
+
56
+ if (arg === "-c" || arg === "--copy") {
57
+ copyOverride = true;
58
+ continue;
59
+ }
60
+
61
+ if (arg === "--no-copy") {
62
+ copyOverride = false;
63
+ continue;
64
+ }
65
+
66
+ if (arg === "-h" || arg === "--help") {
67
+ return { action: "help", exitCode: 0 };
68
+ }
69
+
70
+ if (arg === "-v" || arg === "--version") {
71
+ return { action: "version" };
72
+ }
73
+
74
+ if (arg.startsWith("-")) {
75
+ throw new Error(`Unknown option: ${arg}`);
76
+ }
77
+
78
+ if (!word) {
79
+ word = arg;
80
+ continue;
81
+ }
82
+
83
+ throw new Error("Please provide only one word at a time.");
84
+ }
85
+
86
+ if (!word) {
87
+ throw new Error("Usage: spell <word>");
88
+ }
89
+
90
+ return {
91
+ action: "lookup",
92
+ word,
93
+ define,
94
+ copyOverride
95
+ };
96
+ }
97
+
98
+ module.exports = {
99
+ printHelp,
100
+ parseArgs
101
+ };
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+
3
+ const { spawnSync } = require("child_process");
4
+
5
+ function copyToClipboard(text) {
6
+ const candidates = [];
7
+
8
+ if (process.platform === "darwin") {
9
+ candidates.push({ command: "pbcopy", args: [] });
10
+ } else if (process.platform === "linux") {
11
+ candidates.push(
12
+ { command: "wl-copy", args: [] },
13
+ { command: "xclip", args: ["-selection", "clipboard"] },
14
+ { command: "xsel", args: ["--clipboard", "--input"] }
15
+ );
16
+ } else {
17
+ return {
18
+ ok: false,
19
+ reason: "Clipboard copy is only supported on Linux and macOS."
20
+ };
21
+ }
22
+
23
+ for (const candidate of candidates) {
24
+ const result = spawnSync(candidate.command, candidate.args, {
25
+ input: text,
26
+ encoding: "utf8",
27
+ stdio: ["pipe", "ignore", "ignore"],
28
+ timeout: 2000
29
+ });
30
+
31
+ if (!result.error && result.status === 0) {
32
+ return { ok: true, command: candidate.command };
33
+ }
34
+ }
35
+
36
+ return {
37
+ ok: false,
38
+ reason: "No clipboard command worked (tried wl-copy/xclip/xsel or pbcopy)."
39
+ };
40
+ }
41
+
42
+ module.exports = {
43
+ copyToClipboard
44
+ };
package/src/config.js ADDED
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const DEFAULT_CONFIG = { copy: false };
8
+
9
+ function getConfigDir() {
10
+ if (process.platform === "darwin") {
11
+ return path.join(os.homedir(), "Library", "Application Support", "spell");
12
+ }
13
+
14
+ const baseDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
15
+ return path.join(baseDir, "spell");
16
+ }
17
+
18
+ function getConfigPath() {
19
+ return path.join(getConfigDir(), "config.json");
20
+ }
21
+
22
+ function readConfig() {
23
+ try {
24
+ const raw = fs.readFileSync(getConfigPath(), "utf8");
25
+ const parsed = JSON.parse(raw);
26
+ return { ...DEFAULT_CONFIG, ...parsed };
27
+ } catch (error) {
28
+ if (error.code !== "ENOENT") {
29
+ console.error("Warning: config is invalid, using defaults.");
30
+ }
31
+ return { ...DEFAULT_CONFIG };
32
+ }
33
+ }
34
+
35
+ function writeConfig(config) {
36
+ fs.mkdirSync(getConfigDir(), { recursive: true });
37
+ fs.writeFileSync(
38
+ getConfigPath(),
39
+ `${JSON.stringify({ copy: Boolean(config.copy) }, null, 2)}\n`,
40
+ "utf8"
41
+ );
42
+ }
43
+
44
+ module.exports = {
45
+ readConfig,
46
+ writeConfig
47
+ };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+
3
+ async function fetchDefinition(word) {
4
+ const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word.toLowerCase())}`;
5
+ const controller = new AbortController();
6
+ const timer = setTimeout(() => controller.abort(), 5000);
7
+
8
+ try {
9
+ const response = await fetch(url, { signal: controller.signal });
10
+ if (!response.ok) {
11
+ return null;
12
+ }
13
+
14
+ const entries = await response.json();
15
+ if (!Array.isArray(entries)) {
16
+ return null;
17
+ }
18
+
19
+ for (const entry of entries) {
20
+ if (!entry || !Array.isArray(entry.meanings)) {
21
+ continue;
22
+ }
23
+
24
+ for (const meaning of entry.meanings) {
25
+ if (!meaning || !Array.isArray(meaning.definitions) || meaning.definitions.length === 0) {
26
+ continue;
27
+ }
28
+
29
+ const defs = meaning.definitions;
30
+ const selected = defs.length > 1 ? [defs[0], defs[1]] : [defs[0]];
31
+ const valid = selected.filter(d => d && typeof d.definition === "string");
32
+ if (valid.length === 0) {
33
+ continue;
34
+ }
35
+
36
+ return {
37
+ partOfSpeech: meaning.partOfSpeech || "unknown",
38
+ definitions: valid.map(d => d.definition.trim())
39
+ };
40
+ }
41
+ }
42
+ } catch {
43
+ return null;
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ module.exports = {
52
+ fetchDefinition
53
+ };
package/src/speller.js ADDED
@@ -0,0 +1,132 @@
1
+ "use strict";
2
+
3
+ const nspell = require("nspell");
4
+
5
+ async function loadSpeller() {
6
+ const module = await import("dictionary-en");
7
+ const dictionary = module.default;
8
+ return {
9
+ speller: nspell(dictionary),
10
+ words: parseWordList(dictionary.dic)
11
+ };
12
+ }
13
+
14
+ function parseWordList(dicBuffer) {
15
+ const text = Buffer.from(dicBuffer).toString("utf8");
16
+ const lines = text.split(/\r?\n/);
17
+ const words = new Set();
18
+
19
+ for (let index = 1; index < lines.length; index += 1) {
20
+ const line = lines[index].trim();
21
+ if (!line || line.startsWith("#")) {
22
+ continue;
23
+ }
24
+
25
+ const slashIndex = line.indexOf("/");
26
+ const word = slashIndex >= 0 ? line.slice(0, slashIndex) : line;
27
+ if (word) {
28
+ words.add(word);
29
+ }
30
+ }
31
+
32
+ return Array.from(words);
33
+ }
34
+
35
+ function levenshteinDistance(a, b, maxDistance) {
36
+ if (Math.abs(a.length - b.length) > maxDistance) {
37
+ return maxDistance + 1;
38
+ }
39
+
40
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
41
+
42
+ for (let i = 1; i <= a.length; i += 1) {
43
+ const current = [i];
44
+ let smallest = current[0];
45
+
46
+ for (let j = 1; j <= b.length; j += 1) {
47
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
48
+ const value = Math.min(
49
+ previous[j] + 1,
50
+ current[j - 1] + 1,
51
+ previous[j - 1] + cost
52
+ );
53
+ current[j] = value;
54
+ if (value < smallest) {
55
+ smallest = value;
56
+ }
57
+ }
58
+
59
+ if (smallest > maxDistance) {
60
+ return maxDistance + 1;
61
+ }
62
+
63
+ previous = current;
64
+ }
65
+
66
+ return previous[b.length];
67
+ }
68
+
69
+ function findClosestWord(input, words) {
70
+ const lowerInput = input.toLowerCase();
71
+ const minLength = Math.max(1, lowerInput.length - 2);
72
+ const maxLength = lowerInput.length + 2;
73
+ const maxDistance = 2;
74
+
75
+ let bestWord = null;
76
+ let bestDistance = maxDistance + 1;
77
+
78
+ for (const candidate of words) {
79
+ const lowerCandidate = candidate.toLowerCase();
80
+ if (lowerCandidate.length < minLength || lowerCandidate.length > maxLength) {
81
+ continue;
82
+ }
83
+
84
+ const distance = levenshteinDistance(lowerInput, lowerCandidate, maxDistance);
85
+ if (distance < bestDistance) {
86
+ bestDistance = distance;
87
+ bestWord = candidate;
88
+ if (distance === 1) {
89
+ break;
90
+ }
91
+ }
92
+ }
93
+
94
+ return bestWord;
95
+ }
96
+
97
+ function preserveCase(source, target) {
98
+ if (source.toUpperCase() === source) {
99
+ return target.toUpperCase();
100
+ }
101
+
102
+ const firstChar = source.charAt(0);
103
+ const rest = source.slice(1);
104
+ if (firstChar === firstChar.toUpperCase() && rest === rest.toLowerCase()) {
105
+ return `${target.charAt(0).toUpperCase()}${target.slice(1)}`;
106
+ }
107
+
108
+ return target;
109
+ }
110
+
111
+ function correctWord(speller, words, word) {
112
+ const input = word.trim();
113
+ if (speller.correct(input)) {
114
+ return input;
115
+ }
116
+
117
+ const suggestions = speller.suggest(input);
118
+ if (suggestions.length === 0) {
119
+ const fallback = findClosestWord(input, words);
120
+ if (!fallback) {
121
+ return input;
122
+ }
123
+ return preserveCase(input, fallback);
124
+ }
125
+
126
+ return preserveCase(input, suggestions[0]);
127
+ }
128
+
129
+ module.exports = {
130
+ loadSpeller,
131
+ correctWord
132
+ };