summon-open 1.0.0 → 1.1.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/cli.js CHANGED
@@ -8,12 +8,13 @@ import clipboard from 'clipboardy';
8
8
  import {temporaryWrite} from 'tempy';
9
9
  import {fileTypeFromBuffer} from 'file-type';
10
10
  import {
11
- readConfig,
12
11
  readBookmarks,
13
12
  saveBookmark,
14
13
  removeBookmark,
15
14
  readHistory,
16
15
  addHistory,
16
+ readSearchEngines,
17
+ defaultSearchEngineName,
17
18
  } from './lib/config.js';
18
19
  import {
19
20
  isUrl,
@@ -38,6 +39,8 @@ const cli = meow(`
38
39
  --extension File extension for when stdin file type cannot be detected
39
40
  --dry-run, -n Print what would be opened without opening it
40
41
  --search, -s Treat the input as a search query
42
+ --engine, -e Search engine to use with --search (see --engines)
43
+ --engines List available search engines
41
44
  --clipboard, -c Open the URL/path currently on the clipboard
42
45
  --reveal, -r Reveal the file/folder in your file manager
43
46
  --recent Pick from recently opened items
@@ -53,6 +56,7 @@ const cli = meow(`
53
56
  $ summon @docs # open a saved bookmark
54
57
  $ summon https://docs.example.com --save docs
55
58
  $ summon -s "rust async traits"
59
+ $ summon -s "flatMap" -e mdn
56
60
  $ echo '<h1>Hi</h1>' | summon --extension=html
57
61
  $ summon report.pdf --reveal
58
62
  $ summon --recent
@@ -64,6 +68,8 @@ const cli = meow(`
64
68
  extension: {type: 'string'},
65
69
  dryRun: {type: 'boolean', default: false, shortFlag: 'n'},
66
70
  search: {type: 'boolean', default: false, shortFlag: 's'},
71
+ engine: {type: 'string', shortFlag: 'e'},
72
+ engines: {type: 'boolean', default: false},
67
73
  clipboard: {type: 'boolean', default: false, shortFlag: 'c'},
68
74
  reveal: {type: 'boolean', default: false, shortFlag: 'r'},
69
75
  recent: {type: 'boolean', default: false},
@@ -94,20 +100,43 @@ const stderr = message => process.stderr.write(`${message}\n`);
94
100
  const stdout = message => process.stdout.write(`${message}\n`);
95
101
 
96
102
  async function main() {
97
- // --- Bookmark management (these never open anything) ---
103
+ if (handleManagementCommand()) {
104
+ return;
105
+ }
106
+
107
+ const bookmarks = readBookmarks();
108
+ const plan = await collectInputs(bookmarks);
109
+ if (plan.done) {
110
+ return;
111
+ }
112
+
113
+ if (plan.stdinMode) {
114
+ await handleStdin();
115
+ return;
116
+ }
117
+
118
+ await act(plan.rawInputs, bookmarks);
119
+ }
120
+
121
+ // Handle commands that never open anything. Returns true when one ran.
122
+ function handleManagementCommand() {
98
123
  if (flags.bookmarks) {
99
124
  listBookmarks();
100
- return;
125
+ return true;
126
+ }
127
+
128
+ if (flags.engines) {
129
+ listSearchEngines();
130
+ return true;
101
131
  }
102
132
 
103
133
  if (flags.removeBookmark !== undefined) {
104
- const removed = removeBookmark(flags.removeBookmark);
105
- if (!removed) {
134
+ if (!removeBookmark(flags.removeBookmark)) {
106
135
  throw new SummonError(`No such bookmark: ${flags.removeBookmark}`, 3);
107
136
  }
108
137
 
109
138
  stdout(`Removed bookmark: ${flags.removeBookmark}`);
110
- return;
139
+ return true;
111
140
  }
112
141
 
113
142
  if (flags.save !== undefined) {
@@ -118,14 +147,14 @@ async function main() {
118
147
  const value = resolveTarget(targets[0], readBookmarks());
119
148
  saveBookmark(flags.save, value);
120
149
  stdout(`Saved bookmark ${flags.save} → ${value}`);
121
- return;
150
+ return true;
122
151
  }
123
152
 
124
- // --- Figure out what to open ---
125
- const bookmarks = readBookmarks();
126
- let rawInputs;
127
- let stdinMode = false;
153
+ return false;
154
+ }
128
155
 
156
+ // Decide what to open. Returns {rawInputs} | {stdinMode:true} | {done:true}.
157
+ async function collectInputs(bookmarks) {
129
158
  if (flags.clipboard) {
130
159
  const clipboardText = await clipboard.read();
131
160
  const text = clipboardText.trim();
@@ -133,39 +162,46 @@ async function main() {
133
162
  throw new SummonError('Clipboard is empty', 4);
134
163
  }
135
164
 
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
- }
165
+ return {rawInputs: text.split(/\s+/v).filter(Boolean)};
166
+ }
167
+
168
+ if (flags.search) {
169
+ return {rawInputs: [searchUrlFromTargets()]};
170
+ }
141
171
 
142
- rawInputs = [buildSearchUrl(targets.join(' '), readConfig().searchEngine)];
143
- } else if (flags.recent) {
172
+ if (flags.recent) {
144
173
  const chosen = await pick('Recently opened', readHistory().map(item => ({label: item, value: item})));
145
- if (!chosen) {
146
- return;
147
- }
174
+ return chosen ? {rawInputs: [chosen]} : {done: true};
175
+ }
148
176
 
149
- rawInputs = [chosen];
150
- } else if (targets.length > 0) {
151
- rawInputs = targets;
152
- } else if (process.stdin.isTTY) {
177
+ if (targets.length > 0) {
178
+ return {rawInputs: targets};
179
+ }
180
+
181
+ if (process.stdin.isTTY) {
153
182
  const chosen = await interactivePicker(bookmarks);
154
- if (!chosen) {
155
- return;
156
- }
183
+ return chosen ? {rawInputs: [chosen]} : {done: true};
184
+ }
185
+
186
+ return {stdinMode: true};
187
+ }
157
188
 
158
- rawInputs = [chosen];
159
- } else {
160
- stdinMode = true;
189
+ function searchUrlFromTargets() {
190
+ if (targets.length === 0) {
191
+ throw new SummonError('Provide a search query, e.g. `summon -s "hello world"`', 4);
161
192
  }
162
193
 
163
- if (stdinMode) {
164
- await handleStdin();
165
- return;
194
+ const engines = readSearchEngines();
195
+ const engineName = flags.engine ?? defaultSearchEngineName();
196
+ const template = engines[engineName];
197
+ if (!template) {
198
+ throw new SummonError(`Unknown search engine: ${engineName}. Available: ${Object.keys(engines).toSorted().join(', ')}`, 4);
166
199
  }
167
200
 
168
- // --- Resolve and act ---
201
+ return buildSearchUrl(targets.join(' '), template);
202
+ }
203
+
204
+ async function act(rawInputs, bookmarks) {
169
205
  const expanded = rawInputs.map(input => expandBookmark(input, bookmarks));
170
206
 
171
207
  if (flags.reveal) {
@@ -249,6 +285,20 @@ function listBookmarks() {
249
285
  }
250
286
  }
251
287
 
288
+ function listSearchEngines() {
289
+ const engines = readSearchEngines();
290
+ const defaultName = defaultSearchEngineName();
291
+ const names = Object.keys(engines).toSorted();
292
+ const width = Math.max(...names.map(name => name.length));
293
+
294
+ for (const name of names) {
295
+ const marker = name === defaultName ? '*' : ' ';
296
+ stdout(`${marker} ${name.padEnd(width)} ${engines[name]}`);
297
+ }
298
+
299
+ stdout('\n* = default. Choose another with: summon -s "query" -e <name>');
300
+ }
301
+
252
302
  async function interactivePicker(bookmarks) {
253
303
  const items = [
254
304
  ...Object.entries(bookmarks).map(([name, target]) => ({label: `@${name} → ${target}`, value: `@${name}`})),
package/lib/config.js CHANGED
@@ -10,11 +10,30 @@ import {
10
10
 
11
11
  const HISTORY_LIMIT = 50;
12
12
 
13
+ // Built-in search engines. Each value is a URL template where `%s` is replaced
14
+ // by the URL-encoded query. Users can add/override these in config.json.
15
+ const DEFAULT_SEARCH_ENGINES = {
16
+ google: 'https://www.google.com/search?q=%s',
17
+ ddg: 'https://duckduckgo.com/?q=%s',
18
+ bing: 'https://www.bing.com/search?q=%s',
19
+ brave: 'https://search.brave.com/search?q=%s',
20
+ npm: 'https://www.npmjs.com/search?q=%s',
21
+ gh: 'https://github.com/search?q=%s&type=repositories',
22
+ mdn: 'https://developer.mozilla.org/en-US/search?q=%s',
23
+ so: 'https://stackoverflow.com/search?q=%s',
24
+ yt: 'https://www.youtube.com/results?search_query=%s',
25
+ wiki: 'https://en.wikipedia.org/w/index.php?search=%s',
26
+ };
27
+
13
28
  const DEFAULT_CONFIG = {
14
29
  // Map of alias -> target. Resolved when you run `summon @alias` or `summon alias`.
15
30
  bookmarks: {},
16
- // Search URL template. `%s` is replaced with the URL-encoded query.
17
- searchEngine: 'https://www.google.com/search?q=%s',
31
+ // Named search engines, merged with the built-ins above.
32
+ searchEngines: {},
33
+ // Which engine `--search` uses when `--engine` isn't given.
34
+ defaultSearchEngine: 'google',
35
+ // Legacy single-engine field, still honored as a fallback.
36
+ searchEngine: undefined,
18
37
  };
19
38
 
20
39
  /**
@@ -68,12 +87,44 @@ function writeJson(filePath, data) {
68
87
  /**
69
88
  Read the full config, merged with defaults.
70
89
 
71
- @returns {{bookmarks: Record<string, string>, searchEngine: string}}
90
+ @returns {{bookmarks: Record<string, string>, searchEngines: Record<string, string>, defaultSearchEngine: string, searchEngine?: string}}
72
91
  */
73
92
  export function readConfig() {
74
93
  return {...DEFAULT_CONFIG, ...readJson(configPath(), {})};
75
94
  }
76
95
 
96
+ /**
97
+ All available search engines: built-ins merged with the user's `searchEngines`.
98
+ A legacy top-level `searchEngine` is exposed as the `custom` engine.
99
+
100
+ @returns {Record<string, string>}
101
+ */
102
+ export function readSearchEngines() {
103
+ const config = readConfig();
104
+ const engines = {...DEFAULT_SEARCH_ENGINES, ...config.searchEngines};
105
+
106
+ if (typeof config.searchEngine === 'string' && config.searchEngine.length > 0) {
107
+ engines.custom = config.searchEngine;
108
+ }
109
+
110
+ return engines;
111
+ }
112
+
113
+ /**
114
+ The name of the engine `--search` uses when `--engine` isn't provided.
115
+
116
+ @returns {string}
117
+ */
118
+ export function defaultSearchEngineName() {
119
+ const config = readConfig();
120
+
121
+ if (typeof config.searchEngine === 'string' && config.searchEngine.length > 0 && !config.defaultSearchEngine) {
122
+ return 'custom';
123
+ }
124
+
125
+ return config.defaultSearchEngine ?? 'google';
126
+ }
127
+
77
128
  /** @returns {Record<string, string>} */
78
129
  export function readBookmarks() {
79
130
  return readConfig().bookmarks ?? {};
package/lib/fuzzy.js ADDED
@@ -0,0 +1,68 @@
1
+ // Word-boundary characters that earn a scoring bonus when a match starts there.
2
+ const BOUNDARY = /[\s\-_\/.@:]/v;
3
+
4
+ /**
5
+ Score how well `query` fuzzy-matches `target`.
6
+
7
+ Returns a non-negative score (higher is better) when every character of the
8
+ query appears in order within the target, or `-1` when there's no match.
9
+ Consecutive matches and matches at word boundaries score higher.
10
+
11
+ @param {string} query
12
+ @param {string} target
13
+ @returns {number}
14
+ */
15
+ export function fuzzyScore(query, target) {
16
+ const q = query.toLowerCase();
17
+ const t = target.toLowerCase();
18
+
19
+ if (q === '') {
20
+ return 0;
21
+ }
22
+
23
+ let score = 0;
24
+ let index = 0;
25
+ let consecutive = 0;
26
+
27
+ for (const char of q) {
28
+ const found = t.indexOf(char, index);
29
+ if (found === -1) {
30
+ return -1;
31
+ }
32
+
33
+ let bonus = 1;
34
+
35
+ if (found === index && index > 0) {
36
+ consecutive += 1;
37
+ bonus += consecutive * 2;
38
+ } else {
39
+ consecutive = 0;
40
+ }
41
+
42
+ if (found === 0 || BOUNDARY.test(t[found - 1])) {
43
+ bonus += 3;
44
+ }
45
+
46
+ score += bonus;
47
+ index = found + 1;
48
+ }
49
+
50
+ return score;
51
+ }
52
+
53
+ /**
54
+ Filter and rank items by fuzzy match against a label.
55
+
56
+ @template T
57
+ @param {string} query
58
+ @param {T[]} items
59
+ @param {(item: T) => string} toLabel
60
+ @returns {T[]} Matching items, best first.
61
+ */
62
+ export function fuzzyFilter(query, items, toLabel = String) {
63
+ return items
64
+ .map(item => ({item, score: fuzzyScore(query, toLabel(item))}))
65
+ .filter(entry => entry.score >= 0)
66
+ .toSorted((a, b) => b.score - a.score)
67
+ .map(entry => entry.item);
68
+ }
package/lib/picker.js CHANGED
@@ -1,12 +1,35 @@
1
1
  import process from 'node:process';
2
2
  import readline from 'node:readline/promises';
3
+ import {fuzzyFilter} from './fuzzy.js';
4
+
5
+ const PAGE_SIZE = 15;
6
+
7
+ function render(title, items) {
8
+ process.stderr.write(`\n${title}\n\n`);
9
+
10
+ const shown = items.slice(0, PAGE_SIZE);
11
+ for (const [index, item] of shown.entries()) {
12
+ process.stderr.write(` ${String(index + 1).padStart(2)}. ${item.label}\n`);
13
+ }
14
+
15
+ if (items.length > shown.length) {
16
+ process.stderr.write(` … ${items.length - shown.length} more (type to filter)\n`);
17
+ }
18
+
19
+ process.stderr.write('\n');
20
+ return shown;
21
+ }
3
22
 
4
23
  /**
5
- Show a numbered interactive menu and return the chosen value.
24
+ Show an interactive fuzzy picker and return the chosen value.
25
+
26
+ Type to fuzzy-filter, enter a number to select from the visible list, or press
27
+ Enter on an empty line to cancel. When a filter narrows results to one, it's
28
+ selected automatically.
6
29
 
7
30
  @param {string} title
8
31
  @param {Array<{label: string, value: string}>} items
9
- @returns {Promise<string | undefined>} The chosen value, or undefined if cancelled/empty.
32
+ @returns {Promise<string | undefined>}
10
33
  */
11
34
  export async function pick(title, items) {
12
35
  if (items.length === 0) {
@@ -14,29 +37,40 @@ export async function pick(title, items) {
14
37
  }
15
38
 
16
39
  const rl = readline.createInterface({input: process.stdin, output: process.stderr});
40
+ let current = items;
17
41
 
18
42
  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
- }
43
+ for (;;) {
44
+ const shown = render(title, current);
45
+ // eslint-disable-next-line no-await-in-loop
46
+ const response = await rl.question('Type to filter, number to select, Enter to cancel: ');
47
+ const answer = response.trim();
23
48
 
24
- process.stderr.write('\n');
49
+ if (answer === '') {
50
+ return undefined;
51
+ }
25
52
 
26
- const response = await rl.question('Select a number (or press Enter to cancel): ');
27
- const answer = response.trim();
53
+ if (/^\d+$/v.test(answer)) {
54
+ const index = Number.parseInt(answer, 10) - 1;
55
+ if (index >= 0 && index < shown.length) {
56
+ return shown[index].value;
57
+ }
28
58
 
29
- if (answer === '') {
30
- return undefined;
31
- }
59
+ process.stderr.write('Invalid selection.\n');
60
+ continue;
61
+ }
32
62
 
33
- const index = Number.parseInt(answer, 10) - 1;
34
- if (Number.isInteger(index) && index >= 0 && index < items.length) {
35
- return items[index].value;
36
- }
63
+ const filtered = fuzzyFilter(answer, items, item => item.label);
37
64
 
38
- process.stderr.write('Invalid selection.\n');
39
- return undefined;
65
+ if (filtered.length === 0) {
66
+ process.stderr.write('No matches.\n');
67
+ current = items;
68
+ } else if (filtered.length === 1) {
69
+ return filtered[0].value;
70
+ } else {
71
+ current = filtered;
72
+ }
73
+ }
40
74
  } finally {
41
75
  rl.close();
42
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "summon-open",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Open URLs, files, folders, and apps from one cross-platform command — with bookmarks, search, clipboard, reveal, dry-run, and stdin support.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/readme.md CHANGED
@@ -14,6 +14,8 @@
14
14
  Bookmarks, search, clipboard, reveal-in-file-manager, dry-run, multiple targets, and stdin — all in a tool small enough to forget it's there. No more remembering `xdg-open` vs `start` vs `open`.
15
15
 
16
16
  [![npm version](https://img.shields.io/npm/v/summon-open.svg)](https://www.npmjs.com/package/summon-open)
17
+ [![npm downloads](https://img.shields.io/npm/dm/summon-open.svg)](https://www.npmjs.com/package/summon-open)
18
+ [![install size](https://packagephobia.com/badge?p=summon-open)](https://packagephobia.com/result?p=summon-open)
17
19
  [![node](https://img.shields.io/node/v/summon-open.svg)](https://nodejs.org)
18
20
  [![license](https://img.shields.io/npm/l/summon-open.svg)](license)
19
21
  [![CI](https://github.com/Aditya060806/summon/actions/workflows/main.yml/badge.svg)](https://github.com/Aditya060806/summon/actions)
@@ -21,9 +23,10 @@ Bookmarks, search, clipboard, reveal-in-file-manager, dry-run, multiple targets,
21
23
  </div>
22
24
 
23
25
  <!--
24
- DEMO: drop a terminal recording here for maximum impact.
25
- Record with https://github.com/charmbracelet/vhs or https://asciinema.org
26
- then reference it, e.g.:
26
+ DEMO: a ready-to-run VHS script lives at media/demo.tape.
27
+ 1. Install VHS: https://github.com/charmbracelet/vhs
28
+ 2. From the repo root, run: vhs media/demo.tape
29
+ 3. It writes media/demo.gif — then uncomment the line below.
27
30
  <div align="center"><img src="media/demo.gif" alt="summon demo" width="700"></div>
28
31
  -->
29
32
 
@@ -54,7 +57,7 @@ $ cat diagram.png | summon # pipe raw bytes straight in
54
57
  - [Web search](#web-search)
55
58
  - [Clipboard](#clipboard)
56
59
  - [Reveal in file manager](#reveal-in-file-manager)
57
- - [Recent & interactive picker](#recent--interactive-picker)
60
+ - [Recent & fuzzy picker](#recent--fuzzy-picker)
58
61
  - [Dry run](#dry-run)
59
62
  - [Choosing the app](#choosing-the-app)
60
63
  - [Stdin](#stdin)
@@ -65,6 +68,7 @@ $ cat diagram.png | summon # pipe raw bytes straight in
65
68
  - [Comparison](#comparison)
66
69
  - [Feature matrix](#feature-matrix)
67
70
  - [Same task, side by side](#same-task-side-by-side)
71
+ - [vs other npm openers](#vs-other-npm-openers)
68
72
  - [Efficiency](#efficiency)
69
73
  - [Exit codes](#exit-codes)
70
74
  - [Platform support](#platform-support)
@@ -107,10 +111,10 @@ summon "$url"
107
111
  - **Multiple targets** — `summon a.pdf b.png https://x.com` opens them all.
108
112
  - **Smart URL normalization** — `summon github.com` just works; the `https://` is added for you.
109
113
  - **Bookmarks** — save aliases (`summon @docs`) and turn `summon` into a daily driver.
110
- - **Web search** — `summon -s "query"` opens a search with your configured engine.
114
+ - **Web search** — `summon -s "query"` searches with a configurable engine (`-e mdn`, `-e npm`, …).
111
115
  - **Clipboard mode** — `summon -c` opens whatever URL/path you just copied.
112
116
  - **Reveal** — `summon -r file` highlights it in Finder/Explorer/your file manager.
113
- - **Recent + interactive picker** — re-open recent items or pick from a menu.
117
+ - **Recent + fuzzy picker** — re-open recent items or fuzzy-search a menu of bookmarks/history.
114
118
  - **Dry run** — `--dry-run` prints exactly what would happen without doing it.
115
119
  - **Pick the app** — force a specific app and pass it arguments.
116
120
  - **Pipe-friendly** — stream data over stdin; the file type is auto-detected.
@@ -130,12 +134,19 @@ This installs the `summon` command. Or run it once without installing:
130
134
  npx summon-open https://github.com
131
135
  ```
132
136
 
133
- <details>
134
- <summary>Other install channels</summary>
137
+ **Homebrew** (macOS/Linux):
135
138
 
136
- Homebrew and Scoop manifests are planned. In the meantime, `npm`/`npx` work on every platform. If you maintain a package repo and want to help, contributions are welcome.
139
+ ```sh
140
+ brew install --formula https://raw.githubusercontent.com/Aditya060806/summon/main/dist/homebrew/summon-open.rb
141
+ ```
142
+
143
+ **Scoop** (Windows):
137
144
 
138
- </details>
145
+ ```powershell
146
+ scoop install https://raw.githubusercontent.com/Aditya060806/summon/main/dist/scoop/summon-open.json
147
+ ```
148
+
149
+ Both channels install the same `summon` command and depend on Node.js. The manifests live in [`dist/`](dist).
139
150
 
140
151
  ## Quick start
141
152
 
@@ -191,6 +202,8 @@ The whole tool at a glance — task on the left, command on the right.
191
202
  | List bookmarks | `summon --bookmarks` |
192
203
  | Remove a bookmark | `summon --remove-bookmark docs` |
193
204
  | Search the web | `summon -s "query here"` |
205
+ | Search with a specific engine | `summon -s "flatMap" -e mdn` |
206
+ | List search engines | `summon --engines` |
194
207
  | Open clipboard URL | `summon -c` |
195
208
  | Reveal in file manager | `summon report.pdf -r` |
196
209
  | Re-open something recent | `summon --recent` |
@@ -214,6 +227,8 @@ $ summon --help
214
227
  --extension File extension for when stdin file type cannot be detected
215
228
  --dry-run, -n Print what would be opened without opening it
216
229
  --search, -s Treat the input as a search query
230
+ --engine, -e Search engine to use with --search (see --engines)
231
+ --engines List available search engines
217
232
  --clipboard, -c Open the URL/path currently on the clipboard
218
233
  --reveal, -r Reveal the file/folder in your file manager
219
234
  --recent Pick from recently opened items
@@ -223,12 +238,13 @@ $ summon --help
223
238
 
224
239
  Examples
225
240
  $ summon https://sindresorhus.com
226
- $ summon github.com
241
+ $ summon github.com # scheme added automatically
227
242
  $ summon report.pdf photo.png notes.txt
228
243
  $ summon https://github.com -- 'google chrome' --incognito
229
- $ summon @docs
244
+ $ summon @docs # open a saved bookmark
230
245
  $ summon https://docs.example.com --save docs
231
246
  $ summon -s "rust async traits"
247
+ $ summon -s "flatMap" -e mdn
232
248
  $ echo '<h1>Hi</h1>' | summon --extension=html
233
249
  $ summon report.pdf --reveal
234
250
  $ summon --recent
@@ -243,6 +259,8 @@ $ summon --help
243
259
  | `--extension` | | string | auto | Extension to use for piped stdin when the type can't be auto-detected. |
244
260
  | `--dry-run` | `-n` | boolean | `false` | Print what would be opened without launching anything. |
245
261
  | `--search` | `-s` | boolean | `false` | Treat the input as a web search query. |
262
+ | `--engine <name>` | `-e` | string | config | Search engine to use with `--search`. See `--engines`. |
263
+ | `--engines` | | boolean | `false` | List available search engines (`*` marks the default). |
246
264
  | `--clipboard` | `-c` | boolean | `false` | Open the URL/path currently on the clipboard. |
247
265
  | `--reveal` | `-r` | boolean | `false` | Reveal/highlight the file or folder in the file manager. |
248
266
  | `--recent` | | boolean | `false` | Interactively pick from recently opened items. |
@@ -292,7 +310,15 @@ Bookmarks live in your [config file](#configuration) and can also be edited by h
292
310
  summon -s "how to exit vim"
293
311
  ```
294
312
 
295
- The engine is configurable see [Configuration](#configuration). The default is Google.
313
+ Pick an engine per search with `--engine`/`-e`, and list what's available with `--engines`:
314
+
315
+ ```sh
316
+ summon -s "flatMap" -e mdn
317
+ summon -s "left-pad" -e npm
318
+ summon --engines
319
+ ```
320
+
321
+ Built-in engines include `google` (default), `ddg`, `bing`, `brave`, `npm`, `gh`, `mdn`, `so`, `yt`, and `wiki`. Add your own or change the default in [Configuration](#configuration).
296
322
 
297
323
  ### Clipboard
298
324
 
@@ -318,7 +344,7 @@ summon report.pdf --reveal
318
344
  - **Windows** — `explorer /select,` (selects the file in Explorer)
319
345
  - **Linux/other** — opens the containing folder
320
346
 
321
- ### Recent & interactive picker
347
+ ### Recent & fuzzy picker
322
348
 
323
349
  `summon` remembers what you open. Re-open something recent:
324
350
 
@@ -326,7 +352,7 @@ summon report.pdf --reveal
326
352
  summon --recent
327
353
  ```
328
354
 
329
- Run `summon` with no arguments in a terminal and it shows an interactive menu of your bookmarks and recent items.
355
+ Run `summon` with no arguments in a terminal and it shows an interactive **fuzzy picker** of your bookmarks and recent items. Just start typing to filter (fuzzy, so `ghb` matches `github`), enter a number to select from the visible list, or press Enter on an empty line to cancel. When your filter narrows to a single match, it's selected automatically.
330
356
 
331
357
  ### Dry run
332
358
 
@@ -436,14 +462,19 @@ You can override it with the `SUMMON_CONFIG_DIR` environment variable (also hand
436
462
  "docs": "https://docs.example.com",
437
463
  "repo": "https://github.com/Aditya060806/summon"
438
464
  },
439
- "searchEngine": "https://www.google.com/search?q=%s"
465
+ "defaultSearchEngine": "ddg",
466
+ "searchEngines": {
467
+ "work": "https://intranet.example.com/search?q=%s",
468
+ "scholar": "https://scholar.google.com/scholar?q=%s"
469
+ }
440
470
  }
441
471
  ```
442
472
 
443
473
  - `bookmarks` — map of alias → target. Managed via `--save` / `--remove-bookmark`, or edited by hand.
444
- - `searchEngine` — a URL template where `%s` is replaced by the URL-encoded query. Examples:
445
- - DuckDuckGo: `https://duckduckgo.com/?q=%s`
446
- - Brave: `https://search.brave.com/search?q=%s`
474
+ - `searchEngines` — map of engine name → URL template, where `%s` is replaced by the URL-encoded query. These are **merged** with the built-ins (`google`, `ddg`, `bing`, `brave`, `npm`, `gh`, `mdn`, `so`, `yt`, `wiki`), so you can add new ones or override existing ones.
475
+ - `defaultSearchEngine` — the engine `--search` uses when `--engine` isn't given (default: `google`).
476
+
477
+ > A legacy single `"searchEngine": "…"` string is still honored and appears as the `custom` engine.
447
478
 
448
479
  **`history.json`** stores your most recently opened targets (capped, de-duplicated) and powers `--recent`.
449
480
 
@@ -528,6 +559,8 @@ How `summon` stacks up against common alternatives:
528
559
  | Auto `https://` for bare domains | ✅ | ❌ | ❌ | ❌ | ❌ |
529
560
  | Bookmarks / aliases | ✅ | ❌ | ❌ | ❌ | ❌ |
530
561
  | Web search shortcut | ✅ | ❌ | ❌ | ❌ | ❌ |
562
+ | Configurable search engines | ✅ | ❌ | ❌ | ❌ | ❌ |
563
+ | Fuzzy interactive picker | ✅ | ❌ | ❌ | ❌ | ❌ |
531
564
  | Open from clipboard | ✅ | ❌ | ❌ | ❌ | ❌ |
532
565
  | Reveal in file manager | ✅ | ❌ | ⚠️ `-R` | ⚠️ | ❌ |
533
566
  | Recent / interactive picker | ✅ | ❌ | ❌ | ❌ | ❌ |
@@ -556,6 +589,33 @@ The value shows up most when you compare the actual commands for the same job.
556
589
  | Preview without launching | `summon x.com --dry-run` | no native equivalent |
557
590
  | Pipe + auto-detect type | `cat f \| summon` | no native equivalent |
558
591
 
592
+ ### vs other npm openers
593
+
594
+ The npm ecosystem has a few ways to open things. `summon` builds on the excellent [`open`](https://github.com/sindresorhus/open) library (the programmatic API) and starts from [`open-cli`](https://github.com/sindresorhus/open-cli), then adds a workflow layer on top.
595
+
596
+ | Capability | **summon** | [`open-cli`](https://www.npmjs.com/package/open-cli) | [`opener`](https://www.npmjs.com/package/opener) | [`open`](https://www.npmjs.com/package/open) |
597
+ | --- | :---: | :---: | :---: | :---: |
598
+ | Ships a CLI command | ✅ | ✅ | ✅ | ❌ (library) |
599
+ | Cross-platform | ✅ | ✅ | ✅ | ✅ |
600
+ | Open URL / file / folder / app | ✅ | ✅ | ✅ | ✅ (API) |
601
+ | Choose app + pass args (`-- app …`) | ✅ | ✅ | ❌ | ✅ (API) |
602
+ | Wait / background | ✅ | ✅ | ❌ | ✅ (API) |
603
+ | Stdin + file-type detection | ✅ | ✅ | ❌ | ❌ |
604
+ | Multiple targets in one call | ✅ | ❌ | ❌ | ❌ |
605
+ | Auto `https://` for bare domains | ✅ | ❌ | ❌ | ❌ |
606
+ | Bookmarks / aliases | ✅ | ❌ | ❌ | ❌ |
607
+ | Web search + configurable engines | ✅ | ❌ | ❌ | ❌ |
608
+ | Open from clipboard | ✅ | ❌ | ❌ | ❌ |
609
+ | Reveal in file manager | ✅ | ❌ | ❌ | ❌ |
610
+ | Recent + fuzzy picker | ✅ | ❌ | ❌ | ❌ |
611
+ | Dry-run preview | ✅ | ❌ | ❌ | ❌ |
612
+ | Typed exit codes + friendly errors | ✅ | ⚠️ | ⚠️ | n/a |
613
+ | Shell completions | ✅ | ❌ | ❌ | n/a |
614
+
615
+ ✅ yes · ⚠️ basic/partial · ❌ no · n/a not applicable
616
+
617
+ **In short:** reach for [`open`](https://www.npmjs.com/package/open) when you need a programmatic API inside Node code; reach for `open-cli` or `opener` for a bare "open this one thing" command; reach for **summon** when you want that same reliability *plus* multiple targets, bookmarks, search, clipboard, reveal, a fuzzy picker, and dry-run in day-to-day terminal use.
618
+
559
619
  ## Efficiency
560
620
 
561
621
  `summon` is designed to add as little overhead as possible on top of the native OS handler.
@@ -641,6 +701,20 @@ git config --global core.editor "summon --wait"
641
701
  **A non-existent `report.pdf` didn't open a website — good?**
642
702
  Yes. Names ending in a known file extension are treated as files, so typos give a clear "not found" (exit code 2) instead of launching a browser.
643
703
 
704
+ ## Releasing
705
+
706
+ Maintainers: releases publish to npm automatically when a version tag is pushed (see [`.github/workflows/release.yml`](.github/workflows/release.yml)).
707
+
708
+ 1. One-time: add an npm automation token as the `NPM_TOKEN` repository secret.
709
+ 2. Cut a release:
710
+
711
+ ```sh
712
+ npm version patch # or minor / major — bumps package.json and tags
713
+ git push --follow-tags
714
+ ```
715
+
716
+ The workflow runs the tests, then publishes with npm provenance. See the [changelog](changelog.md) for release history.
717
+
644
718
  ## Related
645
719
 
646
720
  - [open](https://github.com/sindresorhus/open) — the programmatic API that powers this CLI.