summon-cli 0.3.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
@@ -8,43 +8,132 @@ Terminal launcher for local AI CLIs. Run `summon`, pick a tool, launch it.
8
8
 
9
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.2.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.
56
+
57
+ Pass arguments straight through to the launched tool with `--`:
58
+
59
+ ```sh
60
+ cli -- --version
61
+ ```
62
+
63
+ ## Customize
21
64
 
22
- ## Commands
65
+ Add your own CLI with an interactive form (one field at a time — command, name, subtitle):
23
66
 
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>` install a second shell command name for the launcher
28
- - `summon help`
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:
29
82
 
30
- ## Flags
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
+ ```
31
95
 
32
- - `--add <cmd> [label]` add a custom CLI to the menu. Example:
33
- ```sh
34
- summon --add grok "Grok Build"
35
- ```
36
- Adds an entry that runs `grok` and shows as `Grok Build` with hint `Custom provider`.
37
- - `--remove <id>` remove a custom CLI from the menu.
38
- - `--no-logo` / `--logo` toggle the side logo (remembered).
39
- - Args after `--` go to the launched tool.
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
+ ```
40
125
 
41
126
  ## Config
42
127
 
43
- `~/.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.
44
133
 
45
134
  ## Requirements
46
135
 
47
- Node 18+, a TrueColor terminal, the target CLIs on PATH.
136
+ Node 18+, a TrueColor terminal, and the target CLIs on your `PATH`.
48
137
 
49
138
  ## Trademarks
50
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);
@@ -32,6 +39,7 @@ if (flags.has('--no-logo')) {
32
39
  saveConfig({logo: true});
33
40
  }
34
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
45
  process.stdout.write(renderSnapshot(Number(process.env.CLI_LEVEL_ACTIVE || 0), items));
@@ -44,7 +52,11 @@ if (flags.has('--help') || flags.has('-h') || command === 'help') {
44
52
  }
45
53
 
46
54
  if (flags.has('--add')) {
47
- runAdd(args[0], args[1]);
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
 
@@ -53,8 +65,6 @@ if (flags.has('--remove') || flags.has('--rm')) {
53
65
  process.exit(0);
54
66
  }
55
67
 
56
- const canRenderTui = Boolean(process.stdin.isTTY && process.stdout.isTTY && process.env.TERM !== 'dumb');
57
-
58
68
  switch (command) {
59
69
  case 'reorder':
60
70
  await runReorder();
@@ -65,6 +75,12 @@ switch (command) {
65
75
  case 'alias':
66
76
  runAlias(args[1]);
67
77
  break;
78
+ case 'add':
79
+ await runAddForm();
80
+ break;
81
+ case 'setup':
82
+ await runSetupCommand();
83
+ break;
68
84
  case undefined:
69
85
  await runMenu();
70
86
  break;
@@ -80,6 +96,8 @@ async function runMenu() {
80
96
  process.exit(2);
81
97
  }
82
98
 
99
+ await maybeFirstRunSetup();
100
+
83
101
  clearScreen();
84
102
  const selected = await chooseTool();
85
103
  if (!selected) {
@@ -146,7 +164,32 @@ async function runDefault(target) {
146
164
  process.stdout.write(`Default set to ${selected.label}. The menu now opens with the cursor on it.\n`);
147
165
  }
148
166
 
149
- function runAdd(name, label) {
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) {
150
193
  if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
151
194
  process.stderr.write(`${PROG}: usage: ${PROG} --add <command> [label]\n`);
152
195
  process.exit(2);
@@ -165,8 +208,8 @@ function runAdd(name, label) {
165
208
  }
166
209
 
167
210
  const labelText = label || (name.charAt(0).toUpperCase() + name.slice(1));
168
- const next = [...current, {id, label: labelText, command: name, hint: 'Custom provider'}];
169
- saveConfig({customTools: next});
211
+ const entry = {id, label: labelText, command: name, hint: hint || 'Custom provider'};
212
+ saveConfig({customTools: [...current, entry]});
170
213
  process.stdout.write(`Added '${labelText}' (command: ${name}) to the menu.\n`);
171
214
  }
172
215
 
@@ -188,6 +231,73 @@ function runRemove(name) {
188
231
  process.stdout.write(`Removed '${id}'.\n`);
189
232
  }
190
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
+
191
301
  function runAlias(name) {
192
302
  if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
193
303
  process.stderr.write(`${PROG}: usage: ${PROG} alias <name>\n`);
@@ -292,10 +402,12 @@ Commands:
292
402
  reorder Set the order tools appear in
293
403
  default [tool] Start the cursor on <tool>; no tool = pick one; 'off' clears
294
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)
295
407
  help Show this help
296
408
 
297
409
  Options:
298
- --add <cmd> [lbl] Add a custom CLI to the menu (e.g. --add grok "Grok Build")
410
+ --add [cmd] [lbl] Add a custom CLI (no args opens the form; e.g. --add grok "Grok Build")
299
411
  --remove <id> Remove a custom CLI from the menu
300
412
  --no-logo Hide the side logo and remember it
301
413
  --logo Show the side logo and remember it
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "summon-cli",
3
- "version": "0.3.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},
@@ -339,6 +325,267 @@ export function ReorderApp({items = tools, 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},
package/src/config.mjs CHANGED
@@ -9,7 +9,8 @@ const DEFAULTS = {
9
9
  order: [],
10
10
  logo: true,
11
11
  default: null,
12
- customTools: []
12
+ customTools: [],
13
+ setupDone: false
13
14
  };
14
15
 
15
16
  export function loadConfig() {