summon-open 1.0.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/cli.js +275 -0
- package/completions/summon.bash +37 -0
- package/completions/summon.fish +20 -0
- package/completions/summon.ps1 +30 -0
- package/completions/summon.zsh +38 -0
- package/lib/config.js +127 -0
- package/lib/picker.js +43 -0
- package/lib/resolve.js +198 -0
- package/lib/reveal.js +52 -0
- package/license +9 -0
- package/package.json +77 -0
- package/readme.md +654 -0
package/cli.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import {existsSync} from 'node:fs';
|
|
4
|
+
import * as streamConsumers from 'node:stream/consumers';
|
|
5
|
+
import meow from 'meow';
|
|
6
|
+
import open from 'open';
|
|
7
|
+
import clipboard from 'clipboardy';
|
|
8
|
+
import {temporaryWrite} from 'tempy';
|
|
9
|
+
import {fileTypeFromBuffer} from 'file-type';
|
|
10
|
+
import {
|
|
11
|
+
readConfig,
|
|
12
|
+
readBookmarks,
|
|
13
|
+
saveBookmark,
|
|
14
|
+
removeBookmark,
|
|
15
|
+
readHistory,
|
|
16
|
+
addHistory,
|
|
17
|
+
} from './lib/config.js';
|
|
18
|
+
import {
|
|
19
|
+
isUrl,
|
|
20
|
+
looksLikeDomain,
|
|
21
|
+
normalizeTarget,
|
|
22
|
+
expandBookmark,
|
|
23
|
+
resolveTarget,
|
|
24
|
+
buildSearchUrl,
|
|
25
|
+
SummonError,
|
|
26
|
+
} from './lib/resolve.js';
|
|
27
|
+
import {revealInFileManager} from './lib/reveal.js';
|
|
28
|
+
import {pick} from './lib/picker.js';
|
|
29
|
+
|
|
30
|
+
const cli = meow(`
|
|
31
|
+
Usage
|
|
32
|
+
$ summon <file|url|@bookmark> … [options] [-- <app> [args]]
|
|
33
|
+
$ cat <file> | summon [--extension] [options] [-- <app> [args]]
|
|
34
|
+
|
|
35
|
+
Options
|
|
36
|
+
--wait, -w Wait for the app to exit
|
|
37
|
+
--background Do not bring the app to the foreground (macOS only)
|
|
38
|
+
--extension File extension for when stdin file type cannot be detected
|
|
39
|
+
--dry-run, -n Print what would be opened without opening it
|
|
40
|
+
--search, -s Treat the input as a search query
|
|
41
|
+
--clipboard, -c Open the URL/path currently on the clipboard
|
|
42
|
+
--reveal, -r Reveal the file/folder in your file manager
|
|
43
|
+
--recent Pick from recently opened items
|
|
44
|
+
--save <name> Save the given target as a bookmark (does not open)
|
|
45
|
+
--remove-bookmark Remove a saved bookmark by name
|
|
46
|
+
--bookmarks List saved bookmarks
|
|
47
|
+
|
|
48
|
+
Examples
|
|
49
|
+
$ summon https://sindresorhus.com
|
|
50
|
+
$ summon github.com # scheme added automatically
|
|
51
|
+
$ summon report.pdf photo.png notes.txt
|
|
52
|
+
$ summon https://github.com -- 'google chrome' --incognito
|
|
53
|
+
$ summon @docs # open a saved bookmark
|
|
54
|
+
$ summon https://docs.example.com --save docs
|
|
55
|
+
$ summon -s "rust async traits"
|
|
56
|
+
$ echo '<h1>Hi</h1>' | summon --extension=html
|
|
57
|
+
$ summon report.pdf --reveal
|
|
58
|
+
$ summon --recent
|
|
59
|
+
`, {
|
|
60
|
+
importMeta: import.meta,
|
|
61
|
+
flags: {
|
|
62
|
+
wait: {type: 'boolean', default: false, shortFlag: 'w'},
|
|
63
|
+
background: {type: 'boolean', default: false},
|
|
64
|
+
extension: {type: 'string'},
|
|
65
|
+
dryRun: {type: 'boolean', default: false, shortFlag: 'n'},
|
|
66
|
+
search: {type: 'boolean', default: false, shortFlag: 's'},
|
|
67
|
+
clipboard: {type: 'boolean', default: false, shortFlag: 'c'},
|
|
68
|
+
reveal: {type: 'boolean', default: false, shortFlag: 'r'},
|
|
69
|
+
recent: {type: 'boolean', default: false},
|
|
70
|
+
save: {type: 'string'},
|
|
71
|
+
removeBookmark: {type: 'string'},
|
|
72
|
+
bookmarks: {type: 'boolean', default: false},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const {flags} = cli;
|
|
77
|
+
|
|
78
|
+
// Split off a trailing `-- <app> [args]` section from the raw arguments so it
|
|
79
|
+
// never gets confused with the (now possibly multiple) targets.
|
|
80
|
+
const rawArguments = process.argv.slice(2);
|
|
81
|
+
const separatorIndex = rawArguments.indexOf('--');
|
|
82
|
+
const appTokens = separatorIndex === -1 ? [] : rawArguments.slice(separatorIndex + 1);
|
|
83
|
+
const targets = appTokens.length > 0
|
|
84
|
+
? cli.input.slice(0, cli.input.length - appTokens.length)
|
|
85
|
+
: cli.input;
|
|
86
|
+
|
|
87
|
+
const openOptions = {wait: flags.wait, background: flags.background};
|
|
88
|
+
if (appTokens.length > 0) {
|
|
89
|
+
const [name, ...appArguments] = appTokens;
|
|
90
|
+
openOptions.app = {name, arguments: appArguments};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const stderr = message => process.stderr.write(`${message}\n`);
|
|
94
|
+
const stdout = message => process.stdout.write(`${message}\n`);
|
|
95
|
+
|
|
96
|
+
async function main() {
|
|
97
|
+
// --- Bookmark management (these never open anything) ---
|
|
98
|
+
if (flags.bookmarks) {
|
|
99
|
+
listBookmarks();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (flags.removeBookmark !== undefined) {
|
|
104
|
+
const removed = removeBookmark(flags.removeBookmark);
|
|
105
|
+
if (!removed) {
|
|
106
|
+
throw new SummonError(`No such bookmark: ${flags.removeBookmark}`, 3);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stdout(`Removed bookmark: ${flags.removeBookmark}`);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (flags.save !== undefined) {
|
|
114
|
+
if (targets.length === 0) {
|
|
115
|
+
throw new SummonError('Specify a target to save, e.g. `summon https://example.com --save name`', 4);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const value = resolveTarget(targets[0], readBookmarks());
|
|
119
|
+
saveBookmark(flags.save, value);
|
|
120
|
+
stdout(`Saved bookmark ${flags.save} → ${value}`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Figure out what to open ---
|
|
125
|
+
const bookmarks = readBookmarks();
|
|
126
|
+
let rawInputs;
|
|
127
|
+
let stdinMode = false;
|
|
128
|
+
|
|
129
|
+
if (flags.clipboard) {
|
|
130
|
+
const clipboardText = await clipboard.read();
|
|
131
|
+
const text = clipboardText.trim();
|
|
132
|
+
if (!text) {
|
|
133
|
+
throw new SummonError('Clipboard is empty', 4);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
rawInputs = text.split(/\s+/v).filter(Boolean);
|
|
137
|
+
} else if (flags.search) {
|
|
138
|
+
if (targets.length === 0) {
|
|
139
|
+
throw new SummonError('Provide a search query, e.g. `summon -s "hello world"`', 4);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
rawInputs = [buildSearchUrl(targets.join(' '), readConfig().searchEngine)];
|
|
143
|
+
} else if (flags.recent) {
|
|
144
|
+
const chosen = await pick('Recently opened', readHistory().map(item => ({label: item, value: item})));
|
|
145
|
+
if (!chosen) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
rawInputs = [chosen];
|
|
150
|
+
} else if (targets.length > 0) {
|
|
151
|
+
rawInputs = targets;
|
|
152
|
+
} else if (process.stdin.isTTY) {
|
|
153
|
+
const chosen = await interactivePicker(bookmarks);
|
|
154
|
+
if (!chosen) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
rawInputs = [chosen];
|
|
159
|
+
} else {
|
|
160
|
+
stdinMode = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (stdinMode) {
|
|
164
|
+
await handleStdin();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- Resolve and act ---
|
|
169
|
+
const expanded = rawInputs.map(input => expandBookmark(input, bookmarks));
|
|
170
|
+
|
|
171
|
+
if (flags.reveal) {
|
|
172
|
+
for (const target of expanded) {
|
|
173
|
+
if (flags.dryRun) {
|
|
174
|
+
stdout(`[dry-run] reveal: ${target}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
await revealInFileManager(target); // eslint-disable-line no-await-in-loop
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const input of expanded) {
|
|
185
|
+
await openOne(input); // eslint-disable-line no-await-in-loop
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Prefer an existing file over domain normalization, then URLs, then bare domains.
|
|
190
|
+
function classify(input) {
|
|
191
|
+
if (existsSync(input)) {
|
|
192
|
+
return {target: input};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (isUrl(input)) {
|
|
196
|
+
return {target: input};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (looksLikeDomain(input)) {
|
|
200
|
+
return {target: normalizeTarget(input)};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {target: input, missing: true};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function openOne(input) {
|
|
207
|
+
const {target, missing} = classify(input);
|
|
208
|
+
if (missing) {
|
|
209
|
+
throw new SummonError(`File or URL not found: ${input}`, 2);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (flags.dryRun) {
|
|
213
|
+
const via = openOptions.app ? ` (via ${openOptions.app.name})` : '';
|
|
214
|
+
stdout(`[dry-run] open: ${target}${via}`);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await open(target, openOptions);
|
|
219
|
+
addHistory(target);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function handleStdin() {
|
|
223
|
+
const buffer = await streamConsumers.buffer(process.stdin);
|
|
224
|
+
const type = await fileTypeFromBuffer(buffer);
|
|
225
|
+
const extension = flags.extension ?? type?.ext ?? 'txt';
|
|
226
|
+
|
|
227
|
+
if (flags.dryRun) {
|
|
228
|
+
stdout(`[dry-run] open piped stdin as .${extension} file`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const filePath = await temporaryWrite(buffer, {extension});
|
|
233
|
+
await open(filePath, openOptions);
|
|
234
|
+
addHistory(filePath);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function listBookmarks() {
|
|
238
|
+
const bookmarks = readBookmarks();
|
|
239
|
+
const names = Object.keys(bookmarks);
|
|
240
|
+
|
|
241
|
+
if (names.length === 0) {
|
|
242
|
+
stdout('No bookmarks yet. Add one with: summon <target> --save <name>');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const width = Math.max(...names.map(name => name.length));
|
|
247
|
+
for (const name of names.toSorted()) {
|
|
248
|
+
stdout(` ${name.padEnd(width)} ${bookmarks[name]}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function interactivePicker(bookmarks) {
|
|
253
|
+
const items = [
|
|
254
|
+
...Object.entries(bookmarks).map(([name, target]) => ({label: `@${name} → ${target}`, value: `@${name}`})),
|
|
255
|
+
...readHistory().map(item => ({label: item, value: item})),
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
if (items.length === 0) {
|
|
259
|
+
throw new SummonError('Specify a file path or URL', 4);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return pick('What would you like to open?', items);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
await main();
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error instanceof SummonError) {
|
|
269
|
+
stderr(`summon: ${error.message}`);
|
|
270
|
+
process.exit(error.exitCode);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
stderr(`summon: ${error.message ?? error}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# summon bash completion
|
|
2
|
+
# Install: source this file from your ~/.bashrc, e.g.
|
|
3
|
+
# source /path/to/summon/completions/summon.bash
|
|
4
|
+
|
|
5
|
+
_summon_completions() {
|
|
6
|
+
local cur prev flags
|
|
7
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
8
|
+
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
|
9
|
+
|
|
10
|
+
flags="--wait --background --extension --dry-run --search --clipboard --reveal --recent --save --remove-bookmark --bookmarks --help --version"
|
|
11
|
+
|
|
12
|
+
# Complete bookmark names after `@`.
|
|
13
|
+
if [[ "$cur" == @* ]]; then
|
|
14
|
+
local names
|
|
15
|
+
names=$(summon --bookmarks 2>/dev/null | awk '{print "@"$1}')
|
|
16
|
+
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
|
17
|
+
return 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Complete bookmark names for --remove-bookmark.
|
|
21
|
+
if [[ "$prev" == "--remove-bookmark" ]]; then
|
|
22
|
+
local names
|
|
23
|
+
names=$(summon --bookmarks 2>/dev/null | awk '{print $1}')
|
|
24
|
+
COMPREPLY=($(compgen -W "$names" -- "$cur"))
|
|
25
|
+
return 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [[ "$cur" == -* ]]; then
|
|
29
|
+
COMPREPLY=($(compgen -W "$flags" -- "$cur"))
|
|
30
|
+
return 0
|
|
31
|
+
fi
|
|
32
|
+
|
|
33
|
+
# Default to file completion.
|
|
34
|
+
COMPREPLY=($(compgen -f -- "$cur"))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
complete -F _summon_completions summon
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# summon fish completion
|
|
2
|
+
# Install: copy to ~/.config/fish/completions/summon.fish
|
|
3
|
+
|
|
4
|
+
function __summon_bookmarks
|
|
5
|
+
summon --bookmarks 2>/dev/null | string trim | string replace -r '\s.*' ''
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
complete -c summon -s w -l wait -d 'Wait for the app to exit'
|
|
9
|
+
complete -c summon -l background -d 'Do not bring the app to the foreground (macOS)'
|
|
10
|
+
complete -c summon -l extension -r -d 'File extension for stdin'
|
|
11
|
+
complete -c summon -s n -l dry-run -d 'Print what would be opened'
|
|
12
|
+
complete -c summon -s s -l search -d 'Treat input as a search query'
|
|
13
|
+
complete -c summon -s c -l clipboard -d 'Open URL/path from the clipboard'
|
|
14
|
+
complete -c summon -s r -l reveal -d 'Reveal file/folder in file manager'
|
|
15
|
+
complete -c summon -l recent -d 'Pick from recently opened items'
|
|
16
|
+
complete -c summon -l save -r -d 'Save target as a bookmark'
|
|
17
|
+
complete -c summon -l remove-bookmark -x -a '(__summon_bookmarks)' -d 'Remove a bookmark'
|
|
18
|
+
complete -c summon -l bookmarks -d 'List saved bookmarks'
|
|
19
|
+
complete -c summon -l help -d 'Show help'
|
|
20
|
+
complete -c summon -l version -d 'Show version'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# summon PowerShell completion
|
|
2
|
+
# Install: add this line to your PowerShell profile ($PROFILE):
|
|
3
|
+
# . /path/to/summon/completions/summon.ps1
|
|
4
|
+
|
|
5
|
+
Register-ArgumentCompleter -Native -CommandName summon -ScriptBlock {
|
|
6
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
7
|
+
|
|
8
|
+
$flags = @(
|
|
9
|
+
'--wait', '--background', '--extension', '--dry-run', '--search',
|
|
10
|
+
'--clipboard', '--reveal', '--recent', '--save', '--remove-bookmark',
|
|
11
|
+
'--bookmarks', '--help', '--version'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
if ($wordToComplete -like '--*') {
|
|
15
|
+
$flags | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object {
|
|
16
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_)
|
|
17
|
+
}
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Bookmark completion after `@`.
|
|
22
|
+
if ($wordToComplete -like '@*') {
|
|
23
|
+
$prefix = $wordToComplete.TrimStart('@')
|
|
24
|
+
(summon --bookmarks 2>$null) | ForEach-Object {
|
|
25
|
+
($_ -split '\s+', 2)[0].Trim()
|
|
26
|
+
} | Where-Object { $_ -like "$prefix*" } | ForEach-Object {
|
|
27
|
+
[System.Management.Automation.CompletionResult]::new("@$_", "@$_", 'ParameterValue', "@$_")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#compdef summon
|
|
2
|
+
# summon zsh completion
|
|
3
|
+
# Install: place this file in a directory on your $fpath (named `_summon`),
|
|
4
|
+
# or source it from your ~/.zshrc.
|
|
5
|
+
|
|
6
|
+
_summon() {
|
|
7
|
+
local -a flags
|
|
8
|
+
flags=(
|
|
9
|
+
'--wait[Wait for the app to exit]'
|
|
10
|
+
'-w[Wait for the app to exit]'
|
|
11
|
+
'--background[Do not bring the app to the foreground (macOS)]'
|
|
12
|
+
'--extension[File extension for stdin]:extension:'
|
|
13
|
+
'--dry-run[Print what would be opened]'
|
|
14
|
+
'-n[Print what would be opened]'
|
|
15
|
+
'--search[Treat input as a search query]'
|
|
16
|
+
'-s[Treat input as a search query]'
|
|
17
|
+
'--clipboard[Open URL/path from the clipboard]'
|
|
18
|
+
'-c[Open URL/path from the clipboard]'
|
|
19
|
+
'--reveal[Reveal file/folder in file manager]'
|
|
20
|
+
'-r[Reveal file/folder in file manager]'
|
|
21
|
+
'--recent[Pick from recently opened items]'
|
|
22
|
+
'--save[Save target as a bookmark]:name:'
|
|
23
|
+
'--remove-bookmark[Remove a bookmark]:name:_summon_bookmarks'
|
|
24
|
+
'--bookmarks[List saved bookmarks]'
|
|
25
|
+
'--help[Show help]'
|
|
26
|
+
'--version[Show version]'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_arguments -s $flags '*:target:_files'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_summon_bookmarks() {
|
|
33
|
+
local -a names
|
|
34
|
+
names=(${(f)"$(summon --bookmarks 2>/dev/null | awk '{print $1}')"})
|
|
35
|
+
compadd -a names
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_summon "$@"
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import {homedir} from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
|
|
11
|
+
const HISTORY_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CONFIG = {
|
|
14
|
+
// Map of alias -> target. Resolved when you run `summon @alias` or `summon alias`.
|
|
15
|
+
bookmarks: {},
|
|
16
|
+
// Search URL template. `%s` is replaced with the URL-encoded query.
|
|
17
|
+
searchEngine: 'https://www.google.com/search?q=%s',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
Resolve the directory where summon stores its config and history.
|
|
22
|
+
|
|
23
|
+
Priority:
|
|
24
|
+
1. `SUMMON_CONFIG_DIR` (used by tests and power users)
|
|
25
|
+
2. `XDG_CONFIG_HOME/summon`
|
|
26
|
+
3. Windows: `%APPDATA%/summon`
|
|
27
|
+
4. `~/.config/summon`
|
|
28
|
+
|
|
29
|
+
@returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function configDirectory() {
|
|
32
|
+
if (process.env.SUMMON_CONFIG_DIR) {
|
|
33
|
+
return process.env.SUMMON_CONFIG_DIR;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (process.env.XDG_CONFIG_HOME) {
|
|
37
|
+
return path.join(process.env.XDG_CONFIG_HOME, 'summon');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
41
|
+
return path.join(process.env.APPDATA, 'summon');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return path.join(homedir(), '.config', 'summon');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const configPath = () => path.join(configDirectory(), 'config.json');
|
|
48
|
+
const historyPath = () => path.join(configDirectory(), 'history.json');
|
|
49
|
+
|
|
50
|
+
function readJson(filePath, fallback) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(filePath)) {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
57
|
+
} catch {
|
|
58
|
+
// A corrupt file should never crash the CLI; fall back to defaults.
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeJson(filePath, data) {
|
|
64
|
+
mkdirSync(path.dirname(filePath), {recursive: true});
|
|
65
|
+
writeFileSync(filePath, JSON.stringify(data, null, '\t') + '\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
Read the full config, merged with defaults.
|
|
70
|
+
|
|
71
|
+
@returns {{bookmarks: Record<string, string>, searchEngine: string}}
|
|
72
|
+
*/
|
|
73
|
+
export function readConfig() {
|
|
74
|
+
return {...DEFAULT_CONFIG, ...readJson(configPath(), {})};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @returns {Record<string, string>} */
|
|
78
|
+
export function readBookmarks() {
|
|
79
|
+
return readConfig().bookmarks ?? {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
Save (or overwrite) a bookmark.
|
|
84
|
+
|
|
85
|
+
@param {string} name
|
|
86
|
+
@param {string} target
|
|
87
|
+
*/
|
|
88
|
+
export function saveBookmark(name, target) {
|
|
89
|
+
const config = readConfig();
|
|
90
|
+
config.bookmarks = {...config.bookmarks, [name]: target};
|
|
91
|
+
writeJson(configPath(), config);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
Remove a bookmark.
|
|
96
|
+
|
|
97
|
+
@param {string} name
|
|
98
|
+
@returns {boolean} Whether the bookmark existed.
|
|
99
|
+
*/
|
|
100
|
+
export function removeBookmark(name) {
|
|
101
|
+
const config = readConfig();
|
|
102
|
+
|
|
103
|
+
if (!config.bookmarks || !(name in config.bookmarks)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
delete config.bookmarks[name];
|
|
108
|
+
writeJson(configPath(), config);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @returns {string[]} Most-recent-first list of opened targets. */
|
|
113
|
+
export function readHistory() {
|
|
114
|
+
const history = readJson(historyPath(), []);
|
|
115
|
+
return Array.isArray(history) ? history : [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
Prepend a target to the history, de-duplicated and capped.
|
|
120
|
+
|
|
121
|
+
@param {string} target
|
|
122
|
+
*/
|
|
123
|
+
export function addHistory(target) {
|
|
124
|
+
const existing = readHistory().filter(item => item !== target);
|
|
125
|
+
const history = [target, ...existing].slice(0, HISTORY_LIMIT);
|
|
126
|
+
writeJson(historyPath(), history);
|
|
127
|
+
}
|
package/lib/picker.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
Show a numbered interactive menu and return the chosen value.
|
|
6
|
+
|
|
7
|
+
@param {string} title
|
|
8
|
+
@param {Array<{label: string, value: string}>} items
|
|
9
|
+
@returns {Promise<string | undefined>} The chosen value, or undefined if cancelled/empty.
|
|
10
|
+
*/
|
|
11
|
+
export async function pick(title, items) {
|
|
12
|
+
if (items.length === 0) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const rl = readline.createInterface({input: process.stdin, output: process.stderr});
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
process.stderr.write(`${title}\n\n`);
|
|
20
|
+
for (const [index, item] of items.entries()) {
|
|
21
|
+
process.stderr.write(` ${String(index + 1).padStart(2)}. ${item.label}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.stderr.write('\n');
|
|
25
|
+
|
|
26
|
+
const response = await rl.question('Select a number (or press Enter to cancel): ');
|
|
27
|
+
const answer = response.trim();
|
|
28
|
+
|
|
29
|
+
if (answer === '') {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const index = Number.parseInt(answer, 10) - 1;
|
|
34
|
+
if (Number.isInteger(index) && index >= 0 && index < items.length) {
|
|
35
|
+
return items[index].value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
process.stderr.write('Invalid selection.\n');
|
|
39
|
+
return undefined;
|
|
40
|
+
} finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|