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 +110 -12
- package/bin/cli.mjs +170 -10
- package/package.json +2 -2
- package/src/app.mjs +308 -42
- package/src/config.mjs +3 -1
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
Pass arguments straight through to the launched tool with `--`:
|
|
23
58
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
59
|
+
```sh
|
|
60
|
+
cli -- --version
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Customize
|
|
29
64
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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": "
|
|
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: '
|
|
31
|
-
label: '
|
|
32
|
-
command: '
|
|
33
|
-
hint: '
|
|
34
|
-
palette: ['#
|
|
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
|
-
|
|
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 <=
|
|
259
|
-
const index = (from + dir * step +
|
|
260
|
-
if (!taken.has(
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
514
|
-
|
|
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
|
|
549
|
-
|
|
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
|
|
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