@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/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()