summon-open 1.0.0 → 1.1.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 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.0",
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
@@ -21,9 +21,10 @@ Bookmarks, search, clipboard, reveal-in-file-manager, dry-run, multiple targets,
21
21
  </div>
22
22
 
23
23
  <!--
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.:
24
+ DEMO: a ready-to-run VHS script lives at media/demo.tape.
25
+ 1. Install VHS: https://github.com/charmbracelet/vhs
26
+ 2. From the repo root, run: vhs media/demo.tape
27
+ 3. It writes media/demo.gif — then uncomment the line below.
27
28
  <div align="center"><img src="media/demo.gif" alt="summon demo" width="700"></div>
28
29
  -->
29
30
 
@@ -107,10 +108,10 @@ summon "$url"
107
108
  - **Multiple targets** — `summon a.pdf b.png https://x.com` opens them all.
108
109
  - **Smart URL normalization** — `summon github.com` just works; the `https://` is added for you.
109
110
  - **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.
111
+ - **Web search** — `summon -s "query"` searches with a configurable engine (`-e mdn`, `-e npm`, …).
111
112
  - **Clipboard mode** — `summon -c` opens whatever URL/path you just copied.
112
113
  - **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.
114
+ - **Recent + fuzzy picker** — re-open recent items or fuzzy-search a menu of bookmarks/history.
114
115
  - **Dry run** — `--dry-run` prints exactly what would happen without doing it.
115
116
  - **Pick the app** — force a specific app and pass it arguments.
116
117
  - **Pipe-friendly** — stream data over stdin; the file type is auto-detected.
@@ -130,12 +131,19 @@ This installs the `summon` command. Or run it once without installing:
130
131
  npx summon-open https://github.com
131
132
  ```
132
133
 
133
- <details>
134
- <summary>Other install channels</summary>
134
+ **Homebrew** (macOS/Linux):
135
135
 
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.
136
+ ```sh
137
+ brew install --formula https://raw.githubusercontent.com/Aditya060806/summon/main/dist/homebrew/summon-open.rb
138
+ ```
139
+
140
+ **Scoop** (Windows):
141
+
142
+ ```powershell
143
+ scoop install https://raw.githubusercontent.com/Aditya060806/summon/main/dist/scoop/summon-open.json
144
+ ```
137
145
 
138
- </details>
146
+ Both channels install the same `summon` command and depend on Node.js. The manifests live in [`dist/`](dist).
139
147
 
140
148
  ## Quick start
141
149
 
@@ -191,6 +199,8 @@ The whole tool at a glance — task on the left, command on the right.
191
199
  | List bookmarks | `summon --bookmarks` |
192
200
  | Remove a bookmark | `summon --remove-bookmark docs` |
193
201
  | Search the web | `summon -s "query here"` |
202
+ | Search with a specific engine | `summon -s "flatMap" -e mdn` |
203
+ | List search engines | `summon --engines` |
194
204
  | Open clipboard URL | `summon -c` |
195
205
  | Reveal in file manager | `summon report.pdf -r` |
196
206
  | Re-open something recent | `summon --recent` |
@@ -243,6 +253,8 @@ $ summon --help
243
253
  | `--extension` | | string | auto | Extension to use for piped stdin when the type can't be auto-detected. |
244
254
  | `--dry-run` | `-n` | boolean | `false` | Print what would be opened without launching anything. |
245
255
  | `--search` | `-s` | boolean | `false` | Treat the input as a web search query. |
256
+ | `--engine <name>` | `-e` | string | config | Search engine to use with `--search`. See `--engines`. |
257
+ | `--engines` | | boolean | `false` | List available search engines (`*` marks the default). |
246
258
  | `--clipboard` | `-c` | boolean | `false` | Open the URL/path currently on the clipboard. |
247
259
  | `--reveal` | `-r` | boolean | `false` | Reveal/highlight the file or folder in the file manager. |
248
260
  | `--recent` | | boolean | `false` | Interactively pick from recently opened items. |
@@ -292,7 +304,15 @@ Bookmarks live in your [config file](#configuration) and can also be edited by h
292
304
  summon -s "how to exit vim"
293
305
  ```
