@slugbugblue/trax-cli 0.11.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/.dockerignore +7 -0
- package/.rgignore +2 -0
- package/CHANGELOG.md +80 -0
- package/CONTRIBUTING.md +102 -0
- package/Dockerfile +14 -0
- package/LICENSE +201 -0
- package/README.md +193 -0
- package/package.json +71 -0
- package/src/cli.js +732 -0
- package/src/cmds/analyze.js +112 -0
- package/src/cmds/delete.js +71 -0
- package/src/cmds/help.js +81 -0
- package/src/cmds/import-export.js +394 -0
- package/src/cmds/list.js +177 -0
- package/src/cmds/new.js +136 -0
- package/src/cmds/notes.js +37 -0
- package/src/cmds/play-try.js +175 -0
- package/src/cmds/puzzles.js +188 -0
- package/src/cmds/select.js +47 -0
- package/src/cmds/suggest.js +55 -0
- package/src/cmds/undo.js +40 -0
- package/src/cmds/view.js +65 -0
- package/src/types.d.ts +105 -0
- package/src/utils.js +22 -0
- package/src/version.js +2 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* Copyright 2022-2023 Chad Transtrum
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// CLI for Trax, with a fallback REPL
|
|
18
|
+
|
|
19
|
+
import process from 'node:process'
|
|
20
|
+
import repl from 'node:repl'
|
|
21
|
+
|
|
22
|
+
import YAML from 'yaml'
|
|
23
|
+
|
|
24
|
+
import { Trax } from '@slugbugblue/trax'
|
|
25
|
+
import { version } from '@slugbugblue/trax-cli/version'
|
|
26
|
+
import * as tty from '@slugbugblue/trax-tty'
|
|
27
|
+
|
|
28
|
+
// Load all the commands as individual plugins
|
|
29
|
+
import { analyzeCmd } from './cmds/analyze.js'
|
|
30
|
+
import { deleteCmd } from './cmds/delete.js'
|
|
31
|
+
import { helpCmd } from './cmds/help.js'
|
|
32
|
+
import {
|
|
33
|
+
importCmd,
|
|
34
|
+
exportCmd,
|
|
35
|
+
getFile,
|
|
36
|
+
saveFile,
|
|
37
|
+
findFiles,
|
|
38
|
+
} from './cmds/import-export.js'
|
|
39
|
+
import { listCmd } from './cmds/list.js'
|
|
40
|
+
import { newCmd } from './cmds/new.js'
|
|
41
|
+
import { notesCmd } from './cmds/notes.js'
|
|
42
|
+
import { playCmd, tryCmd } from './cmds/play-try.js'
|
|
43
|
+
import { puzzlesCmd } from './cmds/puzzles.js'
|
|
44
|
+
import { selectCmd } from './cmds/select.js'
|
|
45
|
+
import { suggestCmd } from './cmds/suggest.js'
|
|
46
|
+
import { undoCmd } from './cmds/undo.js'
|
|
47
|
+
import { viewCmd } from './cmds/view.js'
|
|
48
|
+
|
|
49
|
+
// Because typeof [] and null are both 'object'. is([], 'arr') => true
|
|
50
|
+
const is = (variable, type) =>
|
|
51
|
+
Object.prototype.toString
|
|
52
|
+
.call(variable)
|
|
53
|
+
.slice(8, -1)
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.startsWith(type)
|
|
56
|
+
|
|
57
|
+
// Sample command:
|
|
58
|
+
const versionCmd = {
|
|
59
|
+
name: 'version',
|
|
60
|
+
opts: ['--version', '-v'],
|
|
61
|
+
desc: 'display version number',
|
|
62
|
+
help: 'Print out the current version of this program.',
|
|
63
|
+
fn: (CLI) => CLI.out('Trax CLI v' + version),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const quitCmd = {
|
|
67
|
+
name: 'quit',
|
|
68
|
+
alt: 'exit',
|
|
69
|
+
desc: 'press <Ctrl-D> to exit',
|
|
70
|
+
help: 'To exit, press Ctrl-D on a blank line or type "quit".',
|
|
71
|
+
hide: true,
|
|
72
|
+
fn() {
|
|
73
|
+
// I actually have no idea why a broken promise is required here,
|
|
74
|
+
// but I tried a lot of different options, and this is what worked
|
|
75
|
+
return new Promise(() => {
|
|
76
|
+
if (CLI.repl) {
|
|
77
|
+
CLI.repl.close()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const plugins = [
|
|
84
|
+
analyzeCmd,
|
|
85
|
+
deleteCmd,
|
|
86
|
+
exportCmd,
|
|
87
|
+
helpCmd,
|
|
88
|
+
importCmd,
|
|
89
|
+
listCmd,
|
|
90
|
+
newCmd,
|
|
91
|
+
notesCmd,
|
|
92
|
+
playCmd,
|
|
93
|
+
puzzlesCmd,
|
|
94
|
+
quitCmd,
|
|
95
|
+
selectCmd,
|
|
96
|
+
suggestCmd,
|
|
97
|
+
tryCmd,
|
|
98
|
+
undoCmd,
|
|
99
|
+
versionCmd,
|
|
100
|
+
viewCmd,
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
const newId = (DATA) => {
|
|
104
|
+
let max = 0
|
|
105
|
+
let min = Number.POSITIVE_INFINITY
|
|
106
|
+
if (DATA.games) {
|
|
107
|
+
for (const id of Object.keys(DATA.games)) {
|
|
108
|
+
max = Math.max(max, Number(id) || 0)
|
|
109
|
+
min = Math.min(min, Number(id) || 0)
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
DATA.games = {}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (max === 0 || min <= 1) return String(max + 1)
|
|
116
|
+
|
|
117
|
+
return String(min - 1)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Colorize text according to simplistic and not very complete patterns.
|
|
121
|
+
* @type Colorer
|
|
122
|
+
*/
|
|
123
|
+
// @ts-ignore
|
|
124
|
+
const color = (text, def) => {
|
|
125
|
+
let out = ''
|
|
126
|
+
let quote = false
|
|
127
|
+
let id = false
|
|
128
|
+
const colors = [def]
|
|
129
|
+
const c = (t) => tty.color(t, colors[colors.length - 1] || CLI.COLORS.default)
|
|
130
|
+
const short = (t) => tty.color(t, CLI.COLORS.short) + c()
|
|
131
|
+
for (const char of text) {
|
|
132
|
+
switch (char) {
|
|
133
|
+
case '"': {
|
|
134
|
+
if (quote) {
|
|
135
|
+
colors.pop()
|
|
136
|
+
} else {
|
|
137
|
+
colors.push(CLI.COLORS.command)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
out += c('')
|
|
141
|
+
quote = !quote
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case '#': {
|
|
146
|
+
id = true
|
|
147
|
+
colors.push(CLI.COLORS.id)
|
|
148
|
+
out += c('#')
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case '|': {
|
|
153
|
+
out += short('|')
|
|
154
|
+
break
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
case '[': {
|
|
158
|
+
colors.push(CLI.COLORS.optional)
|
|
159
|
+
out += short('[')
|
|
160
|
+
break
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case ']': {
|
|
164
|
+
colors.pop()
|
|
165
|
+
out += short(']')
|
|
166
|
+
break
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case '<': {
|
|
170
|
+
colors.push(CLI.COLORS.variable)
|
|
171
|
+
out += c('')
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case '>': {
|
|
176
|
+
colors.pop()
|
|
177
|
+
out += c('')
|
|
178
|
+
break
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
default: {
|
|
182
|
+
if (id && !/[\da-z]/.test(char)) {
|
|
183
|
+
id = false
|
|
184
|
+
colors.pop()
|
|
185
|
+
out += c(char)
|
|
186
|
+
} else {
|
|
187
|
+
out += char
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return tty.color(out, def || CLI.COLORS.default)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Internal representations of files
|
|
197
|
+
const CONFIG = { id: '', puzzleLevel: 0 }
|
|
198
|
+
const DATA = { games: {}, puzzles: {} }
|
|
199
|
+
|
|
200
|
+
// CLI context object for plugins
|
|
201
|
+
const CLI = {
|
|
202
|
+
// Quick access to all stored games
|
|
203
|
+
get GAMES() {
|
|
204
|
+
return DATA.games || {}
|
|
205
|
+
},
|
|
206
|
+
// And information about the current game
|
|
207
|
+
get GAME() {
|
|
208
|
+
return (CONFIG.id && DATA.games?.[CONFIG.id]) || {}
|
|
209
|
+
},
|
|
210
|
+
// And the current game ID
|
|
211
|
+
get ID() {
|
|
212
|
+
return CONFIG.id
|
|
213
|
+
},
|
|
214
|
+
// The current puzzle level
|
|
215
|
+
get puzzleLevel() {
|
|
216
|
+
return CONFIG.puzzleLevel
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
// Provide access to the current Trax object
|
|
220
|
+
TRAX: new Trax(),
|
|
221
|
+
|
|
222
|
+
// Color codes
|
|
223
|
+
COLORS: {
|
|
224
|
+
black: 240,
|
|
225
|
+
command: 33,
|
|
226
|
+
default: 39,
|
|
227
|
+
error: 205,
|
|
228
|
+
fatal: 127,
|
|
229
|
+
help: 74,
|
|
230
|
+
id: 195,
|
|
231
|
+
variable: 123,
|
|
232
|
+
optional: 110,
|
|
233
|
+
short: 153,
|
|
234
|
+
success: 35,
|
|
235
|
+
white: 250,
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
// All of the above COLORS will be populated automatically into "color", where
|
|
239
|
+
// they can be called as functions, to apply the given color, eg:
|
|
240
|
+
// CLI.color('hi') and CLI.color.default('hi') are equivalent
|
|
241
|
+
color,
|
|
242
|
+
|
|
243
|
+
cmd(command) {
|
|
244
|
+
// Return a command structure object or an empty object
|
|
245
|
+
if (is(command, 'str') && command in commands) {
|
|
246
|
+
return commands[command]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {}
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
get commands() {
|
|
253
|
+
return Object.keys(commands)
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
delete(id) {
|
|
257
|
+
delete DATA.games[id]
|
|
258
|
+
saveData()
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
do(cmd, ...args) {
|
|
262
|
+
// Call another plugin
|
|
263
|
+
if (cmd in commands) {
|
|
264
|
+
commands[cmd].fn(CLI, ...args)
|
|
265
|
+
} else {
|
|
266
|
+
throw new SyntaxError(`${cmd} is not a valid command`)
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async doNext(...cmd) {
|
|
271
|
+
// Execute another command line
|
|
272
|
+
await evaluate(cmd.join(' '))
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
error(text) {
|
|
276
|
+
// Prints an error to stdout
|
|
277
|
+
CLI.out(CLI.color.error(text))
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
// Notation manipulation
|
|
281
|
+
fixNotation(move) {
|
|
282
|
+
if (move.endsWith('b')) return move.slice(0, -1) + '\\'
|
|
283
|
+
if (move.endsWith('s')) return move.slice(0, -1) + '/'
|
|
284
|
+
if (move.endsWith('p')) return move.slice(0, -1) + '+'
|
|
285
|
+
return move
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
load(id) {
|
|
289
|
+
if (!id) id = Object.keys(DATA.games || {})[0]
|
|
290
|
+
if (id && DATA.games?.[id] && DATA.games[id].rules) {
|
|
291
|
+
if (id !== String(CONFIG.id)) {
|
|
292
|
+
CONFIG.id = id
|
|
293
|
+
saveConfig()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const game = CLI.GAME
|
|
297
|
+
if (!game.players) game.players = ['white', 'black']
|
|
298
|
+
CLI.TRAX = new Trax(game.rules, game.moves, 'cli')
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
newGame(rules, players, moves) {
|
|
303
|
+
// Creates a new game
|
|
304
|
+
const id = newId(DATA)
|
|
305
|
+
const trax = new Trax(rules, moves, 'cli')
|
|
306
|
+
DATA.games[id] = {
|
|
307
|
+
id,
|
|
308
|
+
rules: trax.rules,
|
|
309
|
+
name: trax.name,
|
|
310
|
+
turn: trax.turn,
|
|
311
|
+
players,
|
|
312
|
+
moves: trax.moves.join(' '),
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
CONFIG.id = id
|
|
316
|
+
CLI.TRAX = trax
|
|
317
|
+
|
|
318
|
+
saveConfig()
|
|
319
|
+
saveData()
|
|
320
|
+
|
|
321
|
+
return id
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
newPuzzle(puzzle) {
|
|
325
|
+
// Starts a new puzzle
|
|
326
|
+
const id = newId(DATA)
|
|
327
|
+
const trax = new Trax(puzzle.game, puzzle.notation, 'cli')
|
|
328
|
+
const players = ['challenger', 'puzzlebot']
|
|
329
|
+
const notes = []
|
|
330
|
+
if (puzzle.title) notes.push({ move: 0, note: puzzle.title })
|
|
331
|
+
if (puzzle.desc) notes.push({ move: 0, note: puzzle.desc })
|
|
332
|
+
const player = [0, 'white', 'black'][puzzle.player]
|
|
333
|
+
notes.push(
|
|
334
|
+
{ move: 0, note: 'Starting position ' + puzzle.notation },
|
|
335
|
+
{ move: 0, note: `${puzzle.id}: ${player} to win by move ${puzzle.max}` },
|
|
336
|
+
)
|
|
337
|
+
DATA.games[id] = {
|
|
338
|
+
id,
|
|
339
|
+
puzzle: puzzle.id,
|
|
340
|
+
rules: trax.rules,
|
|
341
|
+
name: trax.name,
|
|
342
|
+
turn: trax.turn,
|
|
343
|
+
max: puzzle.max,
|
|
344
|
+
players: trax.turn === 1 ? players : players.reverse(),
|
|
345
|
+
moves: trax.moves.join(' '),
|
|
346
|
+
notes,
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (DATA.puzzles[puzzle.id]) {
|
|
350
|
+
DATA.puzzles[puzzle.id].attempts += 1
|
|
351
|
+
} else {
|
|
352
|
+
DATA.puzzles[puzzle.id] = { attempts: 1, solved: 0 }
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
CONFIG.id = id
|
|
356
|
+
CONFIG.puzzleLevel = puzzle.level
|
|
357
|
+
CLI.TRAX = trax
|
|
358
|
+
|
|
359
|
+
saveConfig()
|
|
360
|
+
saveData()
|
|
361
|
+
|
|
362
|
+
return id
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
out(text) {
|
|
366
|
+
// Prints to stdout
|
|
367
|
+
console.info(text + tty.color(''))
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
plural(n, noun, nouns) {
|
|
371
|
+
return String(n) + ' ' + (Math.abs(n) === 1 ? noun : nouns || noun + 's')
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
puzzle(id) {
|
|
375
|
+
return DATA.puzzles?.[id] || { attempts: 0, solved: 0 }
|
|
376
|
+
},
|
|
377
|
+
|
|
378
|
+
puzzleSolved(id) {
|
|
379
|
+
DATA.puzzles = DATA.puzzles || {}
|
|
380
|
+
DATA.puzzles[id] = DATA.puzzles[id] || { attempts: 1, solved: 0 }
|
|
381
|
+
DATA.puzzles[id].solved += 1
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
resolveCommand(word) {
|
|
385
|
+
// Determine if an entered word is a valid command.
|
|
386
|
+
// Returns the command if a single match is found, an array if multiple
|
|
387
|
+
// commands match, or undefined if nothing matches.
|
|
388
|
+
if (is(word, 'str') && word.length > 0) {
|
|
389
|
+
return abbreviations[word.toLowerCase()]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return undefined
|
|
393
|
+
},
|
|
394
|
+
|
|
395
|
+
/** Write out the current data to disk. */
|
|
396
|
+
save() {
|
|
397
|
+
saveData()
|
|
398
|
+
},
|
|
399
|
+
|
|
400
|
+
setGame(moves) {
|
|
401
|
+
// Updates the moves for the current game
|
|
402
|
+
const game = CLI.GAME
|
|
403
|
+
const trax = new Trax(game.rules, moves, 'cli')
|
|
404
|
+
if (game.over || trax.over) game.over = trax.over
|
|
405
|
+
game.moves = trax.moves.join(' ')
|
|
406
|
+
game.turn = trax.turn
|
|
407
|
+
CLI.TRAX = trax
|
|
408
|
+
|
|
409
|
+
saveData()
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
updateGameData() {
|
|
413
|
+
const data = DATA.games[CLI.GAME.id]
|
|
414
|
+
const moves = CLI.TRAX.moves.join(' ')
|
|
415
|
+
if (data.moves !== moves) {
|
|
416
|
+
data.moves = moves
|
|
417
|
+
data.turn = CLI.TRAX.turn
|
|
418
|
+
if (CLI.TRAX.over) data.over = true
|
|
419
|
+
saveData()
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
|
|
423
|
+
// Pass through functions
|
|
424
|
+
bubble: tty.bubble,
|
|
425
|
+
display: tty.display,
|
|
426
|
+
name: tty.name,
|
|
427
|
+
|
|
428
|
+
is,
|
|
429
|
+
version,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let startRepl = false
|
|
433
|
+
|
|
434
|
+
const getConfig = async () => {
|
|
435
|
+
await getFile('config', 'trax.yaml', CONFIG, YAML.parse)
|
|
436
|
+
return CONFIG
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const getData = async () => {
|
|
440
|
+
await getFile('data', 'trax.json', DATA, JSON.parse)
|
|
441
|
+
return DATA
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const saveConfig = () => {
|
|
445
|
+
saveFile('config', 'trax.yaml', YAML.stringify(CONFIG))
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const saveData = () => {
|
|
449
|
+
saveFile('data', 'trax.json', JSON.stringify(DATA, null, 2) + '\n')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const OPTS = {}
|
|
453
|
+
|
|
454
|
+
const parseCommandLine = () => {
|
|
455
|
+
let cmd = ''
|
|
456
|
+
const args = process.argv.slice(2)
|
|
457
|
+
let dash = true
|
|
458
|
+
for (const arg of args) {
|
|
459
|
+
if (dash && arg.startsWith('-')) {
|
|
460
|
+
if (arg === '--') dash = false
|
|
461
|
+
if (arg === '-i' || arg === '--interactive') startRepl = true
|
|
462
|
+
if (arg in OPTS) {
|
|
463
|
+
cmd = OPTS[arg] + ' ' + cmd
|
|
464
|
+
}
|
|
465
|
+
} else {
|
|
466
|
+
cmd += arg + ' '
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return cmd.slice(0, -1)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Auto-populate the color functions
|
|
474
|
+
for (const [name, value] of Object.entries(CLI.COLORS)) {
|
|
475
|
+
CLI.color[name] = (text) => color(text, value)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const forceArray = (item) =>
|
|
479
|
+
is(item, 'arr') ? item : is(item, 'undef') ? [] : [item]
|
|
480
|
+
|
|
481
|
+
const abbreviations = {}
|
|
482
|
+
const commands = {}
|
|
483
|
+
const aliases = []
|
|
484
|
+
const regexes = []
|
|
485
|
+
|
|
486
|
+
const addAbbrev = (abbr, full) => {
|
|
487
|
+
for (let abbrev of abbr) {
|
|
488
|
+
while (abbrev.length > 0) {
|
|
489
|
+
const abbrSet = abbreviations[abbrev] || new Set()
|
|
490
|
+
abbrSet.add(full)
|
|
491
|
+
abbreviations[abbrev] = abbrSet
|
|
492
|
+
abbrev = abbrev.slice(0, -1)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const makeComp = (comp) => {
|
|
498
|
+
if (is(comp, 'arr') || is(comp, 'func')) return comp
|
|
499
|
+
if (is(comp, 'str')) return comp.split(' ')
|
|
500
|
+
return []
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const processPlugins = () => {
|
|
504
|
+
// Process all the plugin command entries
|
|
505
|
+
for (const cmd of plugins) {
|
|
506
|
+
if (cmd.opts) {
|
|
507
|
+
for (const opt of forceArray(cmd.opts)) {
|
|
508
|
+
OPTS[opt] = cmd.name
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (cmd.rx) {
|
|
513
|
+
for (const rx of forceArray(cmd.rx)) {
|
|
514
|
+
regexes.push({ rx, name: cmd.name })
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const entry = {
|
|
519
|
+
alt: forceArray(cmd.alt),
|
|
520
|
+
args: cmd.args || '',
|
|
521
|
+
comp: makeComp(cmd.comp),
|
|
522
|
+
desc: cmd.desc,
|
|
523
|
+
help: forceArray(cmd.help),
|
|
524
|
+
hide: cmd.hide,
|
|
525
|
+
fn: cmd.fn,
|
|
526
|
+
}
|
|
527
|
+
addAbbrev([cmd.name, ...entry.alt], cmd.name)
|
|
528
|
+
commands[cmd.name] = entry
|
|
529
|
+
|
|
530
|
+
aliases.push(cmd.name)
|
|
531
|
+
for (const alias of entry.alt) {
|
|
532
|
+
aliases.push(alias)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const [abbr, value] of Object.entries(abbreviations)) {
|
|
537
|
+
const newValue = [...value]
|
|
538
|
+
abbreviations[abbr] = value.size > 1 ? newValue.sort() : newValue[0]
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const expand = (completions, match) => {
|
|
543
|
+
completions = [...completions] // Do not modify in-place
|
|
544
|
+
if (completions.includes('<cmd>')) {
|
|
545
|
+
// All commands
|
|
546
|
+
completions[completions.indexOf('<cmd>')] = aliases.join('|')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (completions.includes('<id>')) {
|
|
550
|
+
// Game numbers
|
|
551
|
+
const ids = []
|
|
552
|
+
for (const game of Object.values(DATA.games || {})) {
|
|
553
|
+
if (game.id) ids.push('#' + game.id)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
completions[completions.indexOf('<id>')] = ids.join('|')
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (completions.includes('<move>')) {
|
|
560
|
+
// Move numbers
|
|
561
|
+
const moves = []
|
|
562
|
+
for (let move = 1; move <= CLI.TRAX.moves.length; move++) {
|
|
563
|
+
moves.push(String(move))
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
completions[completions.indexOf('<move>')] = moves.join('|')
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (completions.includes('<play>')) {
|
|
570
|
+
// Moves
|
|
571
|
+
const moves = CLI.TRAX.possibleMoves()
|
|
572
|
+
completions[completions.indexOf('<play>')] = moves.join('|')
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (completions.includes('<file>')) {
|
|
576
|
+
// Files ... a bit tricky since we don't want to async in the middle of
|
|
577
|
+
// a completion, but we do want to list all the .trx files in the folder
|
|
578
|
+
// we are looking for ... so we cheat and initiate a search on the first
|
|
579
|
+
// call, and maybe have completions by the second or third call ...
|
|
580
|
+
completions[completions.indexOf('<file>')] = findFiles(match).join('|')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return completions
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const completer = (line) => {
|
|
587
|
+
const words = line.split(/\s+/).filter(Boolean)
|
|
588
|
+
|
|
589
|
+
let match = words.slice(-1)[0] || ''
|
|
590
|
+
const previous = new Set(words.slice(1, -1).map((s) => s.toLowerCase()))
|
|
591
|
+
if (line.endsWith(' ')) {
|
|
592
|
+
previous.add(match.toLowerCase())
|
|
593
|
+
match = ''
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let completions = []
|
|
597
|
+
const cmd = abbreviations[String(words[0]).toLowerCase()]
|
|
598
|
+
|
|
599
|
+
if (is(cmd, 'str') && (line.endsWith(' ') || words.length > 1)) {
|
|
600
|
+
completions = commands[cmd].comp
|
|
601
|
+
if (is(completions, 'func')) {
|
|
602
|
+
completions = completions(CLI, words.slice(1), aliases)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
completions = expand(completions, match)
|
|
606
|
+
if (words.length === 1 || (words.length === 2 && !line.endsWith(' '))) {
|
|
607
|
+
completions.push('help', '?')
|
|
608
|
+
}
|
|
609
|
+
} else {
|
|
610
|
+
completions = aliases
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (let i = 0; i < completions.length; i++) {
|
|
614
|
+
const word = completions[i]
|
|
615
|
+
if (word.includes('|')) {
|
|
616
|
+
const comps = word.split('|')
|
|
617
|
+
if (comps.some((c) => previous.has(c.toLowerCase()))) {
|
|
618
|
+
completions[i] = ''
|
|
619
|
+
} else {
|
|
620
|
+
completions[i] = comps
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
completions = completions
|
|
626
|
+
.flat()
|
|
627
|
+
.filter((c) => c && !previous.has(c.toLowerCase()))
|
|
628
|
+
|
|
629
|
+
if (match && completions.length > 0) {
|
|
630
|
+
completions = completions.filter((c) => {
|
|
631
|
+
return c.toLowerCase().startsWith(match.toLowerCase())
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (!match && completions.length === 1) completions.push(' ')
|
|
636
|
+
|
|
637
|
+
completions = completions.map((c) => c + ' ').sort()
|
|
638
|
+
|
|
639
|
+
return [completions, match]
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const ambiguousSuggestions = (cmd) => {
|
|
643
|
+
const maybe = new Set()
|
|
644
|
+
for (const alias of cmd) {
|
|
645
|
+
if (alias.startsWith(CLI.CMD)) maybe.add(alias)
|
|
646
|
+
|
|
647
|
+
let alts = commands[alias].alt
|
|
648
|
+
alts = is(alts, 'arr') ? alts : [alts || '']
|
|
649
|
+
|
|
650
|
+
for (const alt of alts) {
|
|
651
|
+
if (alt.startsWith(CLI.CMD)) maybe.add(alt)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return maybe
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const evaluate = async (command) => {
|
|
659
|
+
const [CMD, ...words] = command.split(/\s+/).filter(Boolean)
|
|
660
|
+
const cmdLower = CMD?.toLowerCase() || ''
|
|
661
|
+
CLI.CMD = cmdLower
|
|
662
|
+
let cmd = abbreviations[cmdLower]
|
|
663
|
+
|
|
664
|
+
// If we are not in a REPL, let's remove hidden commands
|
|
665
|
+
if (!CLI.repl && is(cmd, 'arr')) {
|
|
666
|
+
cmd = cmd.filter((c) => !commands[c].hide)
|
|
667
|
+
if (cmd.length === 1) cmd = cmd[0]
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
if (is(cmd, 'str')) {
|
|
671
|
+
if (words.length > 0 && abbreviations[words[0].toLowerCase()] === 'help') {
|
|
672
|
+
commands.help.fn(CLI, cmd)
|
|
673
|
+
} else {
|
|
674
|
+
await commands[cmd].fn(CLI, ...words)
|
|
675
|
+
}
|
|
676
|
+
} else if (is(cmd, 'arr')) {
|
|
677
|
+
CLI.error('Ambiguous command. Perhaps you meant one of the following:')
|
|
678
|
+
const maybe = ambiguousSuggestions(cmd)
|
|
679
|
+
|
|
680
|
+
if (maybe.size > 0) {
|
|
681
|
+
for (const alias of [...maybe].sort()) {
|
|
682
|
+
tty.outty(CLI.color.command(' ' + alias))
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
} else if (CMD) {
|
|
686
|
+
let rxCmd
|
|
687
|
+
for (const regex of regexes) {
|
|
688
|
+
if (regex.rx.test(CMD)) rxCmd = regex.name
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (rxCmd) {
|
|
692
|
+
await commands[rxCmd].fn(CLI, CMD, ...words)
|
|
693
|
+
} else {
|
|
694
|
+
CLI.error('Unknown command. Type "help" for a list of valid commands.')
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const main = async () => {
|
|
700
|
+
processPlugins()
|
|
701
|
+
const command = parseCommandLine()
|
|
702
|
+
if (is(command, 'str')) {
|
|
703
|
+
await Promise.all([getConfig(), getData()])
|
|
704
|
+
CLI.load(CONFIG.id)
|
|
705
|
+
if (command) await evaluate(command)
|
|
706
|
+
if (startRepl || !command) {
|
|
707
|
+
if (!command) {
|
|
708
|
+
tty.outty(color('Welcome to the Trax CLI v' + version + '.'))
|
|
709
|
+
tty.outty(color('Type "help" for more information.'))
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
CLI.repl = repl.start({
|
|
713
|
+
prompt: 'trax❯ ',
|
|
714
|
+
async eval(cmd, ctx, fn, cb) {
|
|
715
|
+
try {
|
|
716
|
+
await evaluate(cmd)
|
|
717
|
+
} catch (error) {
|
|
718
|
+
tty.outty(error, CLI.COLORS.fatal)
|
|
719
|
+
throw error
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
cb(null, undefined)
|
|
723
|
+
},
|
|
724
|
+
ignoreUndefined: true,
|
|
725
|
+
completer,
|
|
726
|
+
preview: true,
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
await main()
|