agentcollar 0.0.1 → 0.2.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.
@@ -0,0 +1,40 @@
1
+ // GENERATED by tools/gen-intro.ts from brand/logo/agentcollar-mark-black.png. Do not edit by hand.
2
+ // Derived from the AgentCollar mark: © 2026 ank8dev, all rights reserved (NOT covered by the MIT license).
3
+ // The medallion, 48×48 dots (24 columns × 12 rows), turned 0°, 30°, … 330° clockwise.
4
+ export const SPIN = [
5
+ ["", " ⢀⣤⣤⣴⣶⣶⣶⣦⣄⡀", " ⣤⣾⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢠⣾⣿⣿⣿⠿⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀", " ⢀⣿⢻⣿⣿⡟ ⡀⠹⣿⣿⡿⠉⢀⡀⠘⣿⣿⣷⡀", " ⢸⣿⣾⣿⡿ ⣸⣧ ⢻⣿⠁ ⣿⣿⣿⣿⣿⣿⡇", " ⢸⣿⣿⣿⠃ ⣀⣀⡀⠈⣿ ⠘⣿⣿⡿⣿⣿⣿⡇", " ⠈⣿⣿⡏ ⣼⣿⣿⣷ ⢸⣧⡀⠉⠉⢀⣼⣿⣿⠇", " ⠘⣿⣷⣾⣿⣿⣿⣿⣷⣾⣿⣿⣶⣾⣿⡿⣻⠏", " ⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁", " ⠉⠛⠳⠶⠿⠿⠿⠟⠛⠁", ""],
6
+ ["", " ⣀⣤⣴⣶⣒⣶⣶⣤⣄⣀", " ⢀⣤⡾⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢠⣾⣯⣾⣿⣿⠿⠋⠉⢹⣿⣿⣿⣿⣿⣿⣷⡀", " ⢀⣿⣿⣿⡿⠟⠁⣠⡆ ⣾⣿⣿⣿⣿⣿⣿⣿⣷", " ⢸⣿⡿⠋ ⣀⡀⠉⠁⢀⣿⡿⠋⠉⣀⠉⢻⣿⣿⡆", " ⢸⣿⣦⣤⣾⣿⣿⡇ ⣸⡏ ⣠⣾⣿⣤⣼⣿⣏⡇", " ⠘⣿⣿⣿⣿⣿⣿⡇⢀⣿ ⢰⣿⣿⣿⣿⣿⣿⣿⠁", " ⠘⣿⡿⣿⣿⣿⣿⣿⣿⣄ ⠉⠁⣹⣿⣿⣿⠃", " ⠈⠻⢮⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁", " ⠈⠛⠻⠿⠿⠿⠿⠿⠚⠋⠁", ""],
7
+ ["", " ⣀⣤⣤⣶⣶⣶⣶⣦⣤⡀", " ⢀⣴⣾⣿⣿⣶⣶⣿⣿⣿⣿⣷⣾⣷⣄", " ⢠⣿⣿⣿⡿⠿⠿⠛⠛⠛⠉⠛⣿⣿⣿⣿⣷⡄", " ⢠⣿⣿⣀⣀⣀⣀ ⢲⡶⠋⢀⣾⣿⣿⣿⣿⣿⣷⡀", " ⢸⣿⣿⣿⣿⣿⣿⡦ ⣴⣿⣿⣿⣿⣿⣿⣿⣿⡇", " ⢸⡿⣿⣿⣿⣿⠋ ⣠⡾⠋⠉⠉⠁⠉⠻⣿⣿⣿⡇", " ⠈⣷⣻⣿⣿⣿⣶⣿⠃⢀⣴⣾⣿⣿⡃⢠⣿⣿⡿", " ⠈⢿⣿⣿⣿⣿⣿⡀⠘⠿⢿⣿⣿⣿⣿⣿⡿⠁", " ⠻⢿⣿⣿⣿⣿⣶⣤⣼⣿⣿⣿⡿⠋", " ⠉⠛⠿⢭⣿⣿⣿⠿⠛⠉", ""],
8
+ ["", " ⣀⣤⣴⣶⣶⣶⣶⣦⣤⣀", " ⢀⣤⣿⡿⠿⢿⣿⣿⣿⣷⣶⣿⣿⣷⣤", " ⢠⣿⣿⣿⣷⣤⣀ ⠉⠙⠛⠿⣿⣿⣿⣿⡳⡀", " ⢀⡿⣿⣿⣿⣿⣿⣿⡇ ⣷⠶⠄ ⢘⣿⣿⣿⣿", " ⢸⣧⣿⣿⣿⡿⠛⠋⠁⢀⣀⣤⣴⣾⣿⣿⣿⣿⣿⡆", " ⢸⣿⣿⣿⣿⣷⣶⠶⠛⠛⠛⠻⢿⣿⣿⣿⣿⣿⣿⡇", " ⠈⣿⣿⣿⣿⡟⠁⢠⣤⣶⣤⣤⡀⠘⣿⣿⣿⣿⡿⠁", " ⠈⣿⣿⣿⣷⡀⠘⢿⣿⣿⣿⠁⣀⣿⣿⣿⡿⠁", " ⠈⠻⣝⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⡿⠋", " ⠈⠙⠻⠿⠿⠿⠿⠿⠟⠋⠁", ""],
9
+ ["", " ⣀⣤⣶⣶⣶⣶⣶⣦⣤⣀", " ⢀⣴⢿⣿⣿⣿⣿⠁⠙⢿⣿⣿⣿⢷⣦", " ⢀⣾⣵⣿⣿⣿⣿⣿⣷⡄ ⠹⣿⣿⣷⣹⣷⡄", " ⢀⣿⣿⣿⣿⣿⠿⠿⠿⠿⠁⢠⣄⠈⢻⣿⣿⣿⣿⡀", " ⢸⣿⣿⣿⣿⣿⣦⣤⣄⣀⡀⠈⠉⠁ ⢹⣿⣿⡟⡇", " ⢸⣿⣿⣿⠋ ⣀⡀⠉⠙⢿⣿⣿⣷⣶⣾⣿⣿⣿⡇", " ⠘⣟⣿⣿ ⠸⣿⣿⣷⣄ ⢹⣿⣿⣿⣿⣿⣿⡿", " ⠹⣿⣿⣷⣾⣿⣿⣿⠛⠃⢠⣿⣿⣿⣿⣿⡿⠃", " ⠙⠻⣿⣿⣿⣿⣿⣶⣶⣿⣿⣿⣿⡿⠋", " ⠉⠛⠻⠯⠽⠿⠟⠛⠋⠁", ""],
10
+ ["", " ⢀⣤⡴⢶⣶⣶⣶⣶⣤⣀", " ⢀⣤⣾⣿⣷⣿⣿⣿⣿⣿⡟⠛⣿⣿⣦⡀", " ⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇ ⢿⣿⣿⣷⡄", " ⢠⣿⣿⣿⣿⣿⣿⣧ ⠙⢿⠟⠃ ⢸⣿⣿⡟⣿", " ⣾⣼⣿⡟⠁⣀⡀⠉⢷⣄ ⢶⡇ ⣿⣿⣧⣿⡇", " ⣿⣿⣿⣀⣸⣿⣿⣆ ⢹⣿⣦⡀⠙ ⢻⣿⣿⣿⡇", " ⠘⣿⣿⣿⣿⣿⣿⣿ ⠸⣿⣿⣿⣷⣤⣿⣿⡿⣿⠁", " ⠘⢿⣿⣿⣿⣁⠉⢀⣼⣿⣿⣿⣿⣿⣿⣿⡷⠁", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋", " ⠈⠙⠛⠿⠿⠿⠿⠟⠋⠉", ""],
11
+ ["", " ⢀⣤⣴⣶⣶⣶⠶⢦⣤⣀", " ⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦", " ⣰⣯⣾⣿⡿⠿⣿⣿⡿⢿⣿⣿⣿⣿⡿⢿⣿⡄", " ⢰⣿⣿⡟⠁⣀⣀⠈⢻⡇ ⢿⣿⣿⡟ ⣸⣿⣿⡀", " ⢸⣿⣿⣿⣾⣿⣿⡄ ⣿⡀⠈⠉⠉ ⢠⣿⣿⣿⡇", " ⢸⣿⣿⣿⣿⣿⣿ ⢀⣿⣧ ⢻⡏ ⣾⣿⡿⣿⡇", " ⠈⢿⣿⣿⡄⠈⠁⣀⣾⣿⣿⣆⠈ ⣼⣿⣿⣧⣿⠁", " ⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣶⣿⣿⣿⡿⠃", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢟⡿⠛", " ⠈⠙⠻⠿⠿⠿⠟⠛⠛⠁", ""],
12
+ ["", " ⢀⣠⡤⣶⣶⣶⣶⣶⣦⣤⡀", " ⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡳⣦⡀", " ⢠⣿⣿⣿⣏⢀⣀ ⠙⣿⣿⣿⣿⣿⣿⣾⣿⡄", " ⢀⣿⣿⣿⣿⣿⣿⣿⠇ ⣿⠁⢸⣿⣿⣿⣿⣿⣿⡄", " ⢸⣹⣿⡟⠛⣿⡿⠋ ⣸⡏ ⢸⣿⣿⡿⠛⠻⣿⡇", " ⠸⣿⣿⣧⣀⠉⣀⣠⣾⣿⠁⢀⣀⠈⠉ ⣠⣾⣿⡇", " ⢿⣿⣿⣿⣿⣿⣿⣿⡿ ⠸⠋⢀⣴⣾⣿⣿⣿⠁", " ⠈⢿⣿⣿⣿⣿⣿⣿⣇⣀⣠⣶⣿⣿⡿⣻⡿⠃", " ⠙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡾⠛⠁", " ⠉⠙⠛⠿⠿⠭⠿⠟⠛⠉", ""],
13
+ ["", " ⣀⣤⣶⣿⣿⣿⣓⣶⣤⣀", " ⣠⣾⣿⣿⣿⡟⠛⠿⣿⣿⣿⣿⣷⣦", " ⢀⣾⣿⣿⣿⣿⣿⣷⣶⡄⠈⣿⣿⣿⣿⣿⣷⡀", " ⣾⣿⣿⠃⢨⣿⣿⡿⠟⠁⢠⣿⠿⣿⣿⣿⣯⢿⡀", " ⢸⣿⣿⣿⣦⣀⢀⣀⣀⣠⡾⠋ ⣠⣿⣿⣿⣿⣾⡇", " ⢸⣿⣿⣿⣿⣿⣿⣿⣿⠟ ⠺⣿⣿⣿⣿⣿⣿⡇", " ⠈⢿⣿⣿⣿⣿⣿⡿⠁⣠⠾⠧ ⠉⠉⠉⠉⣿⣿⠃", " ⠘⢿⣿⣿⣿⣿⣤⣀⣤⣤⣤⣶⣶⣾⣿⣿⣿⠃", " ⠙⢿⡿⢿⣿⣿⣿⣿⠿⠿⣿⣿⡿⠟⠁", " ⠈⠛⠻⠿⠿⠿⠿⠛⠛⠉", ""],
14
+ ["", " ⢀⣠⣴⣶⣶⣶⣶⣶⣦⣄⡀", " ⣠⣾⣿⣿⣿⣿⣿⣿⣿⠿⣿⣿⣝⣦⡀", " ⢀⣾⣿⣿⣿⠉⢀⣿⣿⣿⣷⡄⠈⢿⣿⣿⣿⡀", " ⢀⣾⣿⣿⣿⣿⡄⠈⠛⠛⠿⠛⠃⢀⣼⣿⣿⣿⣿⡀", " ⢸⣿⣿⣿⣿⣿⣿⣷⣦⣤⣤⣤⠶⠿⢿⣿⣿⣿⣿⡇", " ⠸⣿⣿⣿⣿⣿⡿⠟⠛⠉⠁⢀⣠⣤⣾⣿⣿⣿⢻⡇", " ⣿⣿⣿⣿⡅ ⠐⠶⢿ ⢸⣿⣿⣿⣿⣿⣿⣾⠁", " ⠈⢮⣿⣿⣿⣿⣶⣤⣄⣀ ⠉⠛⢿⣿⣿⣿⠃", " ⠛⢿⣿⣿⠿⢿⣿⣿⣿⣷⣶⣾⣿⠛⠁", " ⠉⠛⠻⠿⠿⠿⠿⠟⠛⠉", ""],
15
+ ["", " ⢀⣠⣤⣴⣶⣖⣲⣦⣤⣀", " ⣠⣾⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣿⣦⣄", " ⢠⣾⣿⣿⣿⣿⣿⠃⢠⣤⣿⣿⣿⡿⢿⣿⣿⣆", " ⣾⣿⣿⣿⣿⣿⣿⣇ ⠙⢿⣿⣿⡆ ⣿⣿⣽⡄", " ⢸⣿⣿⣿⡿⠿⢿⣿⣿⣷⣄⣀⠈⠉ ⣠⣿⣿⣿⡇", " ⢸⣼⣿⣿⣇ ⢀⣀⡀⠈⠉⠙⠛⠻⣿⣿⣿⣿⣿⡇", " ⠈⣿⣿⣿⣿⣧⡀⠙⠃⢀⣶⣶⣶⣶⣿⣿⣿⣿⣿⠁", " ⠘⢿⣏⢿⣿⣿⣆ ⠘⢿⣿⣿⣿⣿⣿⢟⡿⠁", " ⠻⢷⣿⣿⣿⣷⣄⢀⣿⣿⣿⣿⣷⠟⠁", " ⠉⠛⠻⠿⠿⠿⠿⠿⠛⠉", ""],
16
+ ["", " ⣀⣠⣴⣶⣶⣶⣶⣤⣄⡀", " ⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄", " ⢀⢾⣿⣿⣿⣿⣿⣿⣿⡟⠁⣀⢉⣿⣿⣿⣷⡄", " ⢀⣿⣾⣿⣿⠛⢿⣿⣿⣿⡆ ⣿⣿⣿⣿⣿⣿⣿⡄", " ⢸⣿⣿⣿⣧ ⣄⠈⠻⣿⣇ ⠹⣿⣿⡏⠉⣿⣿⣿", " ⢸⣿⢻⣿⣿ ⢸⠷ ⠙⢷⣀⠈⠉⢀⣼⣿⡟⡿", " ⣿⣼⣿⣿⡇ ⢠⣴⣷⣄ ⢻⣿⣿⣿⣿⣿⣿⠃", " ⠘⢿⣿⣿⣷ ⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃", " ⠈⠻⣿⣿⣤⣼⣿⣿⣿⣿⣿⢿⣿⡿⠛⠁", " ⠉⠛⠿⠿⠿⠿⠷⠞⠛⠁", ""],
17
+ ];
18
+ // The same, 32×32 dots (16 columns × 8 rows), for narrow terminal windows.
19
+ export const SPIN_SMALL = [
20
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀", " ⣴⣿⣿⡟⠻⣿⣿⡿⠟⢿⣿⣆", " ⢠⣿⣿⡟⢠⡆⢹⡿ ⣶⣶⣿⣿⡄", " ⠘⣿⣿⠁⣠⣤ ⢧ ⠿⠟⣿⣿⡇", " ⠹⣧⣴⣿⣿⣧⣼⣷⣤⣶⣿⡟", " ⠙⠿⢿⣿⣿⣿⣿⣿⠟⠋", " ⠉⠉"],
21
+ [" ⣀⣀", " ⢀⡴⣾⣿⣷⣿⣿⣷⣦⡀", " ⣰⣿⣿⡿⠋⠉⣿⣿⣿⣿⣿⣆", " ⢰⣿⠟⠉⡀⠛ ⣿⠟⠛⠛⢿⣿⡀", " ⠸⣿⣶⣾⣿⠇⢸⠃⣰⣿⣷⣾⣿⠅", " ⠻⣿⣿⣿⣶⣿⡀⠙⢛⣿⣿⡏", " ⠉⠻⢿⣿⣿⣿⣿⣿⠿⠋", " ⠉⠉⠁"],
22
+ [" ⣀⣀", " ⣀⣴⣿⣿⣽⣿⣿⣷⣦⡀", " ⣴⡿⠟⠛⠛⣉⡉⢙⣿⣿⣿⣆", " ⢰⣿⣷⣶⣷⡄⠉⣰⣿⣿⣿⣿⣿⡀", " ⠸⣿⣿⣿⡋⣠⠞⢉⣀⣈⠙⣿⣿⠁", " ⠹⣿⣿⣿⣇⠐⠿⣿⣷⣾⣿⠏", " ⠈⠻⣿⣿⣷⣶⣿⣿⠟⠉", " ⠈⠉⠉⠉"],
23
+ [" ⣀⣀", " ⣠⣴⠾⣿⣿⣿⣿⣿⣦⡀", " ⣸⣿⣿⣦⣄⠈⣉⠛⠿⣿⣿⣆", " ⢠⣿⣿⣿⠿⠛ ⣉⣡⣴⣿⣿⣿⡀", " ⠘⣿⣿⣿⡶⠚⠉⠙⠛⢿⣿⣿⣿⠁", " ⠻⣿⣿⡄⠸⣿⣿⡇⣨⣿⣿⠏", " ⠙⠿⣿⣿⣿⣿⣿⡿⠟⠁", " ⠉⠉⠉"],
24
+ [" ⢀⣀⣀⡀", " ⢠⣴⣿⣿⡟⠻⣿⣷⣦⡀", " ⣰⣿⣿⣿⣿⣷⠄⠘⢿⣿⣽⣆", " ⢠⣿⣿⣿⣧⣈⣉ ⠛ ⢹⣿⡿⡄", " ⠸⣿⣿⠁⣠⣄⡉⠻⣿⣿⣿⣿⣿⠃", " ⢻⣿⣦⣿⣿⡿ ⣿⣿⣿⣿⠏", " ⠙⠿⢿⣿⣷⣾⣿⡿⠟⠁", " ⠈⠈⠁"],
25
+ [" ⢀⣀⣀⡀", " ⢀⣴⣾⣿⣿⣿⡿⢿⣦⡄", " ⣴⣿⣿⣿⠟⢿⣿⡧⠈⣿⣿⣦", " ⢰⣿⡿⠋⡉⠳⣄⠉⢠⡄⢻⣿⣻⡄", " ⢸⣿⣧⣼⣿⡆⠘⣷⣦⡁⣸⣿⣿⠃", " ⠻⣿⣿⣏⠃⣰⣿⣿⣿⣿⣿⠏", " ⠘⠻⢿⣿⣿⣿⣿⡿⠟⠁", " ⠈⠁"],
26
+ [" ⣀⣀", " ⣠⣴⣿⣿⣿⣿⣿⣷⣶⣄", " ⣼⣿⠿⠛⢿⡟⢻⣿⣿⠟⢻⣆", " ⢸⣿⣿⣴⣶ ⢳ ⠛⠋⢀⣿⣿⡄", " ⠘⣿⣿⠿⠿ ⣾⣇⠸⠃⣼⣿⣿⠃", " ⠹⣿⣷⣴⣾⣿⣿⣦⣼⣿⣿⠟", " ⠈⠻⢿⣿⣿⣿⣿⡿⠟⠁", " ⠈⠁"],
27
+ [" ⢀⣀⣀", " ⣠⣶⣿⣿⣿⣿⣿⣷⣦⣀", " ⣸⣿⣿⣥⣄⠈⣿⠿⣿⣿⣿⣦", " ⢐⣿⡿⢿⣿⠏⢠⡇⢰⣿⡿⠿⣿⡆", " ⠈⣿⣷⣤⣤⣴⣿ ⣤⠈⣀⣴⣿⠇", " ⠹⣿⣿⣿⣿⣿⣀⣠⣾⣿⣿⠏", " ⠈⠻⢿⣿⣿⢿⣿⡿⠞⠁", " ⠉⠉"],
28
+ [" ⣀⣀⣀⡀", " ⣀⣴⣿⣿⠿⢿⣿⣿⣦⡀", " ⣰⣿⡿⢿⣿⣶⠄⢹⣿⣿⣿⣆", " ⢀⣿⣿⣄⡉⠉⣁⡴⠋⣨⣿⣿⣿⡆", " ⠈⣿⣿⣿⣿⣿⠏⣀⠘⢿⠿⢿⣿⠇", " ⠹⣿⣿⣿⣅⣈⣉⣤⣤⣴⣾⠟", " ⠈⠻⢿⣿⣿⣟⣿⣿⠟⠉", " ⠉⠉"],
29
+ [" ⣀⣀⣀", " ⢀⣴⣾⣿⣿⣿⣿⣿⣶⣄", " ⣰⣿⣿⡋⢸⣿⣿⡆⠘⣿⣿⣦", " ⢀⣿⣿⣿⣷⣤⣄⣀⡤⠾⣿⣿⣿⡄", " ⠈⣿⣿⣿⠟⢋⣉ ⣤⣶⣿⣿⣿⠃", " ⠹⣿⣿⣶⣤⣉⡀⠙⠻⣿⣿⡏", " ⠈⠻⣿⣿⣿⣿⣿⡶⠟⠋", " ⠉⠉"],
30
+ [" ⢀⡀⡀", " ⢀⣴⣾⣿⡿⢿⣿⣷⣶⣄", " ⣰⣿⣿⣿⣿ ⣾⣿⣿⠻⣿⣧", " ⢠⣿⣿⣿⣿⣿⣦⣈⠙⠋⢀⣿⣿⡆", " ⠘⣾⣿⣇ ⣤ ⣉⡉⢻⣿⣿⣿⠃", " ⠹⣟⣿⣷⡄⠐⢿⣿⣿⣿⣿⠏", " ⠈⠻⢿⣿⣦⣼⣿⣿⠟⠃", " ⠈⠉⠉⠁"],
31
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡄", " ⣰⣿⣿⣿⣿⣿⠏⢠⣹⣿⣿⣦", " ⢠⣿⣿⡏⢈⠻⢿⡄⠸⣿⡟⢻⣿⡇", " ⠘⣯⣿⣧⠘⠃⣀⠙⢦⣈⣠⣾⣿⠇", " ⠻⣿⣿⡀⢺⣿⣷⣴⣿⣿⣿⠟", " ⠘⠻⣷⣾⣿⣿⣿⡿⠟⠁", " ⠈⠉⠉⠁"],
32
+ ];
33
+ // Upright and smaller (32, 20, 12 dots): the medallion "flies" into the header.
34
+ export const SHRINK = [
35
+ [" ⢀⡀", " ⢀⣴⣾⣿⣿⣿⣿⣷⣦⡀", " ⣴⣿⣿⡟⠻⣿⣿⡿⠟⢿⣿⣆", " ⢠⣿⣿⡟⢠⡆⢹⡿ ⣶⣶⣿⣿⡄", " ⠘⣿⣿⠁⣠⣤ ⢧ ⠿⠟⣿⣿⡇", " ⠹⣧⣴⣿⣿⣧⣼⣷⣤⣶⣿⡟", " ⠙⠿⢿⣿⣿⣿⣿⣿⠟⠋", " ⠉⠉"],
36
+ [" ⣀⣤⣤⣀", " ⢰⣿⡟⢿⣿⠿⢿⡄", " ⣿⡟⢘⡈⡇⣾⢿⣿", " ⠹⣧⣿⣧⣷⣦⣾⠏", " ⠈⠉⠛⠛⠋"],
37
+ [" ⣠⣴⣦⣄", "⠰⡟⡊⡳⢾⡆", " ⠙⠻⠟⠋"],
38
+ ];
39
+ // 8×8 dots: the small mark in the header.
40
+ export const ICON = ["⢠⡶⢶⡄", "⠘⠶⠾⠃"];
@@ -0,0 +1,121 @@
1
+ // The intro, after landing/preloader.js: the AC medallion spins, "AgentCollar" types itself
2
+ // and un-types, then the medallion shrinks into the header. About 1.7 s; any key skips it.
3
+ // The frames are plain text made once from the logo (see tools/gen-intro.ts).
4
+ import { styleText } from "node:util";
5
+ import { ICON, SHRINK, SPIN, SPIN_SMALL } from "./intro-frames.js";
6
+ const WORD = "AgentCollar";
7
+ const WELCOME = "Welcome. Let agents work. Keep the keys.";
8
+ const WELCOME_SHORT = "Let agents work. Keep the keys.";
9
+ // The big medallion needs 60×16, the small one 44×12; a smaller window gets no intro at all.
10
+ export function introSize(columns, rows) {
11
+ if (columns >= 60 && rows >= SPIN[0].length + 4)
12
+ return "big";
13
+ if (columns >= 44 && rows >= SPIN_SMALL[0].length + 4)
14
+ return "small";
15
+ return "none";
16
+ }
17
+ // The whole animation as data: easy to test (duration, sizes) and to play.
18
+ export function introTimeline(size = "big", width = 100) {
19
+ const spin = size === "big" ? SPIN : SPIN_SMALL;
20
+ const height = spin[0].length; // every frame has this many lines, so each one overwrites the last
21
+ const wordRow = Math.floor(height / 2) - 1;
22
+ const wordColumn = Math.max(...spin[0].map((line) => [...line].length)) + 3;
23
+ const frame = (medallion, word = "", delayMs = 0) => ({
24
+ lines: Array.from({ length: height }, (_, row) => {
25
+ const left = medallion[row] ?? "";
26
+ return row === wordRow && word !== "" ? left.padEnd(wordColumn) + word : left;
27
+ }),
28
+ delayMs,
29
+ });
30
+ const upright = spin[0];
31
+ const frames = [];
32
+ for (const turned of spin)
33
+ frames.push(frame(turned, "", 40)); // one full turn
34
+ frames.push(frame(upright, "", 40)); // settles upright
35
+ for (let i = 1; i <= WORD.length; i++)
36
+ frames.push(frame(upright, WORD.slice(0, i), 45)); // types
37
+ frames[frames.length - 1].delayMs = 250; // holds the full name
38
+ for (let i = WORD.length - 1; i >= 0; i--)
39
+ frames.push(frame(upright, WORD.slice(0, i), 22)); // un-types
40
+ const shrink = size === "big" ? SHRINK : SHRINK.slice(1); // the small intro starts at 32 dots already
41
+ for (const smaller of shrink)
42
+ frames.push(frame(smaller, "", 60)); // flies into the header
43
+ const welcome = width >= WELCOME.length + 6 ? WELCOME : WELCOME_SHORT;
44
+ frames.push(frame([`${ICON[0]} ${styleText("bold", WORD)}`, `${ICON[1]} ${welcome}`]));
45
+ return frames;
46
+ }
47
+ // Only for a person looking at a real terminal: never in pipes, logs, CI, or when asked not to.
48
+ export function shouldShowIntro(c) {
49
+ if (c.noIntro || !c.stdoutTTY)
50
+ return false;
51
+ if (c.env.CI !== undefined || c.env.TERM === "dumb")
52
+ return false;
53
+ if (c.env.NO_COLOR !== undefined && c.env.NO_COLOR !== "")
54
+ return false; // no-color.org rule
55
+ return introSize(c.columns, c.rows) !== "none";
56
+ }
57
+ // ANSI escape codes used below:
58
+ // ESC[?25l / ESC[?25h hide / show the cursor
59
+ // ESC 7 / ESC 8 save / restore the cursor position
60
+ // ESC[2K clear the current line
61
+ // ESC[J clear from the cursor to the end of the screen
62
+ // ESC[<n>A move the cursor n lines up
63
+ const ESC = "\u001b";
64
+ export async function playIntro(out = process.stdout, input = process.stdin) {
65
+ const size = introSize(out.columns ?? 100, out.rows ?? 40);
66
+ if (size === "none")
67
+ return;
68
+ const frames = introTimeline(size, out.columns ?? 100);
69
+ const HEIGHT = frames[0].lines.length;
70
+ let skipped = false;
71
+ let ctrlC = false;
72
+ let wake = () => { };
73
+ const showCursor = () => out.write(`${ESC}[?25h`);
74
+ process.once("exit", showCursor); // even if something goes wrong, never leave the cursor hidden
75
+ const onKey = (key) => {
76
+ skipped = true;
77
+ ctrlC = key[0] === 3; // Ctrl+C
78
+ wake();
79
+ };
80
+ if (input.isTTY) {
81
+ input.setRawMode(true);
82
+ input.resume();
83
+ input.on("data", onKey);
84
+ }
85
+ // make room for the frames, go back up, and remember where the block starts
86
+ out.write(`${ESC}[?25l${"\n".repeat(HEIGHT)}${ESC}[${HEIGHT}A${ESC}7`);
87
+ const draw = (lines) => out.write(`${ESC}8${lines.map((line) => `${ESC}[2K${line}`).join("\n")}`);
88
+ for (const f of frames.slice(0, -1)) {
89
+ if (skipped)
90
+ break;
91
+ draw(f.lines);
92
+ await new Promise((resolve) => {
93
+ wake = resolve;
94
+ setTimeout(resolve, f.delayMs);
95
+ });
96
+ }
97
+ // the header stays; everything below it is cleared
98
+ const header = frames[frames.length - 1].lines.filter((line) => line !== "");
99
+ out.write(`${ESC}8${ESC}[J${header.join("\n")}\n\n`);
100
+ showCursor();
101
+ process.off("exit", showCursor);
102
+ if (input.isTTY) {
103
+ input.off("data", onKey);
104
+ input.setRawMode(false);
105
+ input.pause();
106
+ }
107
+ if (ctrlC)
108
+ process.exit(130);
109
+ }
110
+ // Shows the intro only when it makes sense (see shouldShowIntro).
111
+ export async function maybeIntro(noIntro) {
112
+ const context = {
113
+ stdoutTTY: process.stdout.isTTY === true,
114
+ columns: process.stdout.columns ?? 0,
115
+ rows: process.stdout.rows ?? 0,
116
+ env: process.env,
117
+ noIntro,
118
+ };
119
+ if (shouldShowIntro(context))
120
+ await playIntro();
121
+ }
@@ -0,0 +1,46 @@
1
+ // agentcollar logs [--agent <name>] [--denied] [--today] — the audit log, readable and filtered.
2
+ import { readAuditLog } from "../audit-log.js";
3
+ import { auditLogFile } from "../audit.js";
4
+ import { formatEntry, isSameLocalDay } from "./format.js";
5
+ export function parseLogsArgs(args) {
6
+ const filter = { agent: null, denied: false, today: false };
7
+ for (let i = 0; i < args.length; i++) {
8
+ const arg = args[i];
9
+ if (arg === "--denied")
10
+ filter.denied = true;
11
+ else if (arg === "--today")
12
+ filter.today = true;
13
+ else if (arg === "--agent") {
14
+ const name = args[++i];
15
+ if (name === undefined || name.startsWith("--"))
16
+ throw new Error("--agent needs a name: --agent <name>");
17
+ filter.agent = name;
18
+ }
19
+ else
20
+ throw new Error(`Unknown flag: ${arg}. Available: --agent <name>, --denied, --today`);
21
+ }
22
+ return filter;
23
+ }
24
+ export function filterEntries(entries, filter, now = new Date()) {
25
+ return entries.filter((entry) => (filter.agent === null || entry.agent === filter.agent) &&
26
+ (!filter.denied || !entry.allowed) &&
27
+ (!filter.today || isSameLocalDay(new Date(entry.time), now)));
28
+ }
29
+ export async function runLogs(args) {
30
+ let filter;
31
+ try {
32
+ filter = parseLogsArgs(args);
33
+ }
34
+ catch (error) {
35
+ console.error(error.message);
36
+ return 1;
37
+ }
38
+ const entries = filterEntries(readAuditLog(auditLogFile), filter);
39
+ if (entries.length === 0) {
40
+ console.log("No entries.");
41
+ return 0;
42
+ }
43
+ for (const entry of entries)
44
+ console.log(formatEntry(entry, { showDate: !filter.today }));
45
+ return 0;
46
+ }
@@ -0,0 +1,104 @@
1
+ // agcl <command> [arguments] — one entry point for the broker. `agentcollar` is the same program:
2
+ // both names point to one file, so `agcl watch` == `agentcollar watch`.
3
+ // Each command lives in its own file and is loaded only when it is used.
4
+ import { styleText } from "node:util";
5
+ import { fit, termWidth, wrap } from "./format.js";
6
+ // Runs forever (until Ctrl+C): the server and the MCP server keep the process alive themselves.
7
+ const forever = () => new Promise(() => { });
8
+ const COMMANDS = {
9
+ setup: {
10
+ usage: "agcl setup",
11
+ summary: "connect your own Telegram bot (setup wizard)",
12
+ run: async () => (await import("./setup.js")).runSetup(),
13
+ },
14
+ gmail: {
15
+ usage: "agcl gmail connect|status|disconnect",
16
+ summary: "connect your real Gmail (read + create drafts; cannot send)",
17
+ run: async (args) => (await import("./gmail.js")).runGmail(args),
18
+ },
19
+ server: {
20
+ usage: "agcl server",
21
+ summary: "start the broker on 127.0.0.1",
22
+ run: async () => {
23
+ await import("../server.js");
24
+ return forever();
25
+ },
26
+ },
27
+ watch: {
28
+ usage: "agcl watch",
29
+ summary: "live screen: agent requests as they happen, with the 6 checks",
30
+ run: async (args, flags) => {
31
+ await (await import("./intro.js")).maybeIntro(flags.noIntro);
32
+ return (await import("./watch.js")).runWatch(args);
33
+ },
34
+ },
35
+ mandates: {
36
+ usage: "agcl mandates",
37
+ summary: "mandates of the running broker: state, time and actions left",
38
+ run: async (args) => (await import("./mandates.js")).runMandates(args),
39
+ },
40
+ logs: {
41
+ usage: "agcl logs [--agent <name>] [--denied] [--today]",
42
+ summary: "the audit log: who, what, when, allowed or not",
43
+ run: async (args) => (await import("./logs.js")).runLogs(args),
44
+ },
45
+ revoke: {
46
+ usage: "agcl revoke <id>",
47
+ summary: "revoke a mandate instantly (kill switch)",
48
+ run: async (args) => (await import("./revoke.js")).runRevoke(args),
49
+ },
50
+ mcp: {
51
+ usage: "agcl mcp",
52
+ summary: "MCP server for Claude Code and other agents (stdio)",
53
+ run: async () => {
54
+ await import("../mcp/server.js");
55
+ return forever();
56
+ },
57
+ },
58
+ };
59
+ export function parseCommand(argv) {
60
+ const flags = { noIntro: argv.includes("--no-intro") };
61
+ const [name, ...args] = argv.filter((word) => word !== "--no-intro");
62
+ if (name === undefined)
63
+ return { kind: "default", flags };
64
+ if (name === "help" || name === "--help" || name === "-h")
65
+ return { kind: "help" };
66
+ if (!Object.hasOwn(COMMANDS, name))
67
+ return { kind: "unknown", name };
68
+ return { kind: "run", name, args, flags };
69
+ }
70
+ function helpText() {
71
+ const screen = termWidth();
72
+ const width = Math.max(...Object.values(COMMANDS).map((c) => c.usage.length)) + 3;
73
+ // wide window: "usage summary" on one line; narrow: the summary goes under the usage
74
+ const row = (left, right) => screen >= width + 40
75
+ ? ` ${styleText("cyan", left.padEnd(width))}${fit(right, screen - width - 2)}`
76
+ : ` ${styleText("cyan", fit(left, screen - 2))}\n${wrap(right, screen - 6).map((line) => ` ${line}`).join("\n")}`;
77
+ return [
78
+ fit(`${styleText("bold", "AgentCollar")} — let agents work, keep the keys.`, screen + 20),
79
+ "",
80
+ row("agcl", "intro → setup wizard (first time only) → broker"),
81
+ ...Object.values(COMMANDS).map((c) => row(c.usage, c.summary)),
82
+ "",
83
+ row("--no-intro", "skip the intro"),
84
+ row("-h, --help", "this help"),
85
+ "",
86
+ styleText("dim", fit("agcl is the short name of agentcollar: agcl watch == agentcollar watch", screen)),
87
+ styleText("dim", fit("Your data: ~/.agentcollar/ (only you can open it)", screen)),
88
+ ].join("\n");
89
+ }
90
+ export async function runCli(argv) {
91
+ const parsed = parseCommand(argv);
92
+ switch (parsed.kind) {
93
+ case "default":
94
+ return (await import("./start.js")).runStart(parsed.flags);
95
+ case "help":
96
+ console.log(helpText());
97
+ return 0;
98
+ case "unknown":
99
+ console.error(`${styleText("red", `Unknown command: ${parsed.name}`)}\n\n${helpText()}`);
100
+ return 1;
101
+ case "run":
102
+ return COMMANDS[parsed.name].run(parsed.args, parsed.flags);
103
+ }
104
+ }
@@ -0,0 +1,101 @@
1
+ // agentcollar mandates — the mandates of the running broker: who, what, state, time and actions left.
2
+ import { styleText } from "node:util";
3
+ import { oneLine } from "../approval.js";
4
+ import { readMandatesSnapshot } from "../snapshot.js";
5
+ import { fit, termWidth } from "./format.js";
6
+ function duration(ms) {
7
+ const total = Math.ceil(ms / 1000);
8
+ const h = Math.floor(total / 3600);
9
+ const m = Math.floor((total % 3600) / 60);
10
+ const s = String(total % 60).padStart(2, "0");
11
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${s}` : `${m}:${s}`;
12
+ }
13
+ // The same order as check(): the first thing that ends a mandate decides its name.
14
+ export function mandateRow(m, now = Date.now()) {
15
+ let state = "active";
16
+ if (m.status === "pending")
17
+ state = "waiting";
18
+ else if (m.status === "denied")
19
+ state = "denied";
20
+ else if (now > m.expiresAt)
21
+ state = "expired";
22
+ else if (m.revoked)
23
+ state = "revoked";
24
+ else if (m.used >= m.limit)
25
+ state = "limit used up";
26
+ const live = state === "active";
27
+ return {
28
+ id: m.id,
29
+ agent: oneLine(m.agent, 24), // agent text: control characters removed before printing
30
+ actions: m.allowedActions.join(", "),
31
+ state,
32
+ timeLeft: live ? duration(m.expiresAt - now) : "—",
33
+ limit: `${m.used}/${m.limit}`,
34
+ live,
35
+ };
36
+ }
37
+ // Is the server process still running? Signal 0 checks without touching it.
38
+ function isRunning(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (error) {
44
+ return error.code === "EPERM"; // exists, but belongs to someone else
45
+ }
46
+ }
47
+ const COLUMNS = ["ID", "AGENT", "ACTIONS", "STATE", "TIME LEFT", "USED"];
48
+ function colorState(row, text) {
49
+ if (row.live)
50
+ return styleText("green", text);
51
+ if (row.state === "waiting")
52
+ return styleText("yellow", text);
53
+ return text;
54
+ }
55
+ // A table when it fits the window, otherwise one small "card" (two lines) per mandate.
56
+ export function formatMandates(mandates, now = Date.now(), width = termWidth()) {
57
+ const rows = [...mandates].sort((a, b) => b.createdAt - a.createdAt).map((m) => mandateRow(m, now));
58
+ const cells = (r) => [r.id, r.agent, r.actions, r.state, r.timeLeft, r.limit];
59
+ const widths = COLUMNS.map((title, i) => Math.max(title.length, ...rows.map((r) => cells(r)[i].length)));
60
+ const tableWidth = widths.reduce((sum, w) => sum + w, 0) + 2 * (widths.length - 1);
61
+ const dimIfEnded = (row, text) => (row.live || row.state === "waiting" ? text : styleText("dim", text));
62
+ if (tableWidth <= width) {
63
+ // pad first, color after: color codes are invisible but would break the padding
64
+ const lines = [styleText("dim", COLUMNS.map((title, i) => title.padEnd(widths[i])).join(" "))];
65
+ for (const row of rows) {
66
+ const line = cells(row).map((c, i) => c.padEnd(widths[i]));
67
+ line[3] = colorState(row, line[3]);
68
+ lines.push(dimIfEnded(row, line.join(" ")));
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+ return rows
73
+ .map((row) => {
74
+ const time = row.timeLeft === "—" ? "" : ` ${row.timeLeft} left`;
75
+ const head = fit(`${row.id} ${row.state}${time} ${row.limit} used`, width);
76
+ const colored = head.replace(row.state, colorState(row, row.state));
77
+ return dimIfEnded(row, `${colored}\n ${fit(`${row.agent} · ${row.actions}`, width - 2)}`);
78
+ })
79
+ .join("\n");
80
+ }
81
+ export async function runMandates(args) {
82
+ if (args.length > 0) {
83
+ console.error(`agcl mandates takes no arguments: ${args.join(" ")}`);
84
+ return 1;
85
+ }
86
+ const snapshot = readMandatesSnapshot();
87
+ if (snapshot === null) {
88
+ console.log("The broker has not been started yet. Run: agcl server");
89
+ return 0;
90
+ }
91
+ if (!isRunning(snapshot.pid)) {
92
+ console.log("The broker is not running. Mandates live in its memory, so they are gone once it stops.");
93
+ return 0;
94
+ }
95
+ if (snapshot.mandates.length === 0) {
96
+ console.log("No mandates yet.");
97
+ return 0;
98
+ }
99
+ console.log(formatMandates(snapshot.mandates));
100
+ return 0;
101
+ }
@@ -0,0 +1,8 @@
1
+ // agentcollar revoke <id> — the local kill switch from the terminal.
2
+ import "../env.js";
3
+ import { revokeMandate } from "../revoke.js";
4
+ export async function runRevoke(args) {
5
+ const result = await revokeMandate(args[0] ?? "", Number(process.env.BROKER_PORT ?? 8787));
6
+ console.log(result.message);
7
+ return result.ok ? 0 : 1;
8
+ }
@@ -0,0 +1,124 @@
1
+ // agcl setup (= agentcollar setup, npm run setup) — connects the broker to YOUR Telegram bot, without editing files by hand.
2
+ // 1) asks for the bot token and checks it with Telegram,
3
+ // 2) you press Start in the bot: we take your user id from that message,
4
+ // 3) writes ~/.agentcollar/.env readable only by you (600).
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { ensureHome, envFile, homeDir, legacyFiles, migrateLegacy } from "../paths.js";
8
+ import { buildEnv, getBotUsername, newStartCode, pollForStart, sendText, skipOldUpdates, writeEnvFile, } from "../setup.js";
9
+ const WAIT_MS = 5 * 60 * 1000;
10
+ async function ask(question) {
11
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ try {
13
+ // If the input ends before an answer (Ctrl+D, closed pipe), fail loudly instead of exiting silently.
14
+ const answer = await new Promise((resolve, reject) => {
15
+ rl.question(question).then(resolve, reject);
16
+ rl.once("close", () => reject(new Error("input ended, nothing was written")));
17
+ });
18
+ return answer.trim();
19
+ }
20
+ finally {
21
+ rl.close();
22
+ }
23
+ }
24
+ const yes = (answer) => answer.toLowerCase() === "y";
25
+ // Reads a line without showing it (for the token). Node has no ready-made "password input",
26
+ // so we switch the terminal to raw mode and read key by key, printing nothing.
27
+ function askHidden(question) {
28
+ const stdin = process.stdin;
29
+ if (!stdin.isTTY)
30
+ return ask(question); // input is piped, not typed: nothing to hide
31
+ return new Promise((resolve) => {
32
+ process.stdout.write(question);
33
+ stdin.setRawMode(true);
34
+ stdin.setEncoding("utf8");
35
+ stdin.resume();
36
+ let value = "";
37
+ const onData = (chunk) => {
38
+ for (const char of chunk) {
39
+ if (char === "\r" || char === "\n") {
40
+ stdin.setRawMode(false);
41
+ stdin.pause();
42
+ stdin.off("data", onData);
43
+ process.stdout.write("\n");
44
+ return resolve(value.trim());
45
+ }
46
+ if (char === "\u0003")
47
+ process.exit(130); // Ctrl+C
48
+ if (char === "\u007f")
49
+ value = value.slice(0, -1); // Backspace
50
+ else
51
+ value += char;
52
+ }
53
+ };
54
+ stdin.on("data", onData);
55
+ });
56
+ }
57
+ export async function runSetup() {
58
+ try {
59
+ return await setup();
60
+ }
61
+ catch (error) {
62
+ console.error(`\n✗ ${error.message}`);
63
+ return 1;
64
+ }
65
+ }
66
+ async function setup() {
67
+ console.log("AgentCollar setup\n");
68
+ // Older versions kept the data inside the code folder (broker/.env, broker/data/audit.log).
69
+ const oldFiles = [legacyFiles.env, legacyFiles.audit].filter((file) => existsSync(file));
70
+ if (oldFiles.length > 0) {
71
+ console.log(`Found data from an older version:\n${oldFiles.map((f) => ` ${f}`).join("\n")}`);
72
+ if (yes(await ask(`Move it to ${homeDir}/ (only you can open that folder)? [y/N] `))) {
73
+ const moved = migrateLegacy();
74
+ console.log(moved.length > 0 ? `✓ Moved: ${moved.join(", ")}\n` : "Files already exist there; the old ones stay where they are.\n");
75
+ }
76
+ }
77
+ if (existsSync(envFile) && !yes(await ask(`${envFile} already exists. Replace the Telegram settings? [y/N] `))) {
78
+ console.log("Nothing changed.");
79
+ return 0;
80
+ }
81
+ // 1. Token
82
+ let token = "";
83
+ let username = "";
84
+ while (username === "") {
85
+ token = await askHidden("Bot token (from @BotFather, hidden while you type): ");
86
+ try {
87
+ username = await getBotUsername(token);
88
+ }
89
+ catch (error) {
90
+ console.log(`✗ ${error.message}\n`);
91
+ }
92
+ }
93
+ console.log(`✓ Bot found: @${username}\n`);
94
+ // 2. Start with a one-time code
95
+ const code = newStartCode();
96
+ let offset = await skipOldUpdates(token);
97
+ console.log(`Open this link and press Start: https://t.me/${username}?start=${code}`);
98
+ console.log("Waiting (up to 5 minutes)...");
99
+ let user;
100
+ const deadline = Date.now() + WAIT_MS;
101
+ while (user === undefined && Date.now() < deadline) {
102
+ const round = await pollForStart(token, code, offset);
103
+ user = round.user;
104
+ offset = round.offset;
105
+ }
106
+ await skipOldUpdates(token); // tell Telegram we handled these messages
107
+ if (user === undefined) {
108
+ console.log("Timed out. Run agcl setup again.");
109
+ return 1;
110
+ }
111
+ const who = [user.firstName, user.username ? `@${user.username}` : "", `id ${user.id}`].filter(Boolean).join(", ");
112
+ if (!yes(await ask(`✓ Is this you? ${who} [y/N] `))) {
113
+ console.log("Nothing was written. Run agcl setup again.");
114
+ return 1;
115
+ }
116
+ // 3. ~/.agentcollar/.env, readable only by you
117
+ ensureHome();
118
+ const existing = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
119
+ writeEnvFile(envFile, buildEnv(existing, { TELEGRAM_BOT_TOKEN: token, TELEGRAM_USER_ID: String(user.id) }));
120
+ await sendText(token, user.id, "✅ AgentCollar is set up. Mandate requests will arrive here.").catch(() => { });
121
+ console.log(`✓ Saved to ${envFile} (mode 600: only your macOS user can read it)`);
122
+ console.log("Next: agcl server (or just agcl)");
123
+ return 0;
124
+ }
@@ -0,0 +1,29 @@
1
+ // `agcl` with no command: the intro, then the setup wizard if Telegram is not set up yet,
2
+ // then the broker itself. One word from install to a running broker.
3
+ import "../env.js";
4
+ import { existsSync } from "node:fs";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { envFile } from "../paths.js";
7
+ async function ask(question) {
8
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
9
+ const answer = await rl.question(question);
10
+ rl.close();
11
+ return answer.trim().toLowerCase();
12
+ }
13
+ export async function runStart(flags) {
14
+ await (await import("./intro.js")).maybeIntro(flags.noIntro);
15
+ // Ask only a person at a terminal; a script or pipe goes straight to the server.
16
+ const configured = (process.env.TELEGRAM_BOT_TOKEN ?? "") !== "";
17
+ if (!configured && process.stdin.isTTY) {
18
+ const answer = await ask("Telegram is not set up yet. Set it up now? [Y/n] (n = approve requests in this terminal) ");
19
+ if (answer === "" || answer === "y") {
20
+ const code = await (await import("./setup.js")).runSetup();
21
+ if (code !== 0)
22
+ return code;
23
+ if (existsSync(envFile))
24
+ process.loadEnvFile(envFile); // the server reads the new settings
25
+ }
26
+ }
27
+ await import("../server.js");
28
+ return new Promise(() => { }); // the server runs until Ctrl+C
29
+ }
@@ -0,0 +1,46 @@
1
+ // Like `tail -f`: each readNew() returns only the entries appended since the previous call.
2
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
3
+ import { parseAuditLine } from "../audit-log.js";
4
+ const NEWLINE = 0x0a; // the byte of "\n"
5
+ export function createTail(file) {
6
+ let offset = 0; // how many bytes we have already read
7
+ let rest = Buffer.alloc(0); // bytes after the last "\n": a line the server is still writing
8
+ return {
9
+ readNew() {
10
+ if (!existsSync(file))
11
+ return [];
12
+ const size = statSync(file).size;
13
+ if (size < offset) {
14
+ // the file got shorter: it was cut or replaced, so start over
15
+ offset = 0;
16
+ rest = Buffer.alloc(0);
17
+ }
18
+ if (size === offset)
19
+ return [];
20
+ const chunk = Buffer.alloc(size - offset);
21
+ const fd = openSync(file, "r");
22
+ try {
23
+ readSync(fd, chunk, 0, chunk.length, offset);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ offset = size;
29
+ // Work with BYTES up to the last "\n". Turning a half line into text could cut a
30
+ // Cyrillic letter (2 bytes) in the middle and break it; the rest waits for the next call.
31
+ const bytes = Buffer.concat([rest, chunk]);
32
+ const end = bytes.lastIndexOf(NEWLINE);
33
+ if (end === -1) {
34
+ rest = bytes;
35
+ return [];
36
+ }
37
+ rest = bytes.subarray(end + 1);
38
+ return bytes
39
+ .subarray(0, end)
40
+ .toString("utf8")
41
+ .split("\n")
42
+ .map(parseAuditLine)
43
+ .filter((entry) => entry !== null);
44
+ },
45
+ };
46
+ }