kokoirc 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.
- package/README.md +227 -0
- package/docs/commands/alias.md +42 -0
- package/docs/commands/ban.md +26 -0
- package/docs/commands/close.md +25 -0
- package/docs/commands/connect.md +26 -0
- package/docs/commands/deop.md +24 -0
- package/docs/commands/devoice.md +24 -0
- package/docs/commands/disconnect.md +26 -0
- package/docs/commands/help.md +28 -0
- package/docs/commands/ignore.md +47 -0
- package/docs/commands/items.md +95 -0
- package/docs/commands/join.md +25 -0
- package/docs/commands/kb.md +26 -0
- package/docs/commands/kick.md +25 -0
- package/docs/commands/log.md +82 -0
- package/docs/commands/me.md +24 -0
- package/docs/commands/mode.md +29 -0
- package/docs/commands/msg.md +26 -0
- package/docs/commands/nick.md +24 -0
- package/docs/commands/notice.md +24 -0
- package/docs/commands/op.md +24 -0
- package/docs/commands/part.md +25 -0
- package/docs/commands/quit.md +24 -0
- package/docs/commands/reload.md +19 -0
- package/docs/commands/script.md +126 -0
- package/docs/commands/server.md +61 -0
- package/docs/commands/set.md +37 -0
- package/docs/commands/topic.md +24 -0
- package/docs/commands/unalias.md +22 -0
- package/docs/commands/unban.md +25 -0
- package/docs/commands/unignore.md +25 -0
- package/docs/commands/voice.md +25 -0
- package/docs/commands/whois.md +24 -0
- package/docs/commands/wii.md +23 -0
- package/package.json +38 -0
- package/src/app/App.tsx +205 -0
- package/src/core/commands/docs.ts +183 -0
- package/src/core/commands/execution.ts +114 -0
- package/src/core/commands/help-formatter.ts +185 -0
- package/src/core/commands/helpers.ts +168 -0
- package/src/core/commands/index.ts +7 -0
- package/src/core/commands/parser.ts +33 -0
- package/src/core/commands/registry.ts +1394 -0
- package/src/core/commands/types.ts +19 -0
- package/src/core/config/defaults.ts +66 -0
- package/src/core/config/loader.ts +209 -0
- package/src/core/constants.ts +20 -0
- package/src/core/init.ts +32 -0
- package/src/core/irc/antiflood.ts +244 -0
- package/src/core/irc/client.ts +145 -0
- package/src/core/irc/events.ts +1031 -0
- package/src/core/irc/formatting.ts +132 -0
- package/src/core/irc/ignore.ts +84 -0
- package/src/core/irc/index.ts +2 -0
- package/src/core/irc/netsplit.ts +292 -0
- package/src/core/scripts/api.ts +240 -0
- package/src/core/scripts/event-bus.ts +82 -0
- package/src/core/scripts/index.ts +26 -0
- package/src/core/scripts/manager.ts +154 -0
- package/src/core/scripts/types.ts +256 -0
- package/src/core/state/selectors.ts +39 -0
- package/src/core/state/sorting.ts +30 -0
- package/src/core/state/store.ts +242 -0
- package/src/core/storage/crypto.ts +78 -0
- package/src/core/storage/db.ts +107 -0
- package/src/core/storage/index.ts +80 -0
- package/src/core/storage/query.ts +204 -0
- package/src/core/storage/types.ts +37 -0
- package/src/core/storage/writer.ts +130 -0
- package/src/core/theme/index.ts +3 -0
- package/src/core/theme/loader.ts +45 -0
- package/src/core/theme/parser.ts +518 -0
- package/src/core/theme/renderer.tsx +25 -0
- package/src/index.tsx +17 -0
- package/src/types/config.ts +126 -0
- package/src/types/index.ts +107 -0
- package/src/types/irc-framework.d.ts +569 -0
- package/src/types/theme.ts +37 -0
- package/src/ui/ErrorBoundary.tsx +42 -0
- package/src/ui/chat/ChatView.tsx +39 -0
- package/src/ui/chat/MessageLine.tsx +92 -0
- package/src/ui/hooks/useStatusbarColors.ts +23 -0
- package/src/ui/input/CommandInput.tsx +273 -0
- package/src/ui/layout/AppLayout.tsx +126 -0
- package/src/ui/layout/TopicBar.tsx +46 -0
- package/src/ui/sidebar/BufferList.tsx +55 -0
- package/src/ui/sidebar/NickList.tsx +96 -0
- package/src/ui/splash/SplashScreen.tsx +100 -0
- package/src/ui/statusbar/StatusLine.tsx +205 -0
- package/themes/.gitkeep +0 -0
- package/themes/default.theme +57 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
category: Info
|
|
3
|
+
description: Whois with idle time
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /wii
|
|
7
|
+
|
|
8
|
+
## Syntax
|
|
9
|
+
|
|
10
|
+
/wii <nick>
|
|
11
|
+
|
|
12
|
+
## Description
|
|
13
|
+
|
|
14
|
+
Like `/whois` but also includes idle time and signon time. Sends
|
|
15
|
+
`WHOIS nick nick` to request the idle information from the user's server.
|
|
16
|
+
|
|
17
|
+
## Examples
|
|
18
|
+
|
|
19
|
+
/wii friend
|
|
20
|
+
|
|
21
|
+
## See Also
|
|
22
|
+
|
|
23
|
+
/whois, /nick
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kokoirc",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Terminal IRC client built with OpenTUI and React",
|
|
5
|
+
"module": "src/index.tsx",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"kokoirc": "src/index.tsx"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"themes",
|
|
13
|
+
"docs/commands",
|
|
14
|
+
"tsconfig.json"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"start": "bun run src/index.tsx",
|
|
18
|
+
"dev": "bun --watch run src/index.tsx",
|
|
19
|
+
"test": "bun test",
|
|
20
|
+
"build": "bun build --compile src/index.tsx --outfile openirc"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bun": "^1.3.9",
|
|
24
|
+
"@types/react": "^19.2.14",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"imports": {
|
|
28
|
+
"@/*": "./src/*"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@opentui/core": "^0.1.83",
|
|
32
|
+
"@opentui/react": "^0.1.83",
|
|
33
|
+
"kofany-irc-framework": "^4.14.1",
|
|
34
|
+
"react": "^19.2.4",
|
|
35
|
+
"smol-toml": "^1.6.0",
|
|
36
|
+
"zustand": "^5.0.11"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/app/App.tsx
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react"
|
|
2
|
+
import { useRenderer, useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useStore } from "@/core/state/store"
|
|
4
|
+
import { loadConfig } from "@/core/config/loader"
|
|
5
|
+
import { loadTheme } from "@/core/theme/loader"
|
|
6
|
+
import { connectAllAutoconnect } from "@/core/irc"
|
|
7
|
+
import { CONFIG_PATH, THEME_PATH } from "@/core/constants"
|
|
8
|
+
import { loadAllDocs } from "@/core/commands"
|
|
9
|
+
import { initHomeDir } from "@/core/init"
|
|
10
|
+
import { autoloadScripts } from "@/core/scripts/manager"
|
|
11
|
+
import { initStorage, shutdownStorage } from "@/core/storage"
|
|
12
|
+
import { BufferType, ActivityLevel, makeBufferId, getSortGroup } from "@/types"
|
|
13
|
+
import { SplashScreen } from "@/ui/splash/SplashScreen"
|
|
14
|
+
import { AppLayout } from "@/ui/layout/AppLayout"
|
|
15
|
+
import { TopicBar } from "@/ui/layout/TopicBar"
|
|
16
|
+
import { BufferList } from "@/ui/sidebar/BufferList"
|
|
17
|
+
import { NickList } from "@/ui/sidebar/NickList"
|
|
18
|
+
import { ChatView } from "@/ui/chat/ChatView"
|
|
19
|
+
import { CommandInput } from "@/ui/input/CommandInput"
|
|
20
|
+
import { StatusLine } from "@/ui/statusbar/StatusLine"
|
|
21
|
+
|
|
22
|
+
export function App() {
|
|
23
|
+
const renderer = useRenderer()
|
|
24
|
+
const setConfig = useStore((s) => s.setConfig)
|
|
25
|
+
const setTheme = useStore((s) => s.setTheme)
|
|
26
|
+
const config = useStore((s) => s.config)
|
|
27
|
+
const [showSplash, setShowSplash] = useState(true)
|
|
28
|
+
|
|
29
|
+
// Escape-prefix timestamp for irssi-style Esc+N window switching.
|
|
30
|
+
// OpenTUI's stdin buffer uses a 5ms timeout — too short for manual Esc then N.
|
|
31
|
+
// We track when Escape was pressed and check on the next keypress.
|
|
32
|
+
const escPressedAt = useRef(0)
|
|
33
|
+
|
|
34
|
+
useKeyboard((key) => {
|
|
35
|
+
if (key.name === "q" && key.ctrl) {
|
|
36
|
+
shutdownStorage().finally(() => renderer.destroy())
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Track standalone Escape keypresses for Esc+N prefix
|
|
41
|
+
if (key.name === "escape" && !key.ctrl && !key.shift) {
|
|
42
|
+
escPressedAt.current = Date.now()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if this keypress is an Esc+key combo:
|
|
47
|
+
// Either key.meta is set (terminal sent Alt+N natively, e.g. iTerm2 with Option-as-Meta)
|
|
48
|
+
// or Escape was pressed within the last 500ms (manual Esc then N)
|
|
49
|
+
const isEscCombo = key.meta || (Date.now() - escPressedAt.current < 500)
|
|
50
|
+
|
|
51
|
+
if (isEscCombo) {
|
|
52
|
+
// Reset the escape timestamp so it doesn't trigger again
|
|
53
|
+
escPressedAt.current = 0
|
|
54
|
+
|
|
55
|
+
const s = useStore.getState()
|
|
56
|
+
|
|
57
|
+
// Build sorted buffer list (same order as sidebar)
|
|
58
|
+
const getSortedIds = () => {
|
|
59
|
+
const bufs = Array.from(s.buffers.values())
|
|
60
|
+
.filter((b) => b.connectionId !== "_default")
|
|
61
|
+
.map((b) => ({
|
|
62
|
+
...b,
|
|
63
|
+
connectionLabel: s.connections.get(b.connectionId)?.label ?? b.connectionId,
|
|
64
|
+
}))
|
|
65
|
+
.sort((a, b) => {
|
|
66
|
+
const lc = a.connectionLabel.localeCompare(b.connectionLabel, undefined, { sensitivity: "base" })
|
|
67
|
+
if (lc !== 0) return lc
|
|
68
|
+
const ga = getSortGroup(a.type), gb = getSortGroup(b.type)
|
|
69
|
+
if (ga !== gb) return ga - gb
|
|
70
|
+
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
71
|
+
})
|
|
72
|
+
return bufs.map((b) => b.id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Esc+1..9 → buffer 1-9, Esc+0 → buffer 10
|
|
76
|
+
if (key.name >= "1" && key.name <= "9") {
|
|
77
|
+
const idx = parseInt(key.name, 10) - 1
|
|
78
|
+
const ids = getSortedIds()
|
|
79
|
+
if (idx < ids.length) {
|
|
80
|
+
s.setActiveBuffer(ids[idx])
|
|
81
|
+
key.preventDefault()
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (key.name === "0") {
|
|
86
|
+
const ids = getSortedIds()
|
|
87
|
+
if (ids.length >= 10) {
|
|
88
|
+
s.setActiveBuffer(ids[9])
|
|
89
|
+
key.preventDefault()
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Esc+Left/Right → prev/next buffer (wrap)
|
|
95
|
+
if (key.name === "left" || key.name === "right") {
|
|
96
|
+
const ids = getSortedIds()
|
|
97
|
+
if (ids.length === 0) return
|
|
98
|
+
const currentIdx = s.activeBufferId ? ids.indexOf(s.activeBufferId) : -1
|
|
99
|
+
let next: number
|
|
100
|
+
if (key.name === "left") {
|
|
101
|
+
next = currentIdx <= 0 ? ids.length - 1 : currentIdx - 1
|
|
102
|
+
} else {
|
|
103
|
+
next = currentIdx >= ids.length - 1 ? 0 : currentIdx + 1
|
|
104
|
+
}
|
|
105
|
+
s.setActiveBuffer(ids[next])
|
|
106
|
+
key.preventDefault()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Register shutdown handler so commands can close the app
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
useStore.getState().setShutdownHandler(() => {
|
|
115
|
+
shutdownStorage().finally(() => renderer.destroy())
|
|
116
|
+
})
|
|
117
|
+
}, [renderer])
|
|
118
|
+
|
|
119
|
+
// Load config + theme during splash (but don't connect yet)
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
async function init() {
|
|
122
|
+
await initHomeDir()
|
|
123
|
+
const config = await loadConfig(CONFIG_PATH)
|
|
124
|
+
setConfig(config)
|
|
125
|
+
|
|
126
|
+
// Initialize persistent log storage before any connections
|
|
127
|
+
await initStorage(config.logging)
|
|
128
|
+
|
|
129
|
+
const themePath = THEME_PATH(config.general.theme)
|
|
130
|
+
const theme = await loadTheme(themePath)
|
|
131
|
+
setTheme(theme)
|
|
132
|
+
|
|
133
|
+
await loadAllDocs()
|
|
134
|
+
}
|
|
135
|
+
init().catch((err) => console.error("[init]", err))
|
|
136
|
+
}, [])
|
|
137
|
+
|
|
138
|
+
// After splash finishes → connect, autoload scripts, switch to Status window
|
|
139
|
+
const handleSplashDone = useCallback(() => {
|
|
140
|
+
setShowSplash(false)
|
|
141
|
+
connectAllAutoconnect()
|
|
142
|
+
autoloadScripts().catch((err) => console.error("[scripts] autoload error:", err))
|
|
143
|
+
|
|
144
|
+
const s = useStore.getState()
|
|
145
|
+
|
|
146
|
+
// If no autoconnect created a buffer, create a default Status buffer
|
|
147
|
+
if (s.buffers.size === 0) {
|
|
148
|
+
const bufferId = makeBufferId("_default", "Status")
|
|
149
|
+
s.addBuffer({
|
|
150
|
+
id: bufferId,
|
|
151
|
+
connectionId: "_default",
|
|
152
|
+
type: BufferType.Server,
|
|
153
|
+
name: "Status",
|
|
154
|
+
messages: [{
|
|
155
|
+
id: crypto.randomUUID(),
|
|
156
|
+
timestamp: new Date(),
|
|
157
|
+
type: "event" as const,
|
|
158
|
+
text: "Welcome to kokoIRC. Type /connect to connect to a server.",
|
|
159
|
+
highlight: false,
|
|
160
|
+
}],
|
|
161
|
+
activity: ActivityLevel.None,
|
|
162
|
+
unreadCount: 0,
|
|
163
|
+
lastRead: new Date(),
|
|
164
|
+
users: new Map(),
|
|
165
|
+
listModes: new Map(),
|
|
166
|
+
})
|
|
167
|
+
s.setActiveBuffer(bufferId)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Activate first Status buffer so user sees connection progress
|
|
172
|
+
for (const buf of s.buffers.values()) {
|
|
173
|
+
if (buf.type === BufferType.Server) {
|
|
174
|
+
s.setActiveBuffer(buf.id)
|
|
175
|
+
break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}, [])
|
|
179
|
+
|
|
180
|
+
if (showSplash) {
|
|
181
|
+
return <SplashScreen onDone={handleSplashDone} />
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Show loading state until config is loaded
|
|
185
|
+
if (!config) {
|
|
186
|
+
return (
|
|
187
|
+
<box width="100%" height="100%" justifyContent="center" alignItems="center" backgroundColor="#1a1b26">
|
|
188
|
+
<text><span fg="#565f89">Connecting...</span></text>
|
|
189
|
+
</box>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const statusbarEnabled = config?.statusbar?.enabled ?? true
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<AppLayout
|
|
197
|
+
topicbar={<TopicBar />}
|
|
198
|
+
sidebar={<BufferList />}
|
|
199
|
+
chat={<ChatView />}
|
|
200
|
+
nicklist={<NickList />}
|
|
201
|
+
input={<CommandInput />}
|
|
202
|
+
statusline={statusbarEnabled ? <StatusLine /> : undefined}
|
|
203
|
+
/>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ─── Command Documentation Parser & Cache ──────────────────
|
|
2
|
+
// Parses docs/commands/*.md into structured help objects.
|
|
3
|
+
// Single source of truth for /help output and subcommand tab completion.
|
|
4
|
+
|
|
5
|
+
import { DOCS_DIR } from "@/core/constants"
|
|
6
|
+
|
|
7
|
+
export interface SubcommandHelp {
|
|
8
|
+
name: string
|
|
9
|
+
aliases: string[]
|
|
10
|
+
description: string // first paragraph of prose
|
|
11
|
+
syntax: string // indented code lines
|
|
12
|
+
body: string // full subsection text
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CommandHelp {
|
|
16
|
+
category: string
|
|
17
|
+
description: string // from frontmatter
|
|
18
|
+
syntax: string // from ## Syntax
|
|
19
|
+
body: string // from ## Description
|
|
20
|
+
subcommands: SubcommandHelp[]
|
|
21
|
+
examples: string[]
|
|
22
|
+
seeAlso: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const helpCache = new Map<string, CommandHelp>()
|
|
26
|
+
|
|
27
|
+
// ─── Parsing ────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function parseFrontmatter(raw: string): { meta: Record<string, string>; body: string } {
|
|
30
|
+
const meta: Record<string, string> = {}
|
|
31
|
+
if (!raw.startsWith("---")) return { meta, body: raw }
|
|
32
|
+
|
|
33
|
+
const end = raw.indexOf("---", 3)
|
|
34
|
+
if (end === -1) return { meta, body: raw }
|
|
35
|
+
|
|
36
|
+
const block = raw.slice(3, end).trim()
|
|
37
|
+
for (const line of block.split("\n")) {
|
|
38
|
+
const idx = line.indexOf(":")
|
|
39
|
+
if (idx > 0) {
|
|
40
|
+
meta[line.slice(0, idx).trim()] = line.slice(idx + 1).trim()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { meta, body: raw.slice(end + 3).trim() }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitSections(body: string): Map<string, string> {
|
|
47
|
+
const sections = new Map<string, string>()
|
|
48
|
+
const parts = body.split(/^## /m)
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (!part.trim()) continue
|
|
51
|
+
const nlIdx = part.indexOf("\n")
|
|
52
|
+
if (nlIdx === -1) continue
|
|
53
|
+
const heading = part.slice(0, nlIdx).trim()
|
|
54
|
+
const content = part.slice(nlIdx + 1).trim()
|
|
55
|
+
sections.set(heading.toLowerCase(), content)
|
|
56
|
+
}
|
|
57
|
+
return sections
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractIndented(text: string): string {
|
|
61
|
+
return text
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter((l) => l.startsWith(" "))
|
|
64
|
+
.map((l) => l.slice(4))
|
|
65
|
+
.join("\n")
|
|
66
|
+
.trim()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseSubcommands(text: string): SubcommandHelp[] {
|
|
70
|
+
const subs: SubcommandHelp[] = []
|
|
71
|
+
const parts = text.split(/^### /m)
|
|
72
|
+
|
|
73
|
+
for (const part of parts) {
|
|
74
|
+
if (!part.trim()) continue
|
|
75
|
+
const nlIdx = part.indexOf("\n")
|
|
76
|
+
if (nlIdx === -1) continue
|
|
77
|
+
|
|
78
|
+
const name = part.slice(0, nlIdx).trim().toLowerCase()
|
|
79
|
+
const body = part.slice(nlIdx + 1).trim()
|
|
80
|
+
|
|
81
|
+
const syntax = extractIndented(body)
|
|
82
|
+
|
|
83
|
+
// Extract aliases from "Aliases: del, rm" line
|
|
84
|
+
const aliases: string[] = []
|
|
85
|
+
const aliasMatch = body.match(/^Aliases?:\s*(.+)$/im)
|
|
86
|
+
if (aliasMatch) {
|
|
87
|
+
for (const a of aliasMatch[1].split(",")) {
|
|
88
|
+
const trimmed = a.trim().toLowerCase()
|
|
89
|
+
if (trimmed) aliases.push(trimmed)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// First paragraph of prose (skip indented lines and Aliases line)
|
|
94
|
+
const descLines: string[] = []
|
|
95
|
+
for (const line of body.split("\n")) {
|
|
96
|
+
if (line.startsWith(" ")) continue
|
|
97
|
+
if (/^Aliases?:/i.test(line)) continue
|
|
98
|
+
if (line.startsWith("**Flags:**")) break
|
|
99
|
+
const trimmed = line.trim()
|
|
100
|
+
if (trimmed) descLines.push(trimmed)
|
|
101
|
+
else if (descLines.length > 0) break
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
subs.push({
|
|
105
|
+
name,
|
|
106
|
+
aliases,
|
|
107
|
+
description: descLines.join(" "),
|
|
108
|
+
syntax,
|
|
109
|
+
body,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
return subs
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseDoc(raw: string): CommandHelp {
|
|
116
|
+
const { meta, body } = parseFrontmatter(raw)
|
|
117
|
+
const sections = splitSections(body)
|
|
118
|
+
|
|
119
|
+
const syntax = sections.has("syntax") ? extractIndented(sections.get("syntax")!) : ""
|
|
120
|
+
const description = meta.description ?? ""
|
|
121
|
+
const category = meta.category ?? "Other"
|
|
122
|
+
const bodyText = sections.get("description") ?? ""
|
|
123
|
+
|
|
124
|
+
const subcommands = sections.has("subcommands")
|
|
125
|
+
? parseSubcommands(sections.get("subcommands")!)
|
|
126
|
+
: []
|
|
127
|
+
|
|
128
|
+
const examples = sections.has("examples")
|
|
129
|
+
? extractIndented(sections.get("examples")!).split("\n").filter(Boolean)
|
|
130
|
+
: []
|
|
131
|
+
|
|
132
|
+
const seeAlso = sections.has("see also")
|
|
133
|
+
? sections.get("see also")!
|
|
134
|
+
.split(",")
|
|
135
|
+
.map((s) => s.trim().replace(/^\//, ""))
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
: []
|
|
138
|
+
|
|
139
|
+
return { category, description, syntax, body: bodyText, subcommands, examples, seeAlso }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Cache & Public API ─────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
export async function loadAllDocs(): Promise<void> {
|
|
145
|
+
helpCache.clear()
|
|
146
|
+
const glob = new Bun.Glob("*.md")
|
|
147
|
+
for await (const file of glob.scan(DOCS_DIR)) {
|
|
148
|
+
const name = file.replace(/\.md$/, "").toLowerCase()
|
|
149
|
+
try {
|
|
150
|
+
const raw = await Bun.file(`${DOCS_DIR}/${file}`).text()
|
|
151
|
+
helpCache.set(name, parseDoc(raw))
|
|
152
|
+
} catch {
|
|
153
|
+
// skip unreadable files
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getHelp(command: string): CommandHelp | null {
|
|
159
|
+
return helpCache.get(command.toLowerCase()) ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function getSubcommands(command: string): string[] {
|
|
163
|
+
const help = helpCache.get(command.toLowerCase())
|
|
164
|
+
if (!help) return []
|
|
165
|
+
const names: string[] = []
|
|
166
|
+
for (const sub of help.subcommands) {
|
|
167
|
+
names.push(sub.name)
|
|
168
|
+
for (const alias of sub.aliases) names.push(alias)
|
|
169
|
+
}
|
|
170
|
+
return names.sort()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function getCategories(): Map<string, string[]> {
|
|
174
|
+
const cats = new Map<string, string[]>()
|
|
175
|
+
for (const [name, help] of helpCache) {
|
|
176
|
+
const list = cats.get(help.category) ?? []
|
|
177
|
+
list.push(name)
|
|
178
|
+
cats.set(help.category, list)
|
|
179
|
+
}
|
|
180
|
+
// Sort commands within each category
|
|
181
|
+
for (const list of cats.values()) list.sort()
|
|
182
|
+
return cats
|
|
183
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useStore } from "@/core/state/store"
|
|
2
|
+
import { parseCommand } from "./parser"
|
|
3
|
+
import type { ParsedCommand } from "./parser"
|
|
4
|
+
import { commands, aliasMap, findByAlias } from "./registry"
|
|
5
|
+
import { addLocalEvent } from "./helpers"
|
|
6
|
+
import { eventBus } from "@/core/scripts/event-bus"
|
|
7
|
+
import { scriptCommands } from "@/core/scripts/api"
|
|
8
|
+
|
|
9
|
+
const MAX_ALIAS_DEPTH = 10
|
|
10
|
+
|
|
11
|
+
/** Expand a user alias template with positional args and context variables. */
|
|
12
|
+
function expandAlias(template: string, args: string[], connectionId: string): string {
|
|
13
|
+
let body = template
|
|
14
|
+
|
|
15
|
+
// Auto-append $* if body contains no $ references
|
|
16
|
+
if (!body.includes("$")) {
|
|
17
|
+
body += " $*"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Context variables
|
|
21
|
+
const s = useStore.getState()
|
|
22
|
+
const conn = s.connections.get(connectionId)
|
|
23
|
+
const buf = s.activeBufferId ? s.buffers.get(s.activeBufferId) : null
|
|
24
|
+
|
|
25
|
+
body = body.replace(/\$\{?C\}?/g, buf?.name ?? "")
|
|
26
|
+
body = body.replace(/\$\{?N\}?/g, conn?.nick ?? "")
|
|
27
|
+
body = body.replace(/\$\{?S\}?/g, conn?.label ?? "")
|
|
28
|
+
body = body.replace(/\$\{?T\}?/g, buf?.name ?? "")
|
|
29
|
+
|
|
30
|
+
// Range args: $0-, $1-, $2-, etc.
|
|
31
|
+
body = body.replace(/\$(\d)-/g, (_match, n) => {
|
|
32
|
+
const idx = parseInt(n, 10)
|
|
33
|
+
return args.slice(idx).join(" ")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// $* — all args
|
|
37
|
+
body = body.replace(/\$\*/g, args.join(" "))
|
|
38
|
+
|
|
39
|
+
// Single positional: $0 .. $9
|
|
40
|
+
body = body.replace(/\$(\d)/g, (_match, n) => {
|
|
41
|
+
const idx = parseInt(n, 10)
|
|
42
|
+
return args[idx] ?? ""
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
return body.trim()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function executeCommand(parsed: ParsedCommand, connectionId: string, depth = 0): boolean {
|
|
49
|
+
// Recursion guard
|
|
50
|
+
if (depth > MAX_ALIAS_DEPTH) {
|
|
51
|
+
addLocalEvent(`%Zf7768eAlias recursion limit reached (max ${MAX_ALIAS_DEPTH})%N`)
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Emit command_input event (scripts can intercept/block commands)
|
|
56
|
+
if (depth === 0) {
|
|
57
|
+
const proceed = eventBus.emit("command_input", {
|
|
58
|
+
command: parsed.command,
|
|
59
|
+
args: parsed.args,
|
|
60
|
+
connectionId,
|
|
61
|
+
})
|
|
62
|
+
if (!proceed) return true // script stopped propagation
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 1. Built-in command or built-in alias
|
|
66
|
+
const def = commands[parsed.command] ?? findByAlias(parsed.command)
|
|
67
|
+
if (def) {
|
|
68
|
+
def.handler(parsed.args, connectionId)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 2. Script command
|
|
73
|
+
const scriptCmd = scriptCommands.get(parsed.command)
|
|
74
|
+
if (scriptCmd) {
|
|
75
|
+
scriptCmd.def.handler(parsed.args, connectionId)
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. User alias
|
|
80
|
+
const config = useStore.getState().config
|
|
81
|
+
const aliasBody = config?.aliases[parsed.command]
|
|
82
|
+
if (aliasBody) {
|
|
83
|
+
const expanded = expandAlias(aliasBody, parsed.args, connectionId)
|
|
84
|
+
// Split by ; for command chaining
|
|
85
|
+
const parts = expanded.split(";").map((p) => p.trim()).filter(Boolean)
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
const sub = parseCommand(part)
|
|
88
|
+
if (sub) {
|
|
89
|
+
executeCommand(sub, connectionId, depth + 1)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 4. Unknown
|
|
96
|
+
addLocalEvent(`%Zf7768eUnknown command: /${parsed.command}. Type /help for available commands.%N`)
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** All registered command names + built-in aliases + script commands + user aliases, sorted. For tab completion. */
|
|
101
|
+
export function getCommandNames(): string[] {
|
|
102
|
+
const names = Object.keys(commands)
|
|
103
|
+
for (const alias of Object.keys(aliasMap)) {
|
|
104
|
+
names.push(alias)
|
|
105
|
+
}
|
|
106
|
+
for (const name of scriptCommands.keys()) {
|
|
107
|
+
names.push(name)
|
|
108
|
+
}
|
|
109
|
+
const userAliases = useStore.getState().config?.aliases ?? {}
|
|
110
|
+
for (const name of Object.keys(userAliases)) {
|
|
111
|
+
names.push(name)
|
|
112
|
+
}
|
|
113
|
+
return names.sort()
|
|
114
|
+
}
|