summon-cli 0.2.0 → 0.4.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/README.md CHANGED
@@ -6,36 +6,134 @@
6
6
 
7
7
  Terminal launcher for local AI CLIs. Run `summon`, pick a tool, launch it.
8
8
 
9
- Supported: Codex CLI, Claude Code, Antigravity CLI, Cursor CLI, GitHub Copilot CLI, opencode CLI. Missing tools show dimmed.
9
+ Built-in: Codex CLI, Claude Code, Antigravity CLI, Cursor CLI, GitHub Copilot CLI, opencode CLI. Missing tools show dimmed. Add your own with `--add`.
10
10
 
11
- > Pre-release (0.1.0).
11
+ > Pre-release (0.4.0).
12
12
 
13
13
  ## Install
14
14
 
15
15
  ```sh
16
16
  npm install -g summon-cli
17
+ ```
18
+
19
+ ## First run
20
+
21
+ The first launch walks you through a one-time setup (and keeps asking until you finish it):
22
+
23
+ ```sh
17
24
  summon
18
25
  ```
19
26
 
20
- Move with arrows or `j/k` or `1-9`. `Enter` launches, `Esc` quits.
27
+ Two steps:
28
+
29
+ 1. **Command name** — pick what you'll type to open the launcher. The default is `cli`; `summon` always keeps working. Picking `cli` (or a custom name) installs a small shim in `~/.local/bin`.
30
+ 2. **Side logo** — show the animated logo next to the menu, or keep it compact.
31
+
32
+ Your choices are saved to the [config](#config). Re-run the wizard anytime:
33
+
34
+ ```sh
35
+ summon setup
36
+ ```
37
+
38
+ > If `~/.local/bin` isn't on your `PATH`, the wizard tells you — add it so your new command name resolves:
39
+ > ```sh
40
+ > export PATH="$HOME/.local/bin:$PATH" # add to ~/.zshrc or ~/.bashrc
41
+ > ```
42
+
43
+ ## Daily use
44
+
45
+ Open the picker and launch a tool:
46
+
47
+ ```sh
48
+ cli # or: summon
49
+ ```
50
+
51
+ - **Move** — `↑/↓`, `j/k`, or `1`–`9`
52
+ - **Launch** — `Enter`
53
+ - **Quit** — `Esc`
54
+
55
+ Missing tools show dimmed. The side logo hides automatically on small terminals.
21
56
 
22
- ## Commands
57
+ Pass arguments straight through to the launched tool with `--`:
23
58
 
24
- - `summon` open the picker
25
- - `summon reorder` set the order
26
- - `summon default <tool>` start the cursor on it (`off` clears, no arg = pick)
27
- - `summon alias <name>` add another command name (e.g. `summon alias cli`), or shortcut `summon --add <name>`
28
- - `summon help`
59
+ ```sh
60
+ cli -- --version
61
+ ```
62
+
63
+ ## Customize
29
64
 
30
- Flags: `--no-logo` / `--logo` toggle the side logo (remembered). Args after `--` go to the launched tool.
65
+ Add your own CLI with an interactive form (one field at a time command, name, subtitle):
66
+
67
+ ```sh
68
+ cli add
69
+ ```
70
+
71
+ <p align="center">
72
+ <img src="assets/add-form.png" alt="Filling in the add form" width="640">
73
+ <br>
74
+ <em>Fill each field, then <code>Enter</code> on the last one to save…</em>
75
+ <br><br>
76
+ <img src="assets/add-result.png" alt="The new shortcut in the picker" width="640">
77
+ <br>
78
+ <em>…and it shows up in the picker.</em>
79
+ </p>
80
+
81
+ Or add one in a single line:
82
+
83
+ ```sh
84
+ cli --add grok "Grok Build" # runs `grok`, shows as "Grok Build"
85
+ cli --remove grok # remove it again
86
+ ```
87
+
88
+ Set the order tools appear in, or where the cursor starts:
89
+
90
+ ```sh
91
+ cli reorder # arrange the list
92
+ cli default claude # open with the cursor on Claude
93
+ cli default off # clear it
94
+ ```
95
+
96
+ Toggle the side logo without re-running setup (remembered):
97
+
98
+ ```sh
99
+ cli --logo
100
+ cli --no-logo
101
+ ```
102
+
103
+ Install an extra command name for the launcher at any time:
104
+
105
+ ```sh
106
+ cli alias zap # now `zap` opens it too
107
+ ```
108
+
109
+ ## Commands & flags
110
+
111
+ ```text
112
+ cli open the picker
113
+ cli reorder set the order tools appear in
114
+ cli default [tool] start the cursor on <tool> (off clears, no arg = pick)
115
+ cli alias <name> install another shell command name for the launcher
116
+ cli add add a custom CLI via an interactive form
117
+ cli setup re-run the first-run setup (command name + logo)
118
+ cli help show help
119
+
120
+ --add [cmd] [label] add a custom CLI (no args opens the form)
121
+ --remove <id> remove a custom CLI
122
+ --logo / --no-logo show / hide the side logo (remembered)
123
+ -- <args...> pass everything after -- to the launched tool
124
+ ```
31
125
 
32
126
  ## Config
33
127
 
34
- `~/.config/summon-cli/config.json`
128
+ ```text
129
+ ~/.config/summon-cli/config.json
130
+ ```
131
+
132
+ Holds your order, default tool, custom tools, logo preference, and the setup flag. Safe to delete — the next run rebuilds it and re-runs setup.
35
133
 
36
134
  ## Requirements
37
135
 
38
- Node 18+, a TrueColor terminal, the target CLIs on PATH.
136
+ Node 18+, a TrueColor terminal, and the target CLIs on your `PATH`.
39
137
 
40
138
  ## Trademarks
41
139
 
package/bin/cli.mjs CHANGED
@@ -8,12 +8,19 @@ import {join} from 'node:path';
8
8
  import {fileURLToPath} from 'node:url';
9
9
  import React from 'react';
10
10
  import {render} from 'ink';
11
- import {App, ReorderApp, renderSnapshot, tools, orderTools} from '../src/app.mjs';
11
+ import {App, ReorderApp, SetupApp, AddApp, renderSnapshot, tools, orderTools} from '../src/app.mjs';
12
12
  import {loadConfig, saveConfig, configLocation} from '../src/config.mjs';
13
13
 
14
14
  const PROG = process.env.CLI_LEVEL_NAME || 'summon';
15
15
  const SCRIPT = fileURLToPath(import.meta.url);
16
16
 
17
+ const ALIAS_OPTIONS = [
18
+ {name: 'summon', hint: 'the original name'},
19
+ {name: 'cli', hint: 'short', default: true},
20
+ {kind: 'custom', label: 'Custom…', hint: 'type your own'},
21
+ {kind: 'skip', label: 'Skip', hint: 'set one up later'}
22
+ ];
23
+
17
24
  const passthroughIndex = process.argv.indexOf('--');
18
25
  const forwardedArgs = passthroughIndex === -1 ? [] : process.argv.slice(passthroughIndex + 1);
19
26
  const rawArgs = passthroughIndex === -1 ? process.argv.slice(2) : process.argv.slice(2, passthroughIndex);
@@ -31,10 +38,11 @@ if (flags.has('--no-logo')) {
31
38
  logo = true;
32
39
  saveConfig({logo: true});
33
40
  }
34
- const items = orderTools(config.order);
41
+ const items = orderTools(config.order, config.customTools);
42
+ const canRenderTui = Boolean(process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb');
35
43
 
36
44
  if (process.env.CLI_LEVEL_SNAPSHOT === '1') {
37
- process.stdout.write(renderSnapshot(Number(process.env.CLI_LEVEL_ACTIVE || 0)));
45
+ process.stdout.write(renderSnapshot(Number(process.env.CLI_LEVEL_ACTIVE || 0), items));
38
46
  process.exit(0);
39
47
  }
40
48
 
@@ -44,11 +52,18 @@ if (flags.has('--help') || flags.has('-h') || command === 'help') {
44
52
  }
45
53
 
46
54
  if (flags.has('--add')) {
47
- runAlias(args[0]);
55
+ if (args[0]) {
56
+ addCustomTool(args[0], args[1]);
57
+ process.exit(0);
58
+ }
59
+ await runAddForm();
48
60
  process.exit(0);
49
61
  }
50
62
 
51
- const canRenderTui = Boolean(process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb');
63
+ if (flags.has('--remove') || flags.has('--rm')) {
64
+ runRemove(args[0]);
65
+ process.exit(0);
66
+ }
52
67
 
53
68
  switch (command) {
54
69
  case 'reorder':
@@ -60,6 +75,12 @@ switch (command) {
60
75
  case 'alias':
61
76
  runAlias(args[1]);
62
77
  break;
78
+ case 'add':
79
+ await runAddForm();
80
+ break;
81
+ case 'setup':
82
+ await runSetupCommand();
83
+ break;
63
84
  case undefined:
64
85
  await runMenu();
65
86
  break;
@@ -70,11 +91,13 @@ switch (command) {
70
91
 
71
92
  async function runMenu() {
72
93
  if (!canRenderTui) {
73
- process.stdout.write(renderSnapshot(0));
94
+ process.stdout.write(renderSnapshot(0, items));
74
95
  process.stderr.write(`${PROG}: interactive terminal required for selection.\n`);
75
96
  process.exit(2);
76
97
  }
77
98
 
99
+ await maybeFirstRunSetup();
100
+
78
101
  clearScreen();
79
102
  const selected = await chooseTool();
80
103
  if (!selected) {
@@ -91,6 +114,7 @@ async function runReorder() {
91
114
  const order = await new Promise(resolve => {
92
115
  let instance;
93
116
  instance = render(React.createElement(ReorderApp, {
117
+ items,
94
118
  onCancel: () => {
95
119
  instance.unmount();
96
120
  resolve(null);
@@ -140,9 +164,143 @@ async function runDefault(target) {
140
164
  process.stdout.write(`Default set to ${selected.label}. The menu now opens with the cursor on it.\n`);
141
165
  }
142
166
 
167
+ async function runAddForm() {
168
+ requireTui('add');
169
+ clearScreen();
170
+ const result = await new Promise(resolve => {
171
+ let instance;
172
+ instance = render(React.createElement(AddApp, {
173
+ onSubmit: data => {
174
+ instance.unmount();
175
+ resolve(data);
176
+ },
177
+ onCancel: () => {
178
+ instance.unmount();
179
+ resolve(null);
180
+ }
181
+ }));
182
+ });
183
+
184
+ if (!result) {
185
+ process.stdout.write('Add cancelled.\n');
186
+ return;
187
+ }
188
+
189
+ addCustomTool(result.command, result.label, result.hint);
190
+ }
191
+
192
+ function addCustomTool(name, label, hint) {
193
+ if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
194
+ process.stderr.write(`${PROG}: usage: ${PROG} --add <command> [label]\n`);
195
+ process.exit(2);
196
+ }
197
+
198
+ const id = name.toLowerCase();
199
+ if (tools.some(t => t.id === id)) {
200
+ process.stderr.write(`${PROG}: '${id}' is a built-in tool already.\n`);
201
+ process.exit(2);
202
+ }
203
+
204
+ const current = loadConfig().customTools || [];
205
+ if (current.some(t => t.id === id)) {
206
+ process.stderr.write(`${PROG}: '${id}' is already in your list.\n`);
207
+ process.exit(2);
208
+ }
209
+
210
+ const labelText = label || (name.charAt(0).toUpperCase() + name.slice(1));
211
+ const entry = {id, label: labelText, command: name, hint: hint || 'Custom provider'};
212
+ saveConfig({customTools: [...current, entry]});
213
+ process.stdout.write(`Added '${labelText}' (command: ${name}) to the menu.\n`);
214
+ }
215
+
216
+ function runRemove(name) {
217
+ if (!name) {
218
+ process.stderr.write(`${PROG}: usage: ${PROG} --remove <id>\n`);
219
+ process.exit(2);
220
+ }
221
+
222
+ const id = name.toLowerCase();
223
+ const current = loadConfig().customTools || [];
224
+ const next = current.filter(t => t.id !== id);
225
+ if (next.length === current.length) {
226
+ process.stderr.write(`${PROG}: no custom tool '${id}'. List: ${current.map(t => t.id).join(', ') || '(none)'}.\n`);
227
+ process.exit(2);
228
+ }
229
+
230
+ saveConfig({customTools: next});
231
+ process.stdout.write(`Removed '${id}'.\n`);
232
+ }
233
+
234
+ function runSetup() {
235
+ return new Promise(resolve => {
236
+ let instance;
237
+ instance = render(React.createElement(SetupApp, {
238
+ aliasOptions: ALIAS_OPTIONS,
239
+ onComplete: result => {
240
+ instance.unmount();
241
+ resolve(result);
242
+ },
243
+ onCancel: () => {
244
+ instance.unmount();
245
+ resolve(null);
246
+ }
247
+ }));
248
+ });
249
+ }
250
+
251
+ async function maybeFirstRunSetup() {
252
+ // Already launched through an installed alias shim — nothing to set up.
253
+ if (process.env.CLI_LEVEL_NAME) {
254
+ saveConfig({setupDone: true});
255
+ return;
256
+ }
257
+ if (loadConfig().setupDone) {
258
+ return;
259
+ }
260
+
261
+ const result = await runSetup();
262
+ if (!result) {
263
+ // Bailed out without finishing — leave setupDone false so we ask again next launch.
264
+ return;
265
+ }
266
+ saveConfig({setupDone: true});
267
+
268
+ // Installing a brand-new alias prints instructions — stop before the menu so they stay visible.
269
+ if (applySetup(result)) {
270
+ process.exit(0);
271
+ }
272
+ }
273
+
274
+ async function runSetupCommand() {
275
+ requireTui('setup');
276
+ clearScreen();
277
+ const result = await runSetup();
278
+ if (!result) {
279
+ process.stdout.write('Setup cancelled.\n');
280
+ return;
281
+ }
282
+ saveConfig({setupDone: true});
283
+ applySetup(result);
284
+ process.stdout.write('Setup saved.\n');
285
+ }
286
+
287
+ // Persist the wizard's choices. Returns true if a new alias shim was installed.
288
+ function applySetup(result) {
289
+ saveConfig({logo: result.logo});
290
+ logo = result.logo;
291
+
292
+ const chosen = result.alias;
293
+ if (chosen && chosen.name && !commandExists(chosen.name)) {
294
+ runAlias(chosen.name);
295
+ process.stdout.write(`\nStart it with: ${chosen.name}\n`);
296
+ return true;
297
+ }
298
+ return false;
299
+ }
300
+
143
301
  function runAlias(name) {
144
302
  if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
145
- process.stderr.write(`${PROG}: usage: ${PROG} alias <name> (or ${PROG} --add <name>)\n`);
303
+ process.stderr.write(`${PROG}: usage: ${PROG} alias <name>\n`);
146
304
  process.exit(2);
147
305
  }
148
306
 
@@ -243,12 +401,14 @@ Commands:
243
401
  (none) Open the picker
244
402
  reorder Set the order tools appear in
245
403
  default [tool] Start the cursor on <tool>; no tool = pick one; 'off' clears
246
- alias <name> Install a second command name for this launcher
247
- (shortcut: ${PROG} --add <name>)
404
+ alias <name> Install a second shell command name for this launcher
405
+ add Add a custom CLI via an interactive form
406
+ setup Re-run the first-run setup (command name + logo)
248
407
  help Show this help
249
408
 
250
409
  Options:
251
- --add <name> Install a second command name (same as 'alias')
410
+ --add [cmd] [lbl] Add a custom CLI (no args opens the form; e.g. --add grok "Grok Build")
411
+ --remove <id> Remove a custom CLI from the menu
252
412
  --no-logo Hide the side logo and remember it
253
413
  --logo Show the side logo and remember it
254
414
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "summon-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Summon your AI CLI. Terminal launcher for Codex CLI, Claude Code, Antigravity CLI, Cursor CLI, GitHub Copilot CLI, and opencode CLI.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "summon": "./bin/cli.mjs"
7
+ "summon": "bin/cli.mjs"
8
8
  },
9
9
  "files": [
10
10
  "bin",
package/src/app.mjs CHANGED
@@ -12,13 +12,6 @@ const dimGray = '#3f4348';
12
12
  const white = '#ffffff';
13
13
 
14
14
  export const tools = [
15
- {
16
- id: 'codex',
17
- label: 'Codex',
18
- command: 'codex',
19
- hint: 'OpenAI',
20
- palette: ['#4750d8', '#6b75f2', '#9aa2ff', '#cdd2ff', '#5b63e6', '#3d44c4']
21
- },
22
15
  {
23
16
  id: 'claude',
24
17
  label: 'Claude',
@@ -27,11 +20,11 @@ export const tools = [
27
20
  palette: ['#ca7c5e', '#e3a888', '#b5654a']
28
21
  },
29
22
  {
30
- id: 'antigravity',
31
- label: 'Antigravity',
32
- command: 'agy',
33
- hint: 'Google',
34
- palette: ['#2e88f5', '#3fb0a0', '#66b37f', '#f0883e', '#e15550', '#8d77c4', '#4f7ce0']
23
+ id: 'codex',
24
+ label: 'Codex',
25
+ command: 'codex',
26
+ hint: 'OpenAI',
27
+ palette: ['#4750d8', '#6b75f2', '#9aa2ff', '#cdd2ff', '#5b63e6', '#3d44c4']
35
28
  },
36
29
  {
37
30
  id: 'cursor',
@@ -40,6 +33,13 @@ export const tools = [
40
33
  hint: 'Anysphere',
41
34
  palette: ['#ffffff', '#111111']
42
35
  },
36
+ {
37
+ id: 'antigravity',
38
+ label: 'Antigravity',
39
+ command: 'agy',
40
+ hint: 'Google',
41
+ palette: ['#2e88f5', '#3fb0a0', '#66b37f', '#f0883e', '#e15550', '#8d77c4', '#4f7ce0']
42
+ },
43
43
  {
44
44
  id: 'copilot',
45
45
  label: 'Copilot',
@@ -85,22 +85,6 @@ const LOGO_DATA = {
85
85
  [g('▄', '#6dc694'), g('▀', '#61c37d', '#62bad5'), g('▀', '#43aeab', '#47a8dc'), g(' '), g(' '), g(' '), g(' '), g('▀', '#4a80ea', '#3d89fb'), g('▀', '#6c73d8', '#4a81f0'), g('▄', '#6579e1')],
86
86
  [g('▄', '#67b9f4'), g('▀', '#6bc7a3', '#64b6f6'), g('▀', '#64b6f6'), g(' '), g(' '), g(' '), g(' '), g(' '), g(' '), g('▀', '#3886fb'), g('▀', '#4881f4', '#3883f9'), g('▄', '#3d85fc')]
87
87
  ],
88
- cursor: [
89
- shade(' ▓███▓ '),
90
- shade(' ▒█████████▒ '),
91
- shade(' ░███████████████░ '),
92
- shade(' ███████████████████ '),
93
- shade('░███░ ▒█░'),
94
- shade('░█████▓ ░██░'),
95
- shade('░████████▒ ░███░'),
96
- shade('░█████████▓ ████░'),
97
- shade('░█████████▓ █████░'),
98
- shade('░█████████▓ ██████░'),
99
- shade(' █████████▓ ███████ '),
100
- shade(' ░███████▓ ██████░ '),
101
- shade(' ▒████▓▓███▒ '),
102
- shade(' ▓███▓ ')
103
- ],
104
88
  copilot: [
105
89
  solid('╭─╮╭─╮', '#99dbdf'),
106
90
  solid('╰─╯╰─╯', '#99dbdf'),
@@ -165,6 +149,7 @@ export function App({items = tools, logo = false, initialId = null, onSelect, on
165
149
  }, [items]);
166
150
 
167
151
  const width = stdout?.columns || 80;
152
+ const height = stdout?.rows || 24;
168
153
 
169
154
  useEffect(() => {
170
155
  const timer = setInterval(() => setTick(value => value + 1), 110);
@@ -221,7 +206,8 @@ export function App({items = tools, logo = false, initialId = null, onSelect, on
221
206
  }
222
207
  });
223
208
 
224
- const showLogo = logo && width >= 74;
209
+ // Compact mode: skip the logo when the terminal can't comfortably hold it.
210
+ const showLogo = logo && width >= 74 && height >= LOGO_H + 6;
225
211
 
226
212
  return (
227
213
  React.createElement(Box, {flexDirection: 'column', paddingX: 2, paddingY: 1},
@@ -249,15 +235,15 @@ export function App({items = tools, logo = false, initialId = null, onSelect, on
249
235
  );
250
236
  }
251
237
 
252
- export function ReorderApp({onDone, onCancel}) {
238
+ export function ReorderApp({items = tools, onDone, onCancel}) {
253
239
  const {exit} = useApp();
254
240
  const [order, setOrder] = useState([]);
255
241
  const [active, setActive] = useState(0);
256
242
 
257
243
  const seekUnpicked = (from, dir, taken) => {
258
- for (let step = 0; step <= tools.length; step += 1) {
259
- const index = (from + dir * step + tools.length * (step + 1)) % tools.length;
260
- if (!taken.has(tools[index].id)) {
244
+ for (let step = 0; step <= items.length; step += 1) {
245
+ const index = (from + dir * step + items.length * (step + 1)) % items.length;
246
+ if (!taken.has(items[index].id)) {
261
247
  return index;
262
248
  }
263
249
  }
@@ -290,13 +276,13 @@ export function ReorderApp({onDone, onCancel}) {
290
276
  }
291
277
 
292
278
  if (key.return) {
293
- const id = tools[active].id;
279
+ const id = items[active].id;
294
280
  if (taken.has(id)) {
295
281
  return;
296
282
  }
297
283
 
298
284
  const next = [...order, id];
299
- if (next.length === tools.length) {
285
+ if (next.length === items.length) {
300
286
  exit();
301
287
  onDone(next);
302
288
  return;
@@ -314,7 +300,7 @@ export function ReorderApp({onDone, onCancel}) {
314
300
  React.createElement(Text, {color: dimGray}, ' pick first to last')
315
301
  ),
316
302
  React.createElement(Box, {flexDirection: 'column', gap: 0},
317
- tools.map((tool, index) => {
303
+ items.map((tool, index) => {
318
304
  const pos = order.indexOf(tool.id);
319
305
  const picked = pos >= 0;
320
306
  const isActive = index === active && !picked;
@@ -339,6 +325,267 @@ export function ReorderApp({onDone, onCancel}) {
339
325
  );
340
326
  }
341
327
 
328
+ // First-run wizard: step 1 picks the command name to install, step 2 toggles the logo.
329
+ export function SetupApp({aliasOptions, onComplete, onCancel}) {
330
+ const {exit} = useApp();
331
+ const [phase, setPhase] = useState('alias');
332
+ const [active, setActive] = useState(() => {
333
+ const index = aliasOptions.findIndex(option => option.default);
334
+ return index >= 0 ? index : 0;
335
+ });
336
+ const [text, setText] = useState('');
337
+ const [error, setError] = useState(false);
338
+ const [alias, setAlias] = useState(null);
339
+ const [logoActive, setLogoActive] = useState(0);
340
+
341
+ useInput((input, key) => {
342
+ if (key.ctrl && input === 'c') {
343
+ exit();
344
+ onCancel();
345
+ return;
346
+ }
347
+
348
+ if (phase === 'custom') {
349
+ if (key.escape) {
350
+ setPhase('alias');
351
+ setText('');
352
+ setError(false);
353
+ return;
354
+ }
355
+ if (key.return) {
356
+ const name = text.trim();
357
+ if (/^[a-zA-Z0-9._-]+$/.test(name)) {
358
+ setAlias({name});
359
+ setPhase('logo');
360
+ } else {
361
+ setError(true);
362
+ }
363
+ return;
364
+ }
365
+ if (key.backspace || key.delete) {
366
+ setText(value => value.slice(0, -1));
367
+ setError(false);
368
+ return;
369
+ }
370
+ if (input && !key.ctrl && !key.meta) {
371
+ setText(value => value + input);
372
+ setError(false);
373
+ }
374
+ return;
375
+ }
376
+
377
+ if (phase === 'logo') {
378
+ if (key.escape) {
379
+ setPhase('alias');
380
+ return;
381
+ }
382
+ if (key.upArrow || key.downArrow || input === 'j' || input === 'k') {
383
+ setLogoActive(value => (value + 1) % 2);
384
+ return;
385
+ }
386
+ if (key.return) {
387
+ exit();
388
+ onComplete({alias, logo: logoActive === 0});
389
+ }
390
+ return;
391
+ }
392
+
393
+ // phase === 'alias'
394
+ if (key.escape) {
395
+ setAlias({skip: true});
396
+ setPhase('logo');
397
+ return;
398
+ }
399
+ if (key.upArrow || input === 'k') {
400
+ setActive(value => (value - 1 + aliasOptions.length) % aliasOptions.length);
401
+ return;
402
+ }
403
+ if (key.downArrow || input === 'j') {
404
+ setActive(value => (value + 1) % aliasOptions.length);
405
+ return;
406
+ }
407
+ if (key.return) {
408
+ const option = aliasOptions[active];
409
+ if (option.kind === 'custom') {
410
+ setPhase('custom');
411
+ return;
412
+ }
413
+ if (option.kind === 'skip') {
414
+ setAlias({skip: true});
415
+ setPhase('logo');
416
+ return;
417
+ }
418
+ setAlias({name: option.name});
419
+ setPhase('logo');
420
+ }
421
+ });
422
+
423
+ const frame = children => React.createElement(Box, {flexDirection: 'column', paddingX: 2, paddingY: 1}, children);
424
+
425
+ if (phase === 'custom') {
426
+ return frame([
427
+ React.createElement(Box, {key: 'h', marginBottom: 1},
428
+ React.createElement(Text, {bold: true, color: white}, 'Custom command name')
429
+ ),
430
+ React.createElement(Box, {key: 'i'},
431
+ React.createElement(Text, {color: gray}, 'name '),
432
+ React.createElement(Text, {bold: true, color: white}, '› '),
433
+ React.createElement(Text, {color: white}, text),
434
+ React.createElement(Text, {color: dimGray}, '▌')
435
+ ),
436
+ React.createElement(Box, {key: 'f', marginTop: 1},
437
+ React.createElement(Text, {color: error ? '#d96570' : dimGray},
438
+ error ? 'letters, digits, . _ - only' : 'enter confirm · esc back')
439
+ )
440
+ ]);
441
+ }
442
+
443
+ if (phase === 'logo') {
444
+ const logoOpts = [
445
+ {label: 'Show logo', hint: 'animated art beside the menu'},
446
+ {label: 'Hide logo', hint: 'compact, just the list'}
447
+ ];
448
+ return frame([
449
+ React.createElement(Box, {key: 'h', marginBottom: 1, flexDirection: 'column'},
450
+ React.createElement(Text, {bold: true, color: white}, 'Side logo'),
451
+ React.createElement(Text, {color: dimGray}, 'step 2 of 2')
452
+ ),
453
+ React.createElement(Box, {key: 'l', flexDirection: 'column'},
454
+ logoOpts.map((opt, index) => {
455
+ const isActive = index === logoActive;
456
+ return React.createElement(Box, {key: opt.label, height: 1},
457
+ React.createElement(Box, {width: 2},
458
+ React.createElement(Text, {bold: isActive, color: isActive ? white : dimGray}, isActive ? '›' : ' ')
459
+ ),
460
+ React.createElement(Box, {width: 12},
461
+ React.createElement(Text, {bold: isActive, color: isActive ? white : gray}, opt.label)
462
+ ),
463
+ React.createElement(Text, {color: dimGray}, opt.hint)
464
+ );
465
+ })
466
+ ),
467
+ React.createElement(Box, {key: 'f', marginTop: 1},
468
+ React.createElement(Text, {color: dimGray}, 'enter pick · ↑/↓ move · esc back')
469
+ )
470
+ ]);
471
+ }
472
+
473
+ // phase === 'alias'
474
+ return frame([
475
+ React.createElement(Box, {key: 'h', marginBottom: 1, flexDirection: 'column'},
476
+ React.createElement(Text, {bold: true, color: white}, 'Pick the command you’ll type'),
477
+ React.createElement(Text, {color: dimGray}, 'step 1 of 2 · installs a short name for this launcher')
478
+ ),
479
+ React.createElement(Box, {key: 'o', flexDirection: 'column'},
480
+ aliasOptions.map((option, index) => {
481
+ const isActive = index === active;
482
+ const label = option.name || option.label;
483
+ return React.createElement(Box, {key: label, height: 1},
484
+ React.createElement(Box, {width: 2},
485
+ React.createElement(Text, {bold: isActive, color: isActive ? white : dimGray}, isActive ? '›' : ' ')
486
+ ),
487
+ React.createElement(Box, {width: 14},
488
+ React.createElement(Text, {bold: isActive, color: isActive ? white : gray}, label)
489
+ ),
490
+ React.createElement(Text, {color: dimGray}, option.hint || '')
491
+ );
492
+ })
493
+ ),
494
+ React.createElement(Box, {key: 'f', marginTop: 1},
495
+ React.createElement(Text, {color: dimGray}, 'enter pick · ↑/↓ move · esc skip')
496
+ )
497
+ ]);
498
+ }
499
+
500
+ // Interactive form to add a custom shortcut: one editable field per value.
501
+ export function AddApp({onSubmit, onCancel}) {
502
+ const {exit} = useApp();
503
+ const fields = [
504
+ {key: 'command', label: 'Command', placeholder: 'binary to run, e.g. grok'},
505
+ {key: 'label', label: 'Name', placeholder: 'shown in the menu (optional)'},
506
+ {key: 'hint', label: 'Subtitle', placeholder: 'right-side text (optional)'}
507
+ ];
508
+ const [values, setValues] = useState({command: '', label: '', hint: ''});
509
+ const [active, setActive] = useState(0);
510
+ const [error, setError] = useState('');
511
+
512
+ const submit = () => {
513
+ const command = values.command.trim();
514
+ if (!/^[a-zA-Z0-9._-]+$/.test(command)) {
515
+ setError('command: letters, digits, . _ - only');
516
+ setActive(0);
517
+ return;
518
+ }
519
+ exit();
520
+ onSubmit({command, label: values.label.trim(), hint: values.hint.trim()});
521
+ };
522
+
523
+ useInput((input, key) => {
524
+ if (key.escape || (key.ctrl && input === 'c')) {
525
+ exit();
526
+ onCancel();
527
+ return;
528
+ }
529
+ if (key.tab || key.downArrow) {
530
+ setActive(value => (value + 1) % fields.length);
531
+ return;
532
+ }
533
+ if (key.upArrow) {
534
+ setActive(value => (value - 1 + fields.length) % fields.length);
535
+ return;
536
+ }
537
+ if (key.return) {
538
+ if (active < fields.length - 1) {
539
+ setActive(active + 1);
540
+ } else {
541
+ submit();
542
+ }
543
+ return;
544
+ }
545
+ const fieldKey = fields[active].key;
546
+ if (key.backspace || key.delete) {
547
+ setValues(value => ({...value, [fieldKey]: value[fieldKey].slice(0, -1)}));
548
+ setError('');
549
+ return;
550
+ }
551
+ if (input && !key.ctrl && !key.meta) {
552
+ setValues(value => ({...value, [fieldKey]: value[fieldKey] + input}));
553
+ setError('');
554
+ }
555
+ });
556
+
557
+ return React.createElement(Box, {flexDirection: 'column', paddingX: 2, paddingY: 1},
558
+ React.createElement(Box, {key: 'h', marginBottom: 1, flexDirection: 'column'},
559
+ React.createElement(Text, {bold: true, color: white}, 'Add a shortcut'),
560
+ React.createElement(Text, {color: dimGray}, 'tab/↑↓ move · enter next · enter on last saves · esc cancel')
561
+ ),
562
+ React.createElement(Box, {key: 'fields', flexDirection: 'column'},
563
+ fields.map((field, index) => {
564
+ const isActive = index === active;
565
+ const value = values[field.key];
566
+ return React.createElement(Box, {key: field.key, height: 1},
567
+ React.createElement(Box, {width: 2},
568
+ React.createElement(Text, {bold: isActive, color: isActive ? white : dimGray}, isActive ? '›' : ' ')
569
+ ),
570
+ React.createElement(Box, {width: 10},
571
+ React.createElement(Text, {color: isActive ? white : gray}, field.label)
572
+ ),
573
+ isActive
574
+ ? React.createElement(Box, null,
575
+ React.createElement(Text, {color: white}, value),
576
+ React.createElement(Text, {color: dimGray}, '▌'),
577
+ value ? null : React.createElement(Text, {color: dimGray}, ` ${field.placeholder}`)
578
+ )
579
+ : React.createElement(Text, {color: value ? gray : dimGray}, value || field.placeholder)
580
+ );
581
+ })
582
+ ),
583
+ React.createElement(Box, {key: 'f', marginTop: 1},
584
+ React.createElement(Text, {color: error ? '#d96570' : dimGray}, error || 'command is required')
585
+ )
586
+ );
587
+ }
588
+
342
589
  function Header({title, tick}) {
343
590
  return (
344
591
  React.createElement(Box, {marginBottom: 1},
@@ -509,9 +756,10 @@ function renderPlainLine(tool, index, activeIndex) {
509
756
  return `${arrow} ${label.padEnd(16)} ${tool.hint}`;
510
757
  }
511
758
 
512
- export function renderSnapshot(activeIndex = 0) {
513
- const bounded = Math.max(0, Math.min(tools.length - 1, activeIndex));
514
- return `${tools.map((tool, index) => renderPlainLine(tool, index, bounded)).join('\n')}\n`;
759
+ export function renderSnapshot(activeIndex = 0, items = null) {
760
+ const list = items || tools;
761
+ const bounded = Math.max(0, Math.min(list.length - 1, activeIndex));
762
+ return `${list.map((tool, index) => renderPlainLine(tool, index, bounded)).join('\n')}\n`;
515
763
  }
516
764
 
517
765
  function commandExists(command) {
@@ -545,8 +793,26 @@ function nextAvailable(current, availability, items) {
545
793
  }
546
794
 
547
795
  // Reorder canonical tools by an array of ids; unknown ids ignored, missing appended.
548
- export function orderTools(order = []) {
549
- const byId = new Map(tools.map(tool => [tool.id, tool]));
796
+ export function buildCustomTool({id, label, command, hint, palette} = {}) {
797
+ if (!id) return null;
798
+ return {
799
+ id,
800
+ label: label || id,
801
+ command: command || id,
802
+ hint: hint || 'Custom provider',
803
+ palette: palette && palette.length ? palette : ['#9aa0a6', '#cdcdcd']
804
+ };
805
+ }
806
+
807
+ export function orderTools(order = [], customTools = []) {
808
+ const all = [...tools];
809
+ for (const raw of customTools) {
810
+ const tool = buildCustomTool(raw);
811
+ if (!tool) continue;
812
+ if (all.some(t => t.id === tool.id)) continue;
813
+ all.push(tool);
814
+ }
815
+ const byId = new Map(all.map(tool => [tool.id, tool]));
550
816
  const result = [];
551
817
  for (const id of order) {
552
818
  if (byId.has(id)) {
@@ -554,7 +820,7 @@ export function orderTools(order = []) {
554
820
  byId.delete(id);
555
821
  }
556
822
  }
557
- for (const tool of tools) {
823
+ for (const tool of all) {
558
824
  if (byId.has(tool.id)) {
559
825
  result.push(tool);
560
826
  }
package/src/config.mjs CHANGED
@@ -8,7 +8,9 @@ const configPath = join(baseDir, 'summon-cli', 'config.json');
8
8
  const DEFAULTS = {
9
9
  order: [],
10
10
  logo: true,
11
- default: null
11
+ default: null,
12
+ customTools: [],
13
+ setupDone: false
12
14
  };
13
15
 
14
16
  export function loadConfig() {