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.
Files changed (92) hide show
  1. package/README.md +227 -0
  2. package/docs/commands/alias.md +42 -0
  3. package/docs/commands/ban.md +26 -0
  4. package/docs/commands/close.md +25 -0
  5. package/docs/commands/connect.md +26 -0
  6. package/docs/commands/deop.md +24 -0
  7. package/docs/commands/devoice.md +24 -0
  8. package/docs/commands/disconnect.md +26 -0
  9. package/docs/commands/help.md +28 -0
  10. package/docs/commands/ignore.md +47 -0
  11. package/docs/commands/items.md +95 -0
  12. package/docs/commands/join.md +25 -0
  13. package/docs/commands/kb.md +26 -0
  14. package/docs/commands/kick.md +25 -0
  15. package/docs/commands/log.md +82 -0
  16. package/docs/commands/me.md +24 -0
  17. package/docs/commands/mode.md +29 -0
  18. package/docs/commands/msg.md +26 -0
  19. package/docs/commands/nick.md +24 -0
  20. package/docs/commands/notice.md +24 -0
  21. package/docs/commands/op.md +24 -0
  22. package/docs/commands/part.md +25 -0
  23. package/docs/commands/quit.md +24 -0
  24. package/docs/commands/reload.md +19 -0
  25. package/docs/commands/script.md +126 -0
  26. package/docs/commands/server.md +61 -0
  27. package/docs/commands/set.md +37 -0
  28. package/docs/commands/topic.md +24 -0
  29. package/docs/commands/unalias.md +22 -0
  30. package/docs/commands/unban.md +25 -0
  31. package/docs/commands/unignore.md +25 -0
  32. package/docs/commands/voice.md +25 -0
  33. package/docs/commands/whois.md +24 -0
  34. package/docs/commands/wii.md +23 -0
  35. package/package.json +38 -0
  36. package/src/app/App.tsx +205 -0
  37. package/src/core/commands/docs.ts +183 -0
  38. package/src/core/commands/execution.ts +114 -0
  39. package/src/core/commands/help-formatter.ts +185 -0
  40. package/src/core/commands/helpers.ts +168 -0
  41. package/src/core/commands/index.ts +7 -0
  42. package/src/core/commands/parser.ts +33 -0
  43. package/src/core/commands/registry.ts +1394 -0
  44. package/src/core/commands/types.ts +19 -0
  45. package/src/core/config/defaults.ts +66 -0
  46. package/src/core/config/loader.ts +209 -0
  47. package/src/core/constants.ts +20 -0
  48. package/src/core/init.ts +32 -0
  49. package/src/core/irc/antiflood.ts +244 -0
  50. package/src/core/irc/client.ts +145 -0
  51. package/src/core/irc/events.ts +1031 -0
  52. package/src/core/irc/formatting.ts +132 -0
  53. package/src/core/irc/ignore.ts +84 -0
  54. package/src/core/irc/index.ts +2 -0
  55. package/src/core/irc/netsplit.ts +292 -0
  56. package/src/core/scripts/api.ts +240 -0
  57. package/src/core/scripts/event-bus.ts +82 -0
  58. package/src/core/scripts/index.ts +26 -0
  59. package/src/core/scripts/manager.ts +154 -0
  60. package/src/core/scripts/types.ts +256 -0
  61. package/src/core/state/selectors.ts +39 -0
  62. package/src/core/state/sorting.ts +30 -0
  63. package/src/core/state/store.ts +242 -0
  64. package/src/core/storage/crypto.ts +78 -0
  65. package/src/core/storage/db.ts +107 -0
  66. package/src/core/storage/index.ts +80 -0
  67. package/src/core/storage/query.ts +204 -0
  68. package/src/core/storage/types.ts +37 -0
  69. package/src/core/storage/writer.ts +130 -0
  70. package/src/core/theme/index.ts +3 -0
  71. package/src/core/theme/loader.ts +45 -0
  72. package/src/core/theme/parser.ts +518 -0
  73. package/src/core/theme/renderer.tsx +25 -0
  74. package/src/index.tsx +17 -0
  75. package/src/types/config.ts +126 -0
  76. package/src/types/index.ts +107 -0
  77. package/src/types/irc-framework.d.ts +569 -0
  78. package/src/types/theme.ts +37 -0
  79. package/src/ui/ErrorBoundary.tsx +42 -0
  80. package/src/ui/chat/ChatView.tsx +39 -0
  81. package/src/ui/chat/MessageLine.tsx +92 -0
  82. package/src/ui/hooks/useStatusbarColors.ts +23 -0
  83. package/src/ui/input/CommandInput.tsx +273 -0
  84. package/src/ui/layout/AppLayout.tsx +126 -0
  85. package/src/ui/layout/TopicBar.tsx +46 -0
  86. package/src/ui/sidebar/BufferList.tsx +55 -0
  87. package/src/ui/sidebar/NickList.tsx +96 -0
  88. package/src/ui/splash/SplashScreen.tsx +100 -0
  89. package/src/ui/statusbar/StatusLine.tsx +205 -0
  90. package/themes/.gitkeep +0 -0
  91. package/themes/default.theme +57 -0
  92. 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
+ }
@@ -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
+ }