294
306
 
295
- The engine is configurable see [Configuration](#configuration). The default is Google.
307
+ Pick an engine per search with `--engine`/`-e`, and list what's available with `--engines`:
308
+
309
+ ```sh
310
+ summon -s "flatMap" -e mdn
311
+ summon -s "left-pad" -e npm
312
+ summon --engines
313
+ ```
314
+
315
+ 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
316
 
297
317
  ### Clipboard
298
318
 
@@ -326,7 +346,7 @@ summon report.pdf --reveal
326
346
  summon --recent
327
347
  ```
328
348
 
329
- Run `summon` with no arguments in a terminal and it shows an interactive menu of your bookmarks and recent items.
349
+ 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
350
 
331
351
  ### Dry run
332
352
 
@@ -436,14 +456,19 @@ You can override it with the `SUMMON_CONFIG_DIR` environment variable (also hand
436
456
  "docs": "https://docs.example.com",
437
457
  "repo": "https://github.com/Aditya060806/summon"
438
458
  },
439
- "searchEngine": "https://www.google.com/search?q=%s"
459
+ "defaultSearchEngine": "ddg",
460
+ "searchEngines": {
461
+ "work": "https://intranet.example.com/search?q=%s",
462
+ "scholar": "https://scholar.google.com/scholar?q=%s"
463
+ }
440
464
  }
441
465
  ```
442
466
 
443
467
  - `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`
468
+ - `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.
469
+ - `defaultSearchEngine` — the engine `--search` uses when `--engine` isn't given (default: `google`).
470
+
471
+ > A legacy single `"searchEngine": "…"` string is still honored and appears as the `custom` engine.
447
472
 
448
473
  **`history.json`** stores your most recently opened targets (capped, de-duplicated) and powers `--recent`.
449
474
 
@@ -528,6 +553,8 @@ How `summon` stacks up against common alternatives:
528
553
  | Auto `https://` for bare domains | ✅ | ❌ | ❌ | ❌ | ❌ |
529
554
  | Bookmarks / aliases | ✅ | ❌ | ❌ | ❌ | ❌ |
530
555
  | Web search shortcut | ✅ | ❌ | ❌ | ❌ | ❌ |
556
+ | Configurable search engines | ✅ | ❌ | ❌ | ❌ | ❌ |
557
+ | Fuzzy interactive picker | ✅ | ❌ | ❌ | ❌ | ❌ |
531
558
  | Open from clipboard | ✅ | ❌ | ❌ | ❌ | ❌ |
532
559
  | Reveal in file manager | ✅ | ❌ | ⚠️ `-R` | ⚠️ | ❌ |
533
560
  | Recent / interactive picker | ✅ | ❌ | ❌ | ❌ | ❌ |
@@ -641,6 +668,20 @@ git config --global core.editor "summon --wait"
641
668
  **A non-existent `report.pdf` didn't open a website — good?**
642
669
  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
670
 
671
+ ## Releasing
672
+
673
+ Maintainers: releases publish to npm automatically when a version tag is pushed (see [`.github/workflows/release.yml`](.github/workflows/release.yml)).
674
+
675
+ 1. One-time: add an npm automation token as the `NPM_TOKEN` repository secret.
676
+ 2. Cut a release:
677
+
678
+ ```sh
679
+ npm version patch # or minor / major — bumps package.json and tags
680
+ git push --follow-tags
681
+ ```
682
+
683
+ The workflow runs the tests, then publishes with npm provenance. See the [changelog](changelog.md) for release history.
684
+
644
685
  ## Related
645
686
 
646
687
  - [open](https://github.com/sindresorhus/open) — the programmatic API that powers this CLI.