sumibako 0.2.0 → 0.2.2
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/index.mjs +969 -911
- package/package.json +3 -3
- package/SKILL.md +0 -186
package/index.mjs
CHANGED
|
@@ -1,911 +1,969 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* sumibako - file what your coding agent wrote into your vault.
|
|
4
|
-
*
|
|
5
|
-
* Zero dependencies, one file, Node 18 or newer. That is a deliberate ceiling:
|
|
6
|
-
* this runs inside somebody else's agent session, often on a machine where the
|
|
7
|
-
* install has to be instant and silent, and a dependency tree is a thing that
|
|
8
|
-
* can break there in ways nobody will debug.
|
|
9
|
-
*
|
|
10
|
-
* Why a CLI at all, when the same job could be an MCP server. Two reasons that
|
|
11
|
-
* matter in practice. An MCP tool call carries the document through the model's
|
|
12
|
-
* context to get it to the server, so filing a 30KB plan means re-emitting
|
|
13
|
-
* 30KB of tokens; `sumibako publish plan.md` sends a file the agent has already
|
|
14
|
-
* written to disk, and the model never sees it twice. And a shell exists in
|
|
15
|
-
* every coding agent there is, while MCP support differs between them and
|
|
16
|
-
* changes with the spec. An MCP server can come later and reuse this same API.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { spawn } from "node:child_process";
|
|
20
|
-
import crypto from "node:crypto";
|
|
21
|
-
import fs from "node:fs";
|
|
22
|
-
import os from "node:os";
|
|
23
|
-
import path from "node:path";
|
|
24
|
-
import process from "node:process";
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
*/
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
* The
|
|
492
|
-
*
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
554
|
-
*
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
if (
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
);
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
//
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
${bold(
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sumibako - file what your coding agent wrote into your vault.
|
|
4
|
+
*
|
|
5
|
+
* Zero dependencies, one file, Node 18 or newer. That is a deliberate ceiling:
|
|
6
|
+
* this runs inside somebody else's agent session, often on a machine where the
|
|
7
|
+
* install has to be instant and silent, and a dependency tree is a thing that
|
|
8
|
+
* can break there in ways nobody will debug.
|
|
9
|
+
*
|
|
10
|
+
* Why a CLI at all, when the same job could be an MCP server. Two reasons that
|
|
11
|
+
* matter in practice. An MCP tool call carries the document through the model's
|
|
12
|
+
* context to get it to the server, so filing a 30KB plan means re-emitting
|
|
13
|
+
* 30KB of tokens; `sumibako publish plan.md` sends a file the agent has already
|
|
14
|
+
* written to disk, and the model never sees it twice. And a shell exists in
|
|
15
|
+
* every coding agent there is, while MCP support differs between them and
|
|
16
|
+
* changes with the spec. An MCP server can come later and reuse this same API.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import crypto from "node:crypto";
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import process from "node:process";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The version, read from `package.json` rather than written twice.
|
|
29
|
+
*
|
|
30
|
+
* It was a literal here until `npm version patch` bumped the manifest and left
|
|
31
|
+
* this behind, so a freshly published 0.2.1 introduced itself as 0.2.0. That is
|
|
32
|
+
* a small lie with an outsized cost: the first thing anybody does with a bug
|
|
33
|
+
* report is ask which version, and the answer was wrong.
|
|
34
|
+
*
|
|
35
|
+
* Read lazily, so the only command that needs the file is the one that pays for
|
|
36
|
+
* it, and forgiving of a missing file: a version string is not worth failing a
|
|
37
|
+
* publish over.
|
|
38
|
+
*/
|
|
39
|
+
function version() {
|
|
40
|
+
try {
|
|
41
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
return JSON.parse(
|
|
43
|
+
fs.readFileSync(path.join(here, "package.json"), "utf8"),
|
|
44
|
+
).version;
|
|
45
|
+
} catch {
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Exit code for "this machine is not connected yet".
|
|
52
|
+
*
|
|
53
|
+
* Distinct from 1 so that the thing reading this output can tell a missing
|
|
54
|
+
* credential from a failed command. That distinction is the whole reason the
|
|
55
|
+
* connect flow works from inside an agent session: the skill tells the agent
|
|
56
|
+
* that 3 means "show the user the link and run the same command again", and
|
|
57
|
+
* every other non-zero code means the command genuinely failed.
|
|
58
|
+
*/
|
|
59
|
+
const EXIT_NEEDS_AUTH = 3;
|
|
60
|
+
|
|
61
|
+
/** Where the token lives when it is not in the environment. */
|
|
62
|
+
const CONFIG_DIR = path.join(os.homedir(), ".sumibako");
|
|
63
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where the API lives.
|
|
67
|
+
*
|
|
68
|
+
* The app's own domain rather than the Convex deployment behind it. This string
|
|
69
|
+
* ends up in config files, CI secrets and other people's shell history, so it
|
|
70
|
+
* has to be one that stays true - `next.config.mjs` rewrites it onto whichever
|
|
71
|
+
* deployment is current.
|
|
72
|
+
*/
|
|
73
|
+
const DEFAULT_API = "https://sumibako.com/api/agent";
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Output
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
80
|
+
/**
|
|
81
|
+
* ANSI colour, with the escape byte written as an escape rather than typed.
|
|
82
|
+
*
|
|
83
|
+
* A literal escape character in source is invisible in every diff and every
|
|
84
|
+
* review, and survives exactly until something normalises the file.
|
|
85
|
+
*/
|
|
86
|
+
const ESC = "\u001b";
|
|
87
|
+
const paint = (code, text) =>
|
|
88
|
+
useColor ? `${ESC}[${code}m${text}${ESC}[0m` : text;
|
|
89
|
+
const dim = (text) => paint("2", text);
|
|
90
|
+
const bold = (text) => paint("1", text);
|
|
91
|
+
const green = (text) => paint("32", text);
|
|
92
|
+
const red = (text) => paint("31", text);
|
|
93
|
+
const yellow = (text) => paint("33", text);
|
|
94
|
+
|
|
95
|
+
function die(message, hint) {
|
|
96
|
+
console.error(`${red("error")} ${message}`);
|
|
97
|
+
if (hint) console.error(dim(` ${hint}`));
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stops with the "connect this machine" instructions, and exit code 3.
|
|
103
|
+
*
|
|
104
|
+
* Written to stdout rather than stderr, unlike every other failure here. This
|
|
105
|
+
* is not an error report: it is a link somebody has to open, and the caller is
|
|
106
|
+
* as often an agent relaying it into a chat window as a person reading a
|
|
107
|
+
* terminal. Errors go to stderr because they are diagnostics; this is content.
|
|
108
|
+
*/
|
|
109
|
+
function needsAuth(url, code) {
|
|
110
|
+
console.log(`${bold("Connect this machine to Sumibako:")}`);
|
|
111
|
+
console.log(url);
|
|
112
|
+
console.log();
|
|
113
|
+
console.log(dim(`It should show the code ${bold(code)}. If it does not, the`));
|
|
114
|
+
console.log(dim("page belongs to a different request - close it."));
|
|
115
|
+
console.log();
|
|
116
|
+
console.log(dim("Then run the same command again."));
|
|
117
|
+
process.exit(EXIT_NEEDS_AUTH);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Config
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
function readConfig() {
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
127
|
+
} catch {
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function writeConfig(config) {
|
|
133
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
134
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
|
|
135
|
+
// The file holds a live credential. 0600 is the difference between "only
|
|
136
|
+
// me" and "every process running as any user on this machine", and it
|
|
137
|
+
// costs one argument. Windows ignores the mode, which is why the token is
|
|
138
|
+
// also accepted from the environment.
|
|
139
|
+
mode: 0o600,
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
fs.chmodSync(CONFIG_FILE, 0o600);
|
|
143
|
+
} catch {
|
|
144
|
+
// Best effort: some filesystems do not support it.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The token, from the environment first and the config file second.
|
|
150
|
+
*
|
|
151
|
+
* Environment first so CI can inject one without writing to a home directory
|
|
152
|
+
* that may not persist, and so a person can override a stale saved token for
|
|
153
|
+
* one command without editing a file.
|
|
154
|
+
*/
|
|
155
|
+
function resolveToken() {
|
|
156
|
+
const fromEnv = process.env.SUMIBAKO_TOKEN?.trim();
|
|
157
|
+
if (fromEnv) return fromEnv;
|
|
158
|
+
return readConfig().token ?? null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function resolveApi() {
|
|
162
|
+
return (
|
|
163
|
+
process.env.SUMIBAKO_API?.trim().replace(/\/+$/, "") ||
|
|
164
|
+
readConfig().api ||
|
|
165
|
+
DEFAULT_API
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// HTTP
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* One request, with the two failures that are about the address rather than
|
|
175
|
+
* the call: an unreachable host, and a server that answered but not as this
|
|
176
|
+
* API. Everything else is handed back for the caller to interpret.
|
|
177
|
+
*/
|
|
178
|
+
async function rawCall(
|
|
179
|
+
method,
|
|
180
|
+
base,
|
|
181
|
+
endpoint,
|
|
182
|
+
{ body, query, token, tolerateNetworkError = false } = {},
|
|
183
|
+
) {
|
|
184
|
+
const url = new URL(base + endpoint);
|
|
185
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
186
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let response;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetch(url, {
|
|
192
|
+
method,
|
|
193
|
+
headers: {
|
|
194
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
195
|
+
...(body ? { "Content-Type": "application/json" } : {}),
|
|
196
|
+
},
|
|
197
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
198
|
+
});
|
|
199
|
+
} catch (error) {
|
|
200
|
+
/*
|
|
201
|
+
A dropped connection, not a refusal.
|
|
202
|
+
|
|
203
|
+
Fatal for a single command, because retrying is not ours to decide:
|
|
204
|
+
`append` and `edit` are not idempotent, and a `fetch` that rejects may
|
|
205
|
+
still have been delivered - the response is what went missing. Quietly
|
|
206
|
+
sending it again could add the same paragraph twice.
|
|
207
|
+
|
|
208
|
+
A poll is the exception, and says so by passing the flag. It repeats the
|
|
209
|
+
same question until it gets an answer or runs out of time, so one lost
|
|
210
|
+
reply should cost a couple of seconds rather than the whole command.
|
|
211
|
+
*/
|
|
212
|
+
if (tolerateNetworkError) return null;
|
|
213
|
+
die(
|
|
214
|
+
`Could not reach ${url.origin}.`,
|
|
215
|
+
"Check your connection and run the same command again.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const text = await response.text();
|
|
220
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
221
|
+
|
|
222
|
+
let payload = null;
|
|
223
|
+
if (text && contentType.includes("json")) {
|
|
224
|
+
try {
|
|
225
|
+
payload = JSON.parse(text);
|
|
226
|
+
} catch {
|
|
227
|
+
// Handled below, along with every other not-our-API response.
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/*
|
|
232
|
+
Reached a server, but not this API.
|
|
233
|
+
|
|
234
|
+
Overwhelmingly this means the base URL points at the web app rather than the
|
|
235
|
+
API, and the app answered with an HTML page. Printing that page is how a
|
|
236
|
+
one-line configuration mistake turns into a screenful of markup with the
|
|
237
|
+
actual problem nowhere in it, so the body is deliberately not shown.
|
|
238
|
+
*/
|
|
239
|
+
if (payload === null) {
|
|
240
|
+
die(
|
|
241
|
+
`${base} is not answering as the Sumibako API (HTTP ${response.status}, ${contentType || "no content type"}).`,
|
|
242
|
+
"Check the address: pass --api <url>, or set SUMIBAKO_API.",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { response, payload, text };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function callApi(method, endpoint, { body, token, query, api } = {}) {
|
|
250
|
+
const base = api ?? resolveApi();
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
A token, or the connect flow, before anything is sent.
|
|
254
|
+
|
|
255
|
+
`ensureToken` can exit the process here, which is deliberate: every caller
|
|
256
|
+
of this function needs a credential, and there is nothing sensible for one
|
|
257
|
+
of them to do with "there isn't one" that is not already done better in one
|
|
258
|
+
place.
|
|
259
|
+
*/
|
|
260
|
+
const authToken = token ?? (await ensureToken(base));
|
|
261
|
+
|
|
262
|
+
const { response, payload, text } = await rawCall(method, base, endpoint, {
|
|
263
|
+
body,
|
|
264
|
+
query,
|
|
265
|
+
token: authToken,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (!response.ok) {
|
|
269
|
+
const message = payload?.error?.message ?? text.slice(0, 300) ?? "Request failed.";
|
|
270
|
+
const code = payload?.error?.code;
|
|
271
|
+
|
|
272
|
+
// The three failures worth explaining rather than just reporting, because
|
|
273
|
+
// each has a next step the person cannot guess from the message alone.
|
|
274
|
+
if (response.status === 401) {
|
|
275
|
+
/*
|
|
276
|
+
A saved token that the server refuses is a revoked one, and keeping it
|
|
277
|
+
would wedge this machine: every future command would authenticate with
|
|
278
|
+
it, fail, and print the same advice. Dropping it means the next command
|
|
279
|
+
starts a connect flow instead, which is the thing the person was going
|
|
280
|
+
to have to do anyway.
|
|
281
|
+
|
|
282
|
+
Only when it came from the config file. A token in the environment is
|
|
283
|
+
not ours to forget, and pretending to have forgotten it would send
|
|
284
|
+
somebody looking in the wrong place.
|
|
285
|
+
*/
|
|
286
|
+
if (process.env.SUMIBAKO_TOKEN?.trim()) {
|
|
287
|
+
// Running it again would fail identically, forever: the environment
|
|
288
|
+
// wins over the config file, so there is nothing for a connect flow
|
|
289
|
+
// to take effect on until that variable is dealt with.
|
|
290
|
+
die(
|
|
291
|
+
message,
|
|
292
|
+
"SUMIBAKO_TOKEN is set. Unset it and run the same command again to connect this machine.",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const config = readConfig();
|
|
296
|
+
if (config.token) {
|
|
297
|
+
delete config.token;
|
|
298
|
+
writeConfig(config);
|
|
299
|
+
}
|
|
300
|
+
die(message, "Run the same command again to connect this machine.");
|
|
301
|
+
}
|
|
302
|
+
if (response.status === 403) {
|
|
303
|
+
die(message, "Create a token with publishing allowed, in Settings.");
|
|
304
|
+
}
|
|
305
|
+
if (response.status === 429) {
|
|
306
|
+
const retry = response.headers.get("Retry-After");
|
|
307
|
+
die(message, retry ? `Wait ${retry} seconds and try again.` : undefined);
|
|
308
|
+
}
|
|
309
|
+
die(`${message}${code ? dim(` (${code})`) : ""}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return payload;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Connecting a machine
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
/*
|
|
320
|
+
Getting a token without anybody typing one.
|
|
321
|
+
|
|
322
|
+
The old flow was: open the app, mint a token, copy it, paste it here. That
|
|
323
|
+
reads as four steps and is really one problem - the paste can only be done by
|
|
324
|
+
a person sitting at this terminal, so connecting had to happen before the
|
|
325
|
+
agent was any use, and an agent that hit a missing token could do nothing but
|
|
326
|
+
give up and explain.
|
|
327
|
+
|
|
328
|
+
This is the device authorization grant. We hold 32 random bytes, send their
|
|
329
|
+
hash, get a short code back, and the approval happens in a browser where the
|
|
330
|
+
person already has a session. The bytes are what redeems the request, so the
|
|
331
|
+
code travelling through a URL and a scrollback gives nothing away.
|
|
332
|
+
|
|
333
|
+
Two properties are load-bearing for the agent case. Nothing here reads stdin,
|
|
334
|
+
so it works with no terminal attached. And nothing blocks for long: the first
|
|
335
|
+
run prints a link and exits, and a later run collects the token, so an agent
|
|
336
|
+
relays one line to its user and carries on instead of sitting inside a
|
|
337
|
+
command until the harness kills it.
|
|
338
|
+
*/
|
|
339
|
+
|
|
340
|
+
/** How long a redeem waits before giving the link back to the caller. */
|
|
341
|
+
const REDEEM_WAIT_MS = 20_000;
|
|
342
|
+
|
|
343
|
+
/** Gap between redeem attempts while waiting. */
|
|
344
|
+
const REDEEM_INTERVAL_MS = 2_000;
|
|
345
|
+
|
|
346
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Opens a URL in the desktop browser, and does not care if it cannot.
|
|
350
|
+
*
|
|
351
|
+
* Called only when stdout is a terminal. An agent, a container and a CI job all
|
|
352
|
+
* fail that test, which is exactly right: there is no browser to open there,
|
|
353
|
+
* and the URL is already printed for whoever can open one. That one condition
|
|
354
|
+
* covers every headless case without a flag to remember.
|
|
355
|
+
*/
|
|
356
|
+
function openInBrowser(url) {
|
|
357
|
+
try {
|
|
358
|
+
const [command, args] =
|
|
359
|
+
process.platform === "win32"
|
|
360
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
361
|
+
: process.platform === "darwin"
|
|
362
|
+
? ["open", [url]]
|
|
363
|
+
: ["xdg-open", [url]];
|
|
364
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
365
|
+
child.on("error", () => {});
|
|
366
|
+
child.unref();
|
|
367
|
+
} catch {
|
|
368
|
+
// The link is on screen either way.
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Describes this machine for the approval page. Never a secret. */
|
|
373
|
+
function describeClient() {
|
|
374
|
+
return `${os.hostname()}, ${process.platform}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Opens a connection request and remembers the half of it we must keep.
|
|
379
|
+
*
|
|
380
|
+
* The verifier is stored next to where the token will go, under the same 0600,
|
|
381
|
+
* because between this call and the next command it is the thing that can
|
|
382
|
+
* collect a credential.
|
|
383
|
+
*/
|
|
384
|
+
async function startConnect(base) {
|
|
385
|
+
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
386
|
+
const verifierHash = crypto
|
|
387
|
+
.createHash("sha256")
|
|
388
|
+
.update(verifier)
|
|
389
|
+
.digest("hex");
|
|
390
|
+
|
|
391
|
+
const { response, payload } = await rawCall("POST", base, "/v1/cli/start", {
|
|
392
|
+
body: { verifierHash, client: describeClient() },
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
die(
|
|
397
|
+
payload?.error?.message ?? "Could not start the connection.",
|
|
398
|
+
"Check the address: pass --api <url>, or set SUMIBAKO_API.",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const pending = {
|
|
403
|
+
userCode: payload.userCode,
|
|
404
|
+
verifier,
|
|
405
|
+
verificationUrl: payload.verificationUrl,
|
|
406
|
+
expiresAt: payload.expiresAt,
|
|
407
|
+
api: base,
|
|
408
|
+
};
|
|
409
|
+
writeConfig({ ...readConfig(), pending });
|
|
410
|
+
return pending;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The pending request, if there is one and it is still worth trying. */
|
|
414
|
+
function readPending(base) {
|
|
415
|
+
const pending = readConfig().pending;
|
|
416
|
+
if (!pending?.verifier || !pending?.userCode) return null;
|
|
417
|
+
if (typeof pending.expiresAt === "number" && pending.expiresAt < Date.now()) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
// A request opened against one deployment cannot be redeemed at another.
|
|
421
|
+
if (pending.api && pending.api !== base) return null;
|
|
422
|
+
return pending;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function clearPending() {
|
|
426
|
+
const config = readConfig();
|
|
427
|
+
delete config.pending;
|
|
428
|
+
writeConfig(config);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Waits for approval, up to `waitMs`, and saves the token when it arrives.
|
|
433
|
+
*
|
|
434
|
+
* Returns the token, or null if it is still pending. Anything that ends the
|
|
435
|
+
* request - denied, expired, collected already - clears the saved request and
|
|
436
|
+
* returns null, so the caller starts a fresh one rather than retrying a dead
|
|
437
|
+
* code forever.
|
|
438
|
+
*/
|
|
439
|
+
async function redeemPending(base, pending, waitMs) {
|
|
440
|
+
const deadline = Date.now() + waitMs;
|
|
441
|
+
|
|
442
|
+
for (;;) {
|
|
443
|
+
const attempt = await rawCall("POST", base, "/v1/cli/redeem", {
|
|
444
|
+
body: { userCode: pending.userCode, verifier: pending.verifier },
|
|
445
|
+
tolerateNetworkError: true,
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
/*
|
|
449
|
+
The connection dropped. Wait and ask again rather than giving up.
|
|
450
|
+
|
|
451
|
+
Almost every poll answers "pending" and consumes nothing, so repeating
|
|
452
|
+
one is free. The exception is the single reply that carries the token: if
|
|
453
|
+
that is the one that goes missing, the code has been spent and asking
|
|
454
|
+
again gets `expired`. The cost of that is one more `login`, which is why
|
|
455
|
+
this is a retry rather than something more careful.
|
|
456
|
+
*/
|
|
457
|
+
if (attempt === null) {
|
|
458
|
+
if (Date.now() + REDEEM_INTERVAL_MS > deadline) return null;
|
|
459
|
+
await sleep(REDEEM_INTERVAL_MS);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { response, payload } = attempt;
|
|
464
|
+
|
|
465
|
+
if (!response.ok) {
|
|
466
|
+
clearPending();
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (payload.status === "approved") {
|
|
471
|
+
const config = readConfig();
|
|
472
|
+
delete config.pending;
|
|
473
|
+
writeConfig({ ...config, token: payload.token });
|
|
474
|
+
return payload;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (payload.status !== "pending") {
|
|
478
|
+
// Denied or expired. Either way this code is finished.
|
|
479
|
+
clearPending();
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (Date.now() + REDEEM_INTERVAL_MS > deadline) return null;
|
|
484
|
+
await sleep(REDEEM_INTERVAL_MS);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* A usable token, or the connect instructions and exit 3.
|
|
490
|
+
*
|
|
491
|
+
* The whole flow in one function, because every authenticated command needs
|
|
492
|
+
* exactly this and none of them should be deciding any part of it themselves.
|
|
493
|
+
*/
|
|
494
|
+
async function ensureToken(base) {
|
|
495
|
+
const saved = resolveToken();
|
|
496
|
+
if (saved) return saved;
|
|
497
|
+
|
|
498
|
+
const pending = readPending(base);
|
|
499
|
+
if (pending) {
|
|
500
|
+
const approved = await redeemPending(base, pending, REDEEM_WAIT_MS);
|
|
501
|
+
if (approved) return approved.token;
|
|
502
|
+
|
|
503
|
+
// Still waiting: hand the same link back rather than opening a second
|
|
504
|
+
// request, so the page the person already has open is the right one.
|
|
505
|
+
const current = readPending(base);
|
|
506
|
+
if (current) needsAuth(current.verificationUrl, current.userCode);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const started = await startConnect(base);
|
|
510
|
+
needsAuth(started.verificationUrl, started.userCode);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
// Arguments
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
/** Parses `--flag value`, `--flag=value` and `--boolean` into an object. */
|
|
518
|
+
function parseFlags(argv) {
|
|
519
|
+
const flags = {};
|
|
520
|
+
const positional = [];
|
|
521
|
+
|
|
522
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
523
|
+
const item = argv[index];
|
|
524
|
+
if (!item.startsWith("--")) {
|
|
525
|
+
positional.push(item);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const equals = item.indexOf("=");
|
|
529
|
+
if (equals !== -1) {
|
|
530
|
+
flags[item.slice(2, equals)] = item.slice(equals + 1);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const name = item.slice(2);
|
|
534
|
+
const next = argv[index + 1];
|
|
535
|
+
if (next === undefined || next.startsWith("--")) {
|
|
536
|
+
flags[name] = true;
|
|
537
|
+
} else {
|
|
538
|
+
flags[name] = next;
|
|
539
|
+
index += 1;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { flags, positional };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* The key a file is filed under, so re-running lands on one page.
|
|
548
|
+
*
|
|
549
|
+
* The repo-relative path, because that is the identity of an artifact that a
|
|
550
|
+
* person and an agent will both agree on: `docs/plans/auth.md` is the plan for
|
|
551
|
+
* auth, this week and next. Falls back to the path as given when the file sits
|
|
552
|
+
* outside a git repository.
|
|
553
|
+
*
|
|
554
|
+
* Normalised to forward slashes so the same file keyed from Windows and from
|
|
555
|
+
* CI resolves to the same page.
|
|
556
|
+
*/
|
|
557
|
+
function defaultKey(filePath) {
|
|
558
|
+
const absolute = path.resolve(filePath);
|
|
559
|
+
let directory = path.dirname(absolute);
|
|
560
|
+
|
|
561
|
+
for (let depth = 0; depth < 40; depth += 1) {
|
|
562
|
+
if (fs.existsSync(path.join(directory, ".git"))) {
|
|
563
|
+
return path.relative(directory, absolute).split(path.sep).join("/");
|
|
564
|
+
}
|
|
565
|
+
const parent = path.dirname(directory);
|
|
566
|
+
if (parent === directory) break;
|
|
567
|
+
directory = parent;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return absolute.split(path.sep).join("/");
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function readMarkdown(filePath) {
|
|
574
|
+
if (filePath === "-") {
|
|
575
|
+
return fs.readFileSync(0, "utf8");
|
|
576
|
+
}
|
|
577
|
+
if (!fs.existsSync(filePath)) {
|
|
578
|
+
die(`No such file: ${filePath}`);
|
|
579
|
+
}
|
|
580
|
+
const stats = fs.statSync(filePath);
|
|
581
|
+
if (stats.isDirectory()) {
|
|
582
|
+
die(`${filePath} is a directory. Point at one Markdown file.`);
|
|
583
|
+
}
|
|
584
|
+
return fs.readFileSync(filePath, "utf8");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Commands
|
|
589
|
+
// ---------------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
/** Prints what a freshly connected machine may do. */
|
|
592
|
+
function reportSignedIn(plan, scopes) {
|
|
593
|
+
console.log(
|
|
594
|
+
`${green("Connected.")} Plan ${bold(plan)}, can ${scopes
|
|
595
|
+
.map((scope) => scope.replace("pages:", ""))
|
|
596
|
+
.join(", ")}.`,
|
|
597
|
+
);
|
|
598
|
+
console.log(dim(`Token saved to ${CONFIG_FILE}`));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Connects this machine.
|
|
603
|
+
*
|
|
604
|
+
* Three ways in, in the order they are checked. `--token` for somebody pasting
|
|
605
|
+
* one deliberately and for scripts; a browser approval for everybody else; and
|
|
606
|
+
* `SUMIBAKO_TOKEN` in the environment, which needs no command at all and is
|
|
607
|
+
* why CI never reaches this function.
|
|
608
|
+
*
|
|
609
|
+
* The browser half waits, unlike the connect flow inside `publish`. That
|
|
610
|
+
* difference is the point: a person who typed `login` is sitting there and
|
|
611
|
+
* expects the command to finish when they are done approving, while an agent
|
|
612
|
+
* that hit a missing token needs the link and its turn back.
|
|
613
|
+
*/
|
|
614
|
+
async function login(flags) {
|
|
615
|
+
const api = typeof flags.api === "string" ? flags.api.replace(/\/+$/, "") : undefined;
|
|
616
|
+
const base = api ?? resolveApi();
|
|
617
|
+
|
|
618
|
+
let token = typeof flags.token === "string" ? flags.token.trim() : "";
|
|
619
|
+
|
|
620
|
+
// A pasted token still has to work before it is saved, so a mistyped one
|
|
621
|
+
// fails here rather than halfway through the next session, and a failed
|
|
622
|
+
// login leaves whatever was configured before it untouched.
|
|
623
|
+
if (token) {
|
|
624
|
+
const who = await callApi("GET", "/v1/whoami", { token, api });
|
|
625
|
+
const config = readConfig();
|
|
626
|
+
delete config.pending;
|
|
627
|
+
writeConfig({ ...config, token, ...(api ? { api } : {}) });
|
|
628
|
+
reportSignedIn(who.plan, who.scopes);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const pending = readPending(base) ?? (await startConnect(base));
|
|
633
|
+
|
|
634
|
+
console.log(`Open this to connect ${bold(describeClient())}:`);
|
|
635
|
+
console.log(pending.verificationUrl);
|
|
636
|
+
console.log();
|
|
637
|
+
console.log(dim(`The page should show the code ${bold(pending.userCode)}.`));
|
|
638
|
+
|
|
639
|
+
if (process.stdout.isTTY) openInBrowser(pending.verificationUrl);
|
|
640
|
+
|
|
641
|
+
const remaining = Math.max(0, (pending.expiresAt ?? 0) - Date.now());
|
|
642
|
+
if (remaining === 0) {
|
|
643
|
+
die("That request expired.", "Run `npx sumibako login` again.");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
console.log();
|
|
647
|
+
console.log(dim("Waiting for you to approve it..."));
|
|
648
|
+
|
|
649
|
+
const approved = await redeemPending(base, pending, remaining);
|
|
650
|
+
if (!approved) {
|
|
651
|
+
die(
|
|
652
|
+
"The request was not approved.",
|
|
653
|
+
"Run `npx sumibako login` again, or pass --token to paste one instead.",
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (api) writeConfig({ ...readConfig(), api });
|
|
658
|
+
reportSignedIn(approved.plan, approved.scopes);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function logout() {
|
|
662
|
+
const config = readConfig();
|
|
663
|
+
delete config.token;
|
|
664
|
+
// Also the half-finished connection, if there is one. Leaving it would let
|
|
665
|
+
// the next command silently pick up a token from a request made before the
|
|
666
|
+
// person asked to be signed out.
|
|
667
|
+
delete config.pending;
|
|
668
|
+
writeConfig(config);
|
|
669
|
+
console.log(`${green("Signed out.")} The token was removed from this machine.`);
|
|
670
|
+
console.log(dim("Revoke it in Settings if it may have leaked."));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async function publish(positional, flags) {
|
|
674
|
+
const file = positional[0];
|
|
675
|
+
if (!file) {
|
|
676
|
+
die("Which file?", "Usage: sumibako publish <file.md> [--public]");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const markdown = readMarkdown(file);
|
|
680
|
+
if (!markdown.trim()) die(`${file} is empty.`);
|
|
681
|
+
|
|
682
|
+
const key =
|
|
683
|
+
typeof flags.key === "string"
|
|
684
|
+
? flags.key
|
|
685
|
+
: flags.new === true || file === "-"
|
|
686
|
+
? undefined
|
|
687
|
+
: defaultKey(file);
|
|
688
|
+
|
|
689
|
+
const wantsPublic = flags.public === true || flags.publish === true;
|
|
690
|
+
|
|
691
|
+
const result = await callApi("POST", "/v1/pages", {
|
|
692
|
+
body: {
|
|
693
|
+
markdown,
|
|
694
|
+
externalId: key,
|
|
695
|
+
title: typeof flags.title === "string" ? flags.title : undefined,
|
|
696
|
+
parentDocument: typeof flags.parent === "string" ? flags.parent : undefined,
|
|
697
|
+
publish: wantsPublic ? true : undefined,
|
|
698
|
+
},
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
report(result, { verb: result.created ? "Created" : "Updated" });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Works out whether an argument names a file on disk or a page id.
|
|
706
|
+
*
|
|
707
|
+
* A file that exists is keyed by its repo path, the same way `publish` filed
|
|
708
|
+
* it. Anything else is taken to be a page id, which is what someone pastes
|
|
709
|
+
* after copying it out of a URL.
|
|
710
|
+
*/
|
|
711
|
+
function targetFor(argument, flags) {
|
|
712
|
+
if (typeof flags.key === "string") return { externalId: flags.key };
|
|
713
|
+
if (!argument) return {};
|
|
714
|
+
if (fs.existsSync(argument)) return { externalId: defaultKey(argument) };
|
|
715
|
+
return { documentId: argument };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Adds to a page without sending it back.
|
|
720
|
+
*
|
|
721
|
+
* The running-log case, which `publish` handles badly: filing a note at the
|
|
722
|
+
* end of a session with `publish` means reading the whole page, adding a line
|
|
723
|
+
* and writing all of it again. Here the text to add is the only thing that
|
|
724
|
+
* crosses the wire.
|
|
725
|
+
*
|
|
726
|
+
* Words after the target are the text. With none, it is read from stdin, so
|
|
727
|
+
* `git log -1 --format=%s | sumibako append CHANGELOG.md` works.
|
|
728
|
+
*/
|
|
729
|
+
async function append(positional, flags) {
|
|
730
|
+
const target = targetFor(positional[0], flags);
|
|
731
|
+
if (!target.documentId && !target.externalId) {
|
|
732
|
+
die("Which page?", "Usage: sumibako append <file.md | page-id> \"text\"");
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const inline = positional.slice(1).join(" ");
|
|
736
|
+
const text = inline || fs.readFileSync(0, "utf8");
|
|
737
|
+
if (!text.trim()) {
|
|
738
|
+
die("Nothing to add.", "Pass the text as an argument or pipe it in.");
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const result = await callApi("PATCH", "/v1/pages", {
|
|
742
|
+
body: {
|
|
743
|
+
...target,
|
|
744
|
+
[flags.prepend === true ? "prepend" : "append"]: text,
|
|
745
|
+
},
|
|
746
|
+
});
|
|
747
|
+
report(result, {
|
|
748
|
+
verb: flags.prepend === true ? "Prepended to" : "Appended to",
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Replaces one exact piece of text in a page.
|
|
754
|
+
*
|
|
755
|
+
* `--find` matches against the page's Markdown, which is what `open
|
|
756
|
+
* --markdown` prints, so the way to use this is to look first and paste. The
|
|
757
|
+
* API refuses a match that is not unique rather than guessing, so a failure
|
|
758
|
+
* here means "say more", not "try again".
|
|
759
|
+
*/
|
|
760
|
+
async function edit(positional, flags) {
|
|
761
|
+
const target = targetFor(positional[0], flags);
|
|
762
|
+
if (!target.documentId && !target.externalId) {
|
|
763
|
+
die(
|
|
764
|
+
"Which page?",
|
|
765
|
+
"Usage: sumibako edit <file.md | page-id> --find <old> --replace <new>",
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const rename = typeof flags.title === "string" ? flags.title : undefined;
|
|
770
|
+
const find = typeof flags.find === "string" ? flags.find : undefined;
|
|
771
|
+
if (find === undefined && rename === undefined) {
|
|
772
|
+
die(
|
|
773
|
+
"Nothing to change.",
|
|
774
|
+
"Pass --find with --replace, or --title to rename the page.",
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
// An empty --replace is a deletion, and has to survive the default below.
|
|
778
|
+
const replace = typeof flags.replace === "string" ? flags.replace : "";
|
|
779
|
+
|
|
780
|
+
const result = await callApi("PATCH", "/v1/pages", {
|
|
781
|
+
body: {
|
|
782
|
+
...target,
|
|
783
|
+
...(find !== undefined ? { find, replace } : {}),
|
|
784
|
+
...(rename !== undefined ? { title: rename } : {}),
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
report(result, { verb: "Edited" });
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
async function unpublish(positional, flags) {
|
|
791
|
+
const target = targetFor(positional[0], flags);
|
|
792
|
+
if (!target.documentId && !target.externalId) {
|
|
793
|
+
die("Which page?", "Usage: sumibako unpublish <file.md | page-id>");
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const result = await callApi("POST", "/v1/pages/publish", {
|
|
797
|
+
body: { ...target, publish: false },
|
|
798
|
+
});
|
|
799
|
+
console.log(`${green("Taken down.")} ${bold(result.title)} is private again.`);
|
|
800
|
+
console.log(dim(result.url));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function open(positional, flags) {
|
|
804
|
+
const target = targetFor(positional[0], flags);
|
|
805
|
+
if (!target.documentId && !target.externalId) {
|
|
806
|
+
die("Which page?", "Usage: sumibako open <file.md | page-id>");
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const page = await callApi("GET", "/v1/pages", {
|
|
810
|
+
query: { id: target.documentId, externalId: target.externalId },
|
|
811
|
+
});
|
|
812
|
+
console.log(bold(page.title));
|
|
813
|
+
console.log(page.url);
|
|
814
|
+
if (page.publicUrl) console.log(green(page.publicUrl));
|
|
815
|
+
|
|
816
|
+
// Two names for one thing. `--text` came first and is in people's scripts;
|
|
817
|
+
// Markdown is strictly the better answer, because it is what you edit and
|
|
818
|
+
// send back, so both flags print it rather than keeping a worse output alive
|
|
819
|
+
// for the sake of a flag name.
|
|
820
|
+
if (flags.markdown === true || flags.text === true) {
|
|
821
|
+
console.log();
|
|
822
|
+
console.log(page.markdown || page.text);
|
|
823
|
+
for (const warning of page.markdownWarnings ?? []) {
|
|
824
|
+
console.log(yellow(`note ${warning}`));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function search(positional) {
|
|
830
|
+
// No words is a question too: "what is in here". It lists the newest pages
|
|
831
|
+
// rather than explaining the command to somebody who has just been handed a
|
|
832
|
+
// vault they have never seen.
|
|
833
|
+
const term = positional.join(" ").trim();
|
|
834
|
+
|
|
835
|
+
const { results } = await callApi("GET", "/v1/pages/search", {
|
|
836
|
+
query: { q: term || undefined, limit: term ? undefined : 20 },
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
if (results.length === 0) {
|
|
840
|
+
console.log(dim(term ? "Nothing matched." : "This vault has no pages yet."));
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
for (const page of results) {
|
|
844
|
+
console.log(`${bold(page.title)}${page.publicUrl ? green(" public") : ""}`);
|
|
845
|
+
console.log(dim(` ${page.publicUrl ?? page.url}`));
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function whoami() {
|
|
850
|
+
const who = await callApi("GET", "/v1/whoami");
|
|
851
|
+
const usage = await callApi("GET", "/v1/usage");
|
|
852
|
+
console.log(`Plan ${bold(who.plan)} at ${who.site}`);
|
|
853
|
+
console.log(`Permissions: ${who.scopes.join(", ")}`);
|
|
854
|
+
console.log(
|
|
855
|
+
`Pages: ${usage.documents} of ${limit(usage.maxDocuments)} Published: ${usage.published} of ${limit(usage.maxPublished)}`,
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const limit = (value) => (value === null || !Number.isFinite(value) ? "unlimited" : value);
|
|
860
|
+
|
|
861
|
+
/** Prints the outcome of a write, link last so it is the easiest thing to copy. */
|
|
862
|
+
function report(result, { verb }) {
|
|
863
|
+
console.log(`${green(verb)} ${bold(result.title)}`);
|
|
864
|
+
for (const warning of result.warnings ?? []) {
|
|
865
|
+
console.log(`${yellow("note")} ${warning}`);
|
|
866
|
+
}
|
|
867
|
+
console.log(dim(result.url));
|
|
868
|
+
if (result.publicUrl) {
|
|
869
|
+
console.log();
|
|
870
|
+
console.log(`${bold("Share this:")} ${result.publicUrl}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// ---------------------------------------------------------------------------
|
|
875
|
+
// Entry
|
|
876
|
+
// ---------------------------------------------------------------------------
|
|
877
|
+
|
|
878
|
+
const HELP = `
|
|
879
|
+
${bold("sumibako")} - file what your coding agent wrote into your vault
|
|
880
|
+
|
|
881
|
+
${bold("sumibako login")} [--token <t>] [--api <url>] connect this machine
|
|
882
|
+
${bold("sumibako publish")} <file.md> [--public] file a Markdown file as a page
|
|
883
|
+
${bold("sumibako unpublish")} <file.md> take a published page off the web
|
|
884
|
+
${bold("sumibako append")} <file.md> <text> add to the end of a page
|
|
885
|
+
${bold("sumibako edit")} <file.md> --find ... replace one piece of text
|
|
886
|
+
${bold("sumibako open")} <file.md> [--markdown] print the links, or the page
|
|
887
|
+
${bold("sumibako search")} [words] search your vault, or list it
|
|
888
|
+
${bold("sumibako whoami")} check the token and plan
|
|
889
|
+
${bold("sumibako logout")} forget the token
|
|
890
|
+
|
|
891
|
+
${bold("Options for publish")}
|
|
892
|
+
--public publish it and print a shareable link
|
|
893
|
+
--title <title> override the title (default: the first heading)
|
|
894
|
+
--key <key> the identity of this artifact (default: its repo path)
|
|
895
|
+
--new file a new page even if this file was filed before
|
|
896
|
+
--parent <page-id> nest it under an existing page
|
|
897
|
+
|
|
898
|
+
${bold("Options for append and edit")}
|
|
899
|
+
--prepend add to the start of the page instead of the end
|
|
900
|
+
--find <text> the exact text to replace, as open --markdown prints it
|
|
901
|
+
--replace <text> what to put there; empty deletes the matched text
|
|
902
|
+
--title <title> rename the page
|
|
903
|
+
--key <key> name the page by its key rather than a path or an id
|
|
904
|
+
|
|
905
|
+
${bold("Editing a page you did not write")}
|
|
906
|
+
open --markdown prints the page as Markdown, which is the same text --find
|
|
907
|
+
matches against. A --find that appears twice is refused rather than guessed
|
|
908
|
+
at, so quote enough of the surrounding lines to be unambiguous.
|
|
909
|
+
|
|
910
|
+
${bold("How re-running works")}
|
|
911
|
+
A file is filed under its path in the repo, so publishing the same file again
|
|
912
|
+
updates the same page instead of making a second one. Pass --new when you
|
|
913
|
+
genuinely want another page, or --key to choose the identity yourself.
|
|
914
|
+
|
|
915
|
+
${bold("Connecting")}
|
|
916
|
+
Any command will start it: with no token, it prints a link to open and exits
|
|
917
|
+
with code 3. Open the link, approve it, run the same command again. Nothing
|
|
918
|
+
is typed and nothing waits, so an agent can do this without stalling.
|
|
919
|
+
|
|
920
|
+
Exit codes: 0 worked, 3 not connected yet, 1 everything else.
|
|
921
|
+
|
|
922
|
+
${bold("Environment")}
|
|
923
|
+
SUMIBAKO_TOKEN use this token instead of the saved one
|
|
924
|
+
SUMIBAKO_API point at a different deployment
|
|
925
|
+
`;
|
|
926
|
+
|
|
927
|
+
async function main() {
|
|
928
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
929
|
+
const { flags, positional } = parseFlags(rest);
|
|
930
|
+
|
|
931
|
+
if (!command || command === "help" || flags.help === true) {
|
|
932
|
+
console.log(HELP);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
if (command === "--version" || command === "version") {
|
|
936
|
+
console.log(version());
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
switch (command) {
|
|
941
|
+
case "login":
|
|
942
|
+
return login(flags);
|
|
943
|
+
case "logout":
|
|
944
|
+
return logout();
|
|
945
|
+
case "publish":
|
|
946
|
+
case "push":
|
|
947
|
+
return publish(positional, flags);
|
|
948
|
+
case "unpublish":
|
|
949
|
+
return unpublish(positional, flags);
|
|
950
|
+
case "append":
|
|
951
|
+
return append(positional, flags);
|
|
952
|
+
case "edit":
|
|
953
|
+
return edit(positional, flags);
|
|
954
|
+
case "open":
|
|
955
|
+
case "get":
|
|
956
|
+
return open(positional, flags);
|
|
957
|
+
case "search":
|
|
958
|
+
case "list":
|
|
959
|
+
return search(positional);
|
|
960
|
+
case "whoami":
|
|
961
|
+
return whoami();
|
|
962
|
+
default:
|
|
963
|
+
die(`Unknown command: ${command}`, "Run `sumibako help` for the list.");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
main().catch((error) => {
|
|
968
|
+
die(error instanceof Error ? error.message : String(error));
|
|
969
|
+
});
|