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 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
+ }