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,240 @@
1
+ import { useStore } from "@/core/state/store"
2
+ import { getClient } from "@/core/irc"
3
+ import { makeBufferId } from "@/types"
4
+ import { eventBus } from "./event-bus"
5
+ import { EventPriority } from "./types"
6
+ import type {
7
+ KokoAPI,
8
+ ScriptMeta,
9
+ ScriptCommandDef,
10
+ EventHandler,
11
+ TimerHandle,
12
+ StoreAccess,
13
+ IrcAccess,
14
+ UiAccess,
15
+ ScriptConfigAccess,
16
+ } from "./types"
17
+
18
+ /** Registry of script-defined commands. Checked in execution.ts between built-ins and aliases. */
19
+ export const scriptCommands = new Map<string, { def: ScriptCommandDef; owner: string }>()
20
+
21
+ export function createScriptAPI(meta: ScriptMeta, scriptDefaults: Record<string, any>): {
22
+ api: KokoAPI
23
+ cleanup: () => void
24
+ } {
25
+ const scriptName = meta.name
26
+ const unsubs: Array<() => void> = []
27
+ const timers: Array<TimerHandle> = []
28
+ const registeredCommands: string[] = []
29
+
30
+ // ─── Store Access ────────────────────────────────────────
31
+
32
+ const store: StoreAccess = {
33
+ getConnections: () => useStore.getState().connections,
34
+ getBuffers: () => useStore.getState().buffers,
35
+ getActiveBufferId: () => useStore.getState().activeBufferId,
36
+ getConfig: () => useStore.getState().config,
37
+ getConnection: (id) => useStore.getState().connections.get(id),
38
+ getBuffer: (id) => useStore.getState().buffers.get(id),
39
+ subscribe: (listener) => useStore.subscribe(listener),
40
+ }
41
+
42
+ // ─── IRC Access ──────────────────────────────────────────
43
+
44
+ function resolveConnId(explicit?: string): string | undefined {
45
+ if (explicit) return explicit
46
+ const s = useStore.getState()
47
+ const bufId = s.activeBufferId
48
+ if (!bufId) return undefined
49
+ const buf = s.buffers.get(bufId)
50
+ return buf?.connectionId
51
+ }
52
+
53
+ const irc: IrcAccess = {
54
+ say(target, message, connectionId) {
55
+ const connId = resolveConnId(connectionId)
56
+ if (!connId) return
57
+ getClient(connId)?.say(target, message)
58
+ },
59
+ action(target, message, connectionId) {
60
+ const connId = resolveConnId(connectionId)
61
+ if (!connId) return
62
+ getClient(connId)?.action(target, message)
63
+ },
64
+ notice(target, message, connectionId) {
65
+ const connId = resolveConnId(connectionId)
66
+ if (!connId) return
67
+ getClient(connId)?.notice(target, message)
68
+ },
69
+ join(channel, key, connectionId) {
70
+ const connId = resolveConnId(connectionId)
71
+ if (!connId) return
72
+ getClient(connId)?.join(channel, key)
73
+ },
74
+ part(channel, message, connectionId) {
75
+ const connId = resolveConnId(connectionId)
76
+ if (!connId) return
77
+ getClient(connId)?.part(channel, message)
78
+ },
79
+ raw(line, connectionId) {
80
+ const connId = resolveConnId(connectionId)
81
+ if (!connId) return
82
+ getClient(connId)?.raw(line)
83
+ },
84
+ changeNick(nick, connectionId) {
85
+ const connId = resolveConnId(connectionId)
86
+ if (!connId) return
87
+ getClient(connId)?.changeNick(nick)
88
+ },
89
+ whois(nick, connectionId) {
90
+ const connId = resolveConnId(connectionId)
91
+ if (!connId) return
92
+ getClient(connId)?.whois(nick)
93
+ },
94
+ getClient(connectionId) {
95
+ const connId = resolveConnId(connectionId)
96
+ if (!connId) return undefined
97
+ return getClient(connId)
98
+ },
99
+ }
100
+
101
+ // ─── UI Access ───────────────────────────────────────────
102
+
103
+ const ui: UiAccess = {
104
+ addLocalEvent(text) {
105
+ const s = useStore.getState()
106
+ const buf = s.activeBufferId
107
+ if (!buf) return
108
+ s.addMessage(buf, {
109
+ id: crypto.randomUUID(),
110
+ timestamp: new Date(),
111
+ type: "event",
112
+ text,
113
+ highlight: false,
114
+ })
115
+ },
116
+ addMessage(bufferId, partial) {
117
+ useStore.getState().addMessage(bufferId, {
118
+ id: crypto.randomUUID(),
119
+ timestamp: new Date(),
120
+ ...partial,
121
+ })
122
+ },
123
+ switchBuffer(bufferId) {
124
+ useStore.getState().setActiveBuffer(bufferId)
125
+ },
126
+ makeBufferId,
127
+ }
128
+
129
+ // ─── Config Access ───────────────────────────────────────
130
+
131
+ const config: ScriptConfigAccess = {
132
+ get<T = any>(key: string, defaultValue: T): T {
133
+ const appConfig = useStore.getState().config
134
+ const scriptConfig = (appConfig as any)?.scripts?.[scriptName]
135
+ if (scriptConfig && key in scriptConfig) return scriptConfig[key]
136
+ if (key in scriptDefaults) return scriptDefaults[key] as T
137
+ return defaultValue
138
+ },
139
+ set(key: string, value: any) {
140
+ const s = useStore.getState()
141
+ const appConfig = s.config
142
+ if (!appConfig) return
143
+ // Mutate scripts config in-place (same pattern as /set command)
144
+ if (!(appConfig as any).scripts) (appConfig as any).scripts = {}
145
+ if (!(appConfig as any).scripts[scriptName]) {
146
+ (appConfig as any).scripts[scriptName] = { ...scriptDefaults }
147
+ }
148
+ ;(appConfig as any).scripts[scriptName][key] = value
149
+ s.setConfig({ ...appConfig })
150
+ },
151
+ }
152
+
153
+ // ─── KokoAPI ─────────────────────────────────────────────
154
+
155
+ const api: KokoAPI = {
156
+ meta,
157
+
158
+ on(event, handler, priority = EventPriority.NORMAL) {
159
+ const unsub = eventBus.on(event, handler, priority, scriptName)
160
+ unsubs.push(unsub)
161
+ return unsub
162
+ },
163
+
164
+ once(event, handler, priority = EventPriority.NORMAL) {
165
+ const unsub = eventBus.once(event, handler, priority, scriptName)
166
+ unsubs.push(unsub)
167
+ return unsub
168
+ },
169
+
170
+ emit(event, data) {
171
+ return eventBus.emit(`script.${event}`, data)
172
+ },
173
+
174
+ command(name, def) {
175
+ const lower = name.toLowerCase()
176
+ scriptCommands.set(lower, { def, owner: scriptName })
177
+ registeredCommands.push(lower)
178
+ },
179
+
180
+ removeCommand(name) {
181
+ const lower = name.toLowerCase()
182
+ const entry = scriptCommands.get(lower)
183
+ if (entry?.owner === scriptName) {
184
+ scriptCommands.delete(lower)
185
+ }
186
+ },
187
+
188
+ timer(ms, handler) {
189
+ const id = setInterval(handler, ms)
190
+ const handle: TimerHandle = { clear: () => clearInterval(id) }
191
+ timers.push(handle)
192
+ return handle
193
+ },
194
+
195
+ timeout(ms, handler) {
196
+ const id = setTimeout(handler, ms)
197
+ const handle: TimerHandle = { clear: () => clearTimeout(id) }
198
+ timers.push(handle)
199
+ return handle
200
+ },
201
+
202
+ store,
203
+ irc,
204
+ ui,
205
+ config,
206
+ EventPriority,
207
+
208
+ log(...args) {
209
+ const appConfig = useStore.getState().config
210
+ const debug = (appConfig as any)?.scripts?.debug ?? false
211
+ if (debug) {
212
+ console.log(`[script:${scriptName}]`, ...args)
213
+ }
214
+ },
215
+ }
216
+
217
+ // ─── Cleanup ─────────────────────────────────────────────
218
+
219
+ function cleanup() {
220
+ // Remove all event handlers
221
+ eventBus.removeAll(scriptName)
222
+ for (const unsub of unsubs) unsub()
223
+ unsubs.length = 0
224
+
225
+ // Clear all timers
226
+ for (const t of timers) t.clear()
227
+ timers.length = 0
228
+
229
+ // Remove all commands
230
+ for (const name of registeredCommands) {
231
+ const entry = scriptCommands.get(name)
232
+ if (entry?.owner === scriptName) {
233
+ scriptCommands.delete(name)
234
+ }
235
+ }
236
+ registeredCommands.length = 0
237
+ }
238
+
239
+ return { api, cleanup }
240
+ }
@@ -0,0 +1,82 @@
1
+ import { EventPriority } from "./types"
2
+ import type { EventHandler, EventRegistration, EventContext } from "./types"
3
+
4
+ export class EventBus {
5
+ private handlers: EventRegistration[] = []
6
+
7
+ /** Subscribe to an event. Returns an unsubscribe function. */
8
+ on(event: string, handler: EventHandler, priority = EventPriority.NORMAL, owner = ""): () => void {
9
+ const reg: EventRegistration = { event, handler, priority, once: false, owner }
10
+ this.insert(reg)
11
+ return () => this.remove(reg)
12
+ }
13
+
14
+ /** Subscribe to an event for a single firing. */
15
+ once(event: string, handler: EventHandler, priority = EventPriority.NORMAL, owner = ""): () => void {
16
+ const reg: EventRegistration = { event, handler, priority, once: true, owner }
17
+ this.insert(reg)
18
+ return () => this.remove(reg)
19
+ }
20
+
21
+ /** Emit an event. Returns false if a handler called stop(). */
22
+ emit(event: string, data?: any): boolean {
23
+ const ctx: EventContext = {
24
+ stopped: false,
25
+ stop() { this.stopped = true },
26
+ }
27
+
28
+ // Snapshot matching handlers (allows safe removal during iteration)
29
+ const matching = this.handlers.filter((h) => h.event === event)
30
+
31
+ for (const reg of matching) {
32
+ reg.handler(data, ctx)
33
+
34
+ if (reg.once) {
35
+ this.remove(reg)
36
+ }
37
+
38
+ if (ctx.stopped) {
39
+ // Remove remaining once handlers that matched but didn't fire
40
+ for (const r of matching) {
41
+ if (r.once && r !== reg) this.remove(r)
42
+ }
43
+ return false
44
+ }
45
+ }
46
+
47
+ return true
48
+ }
49
+
50
+ /** Remove all registrations for a given owner. */
51
+ removeAll(owner: string): void {
52
+ this.handlers = this.handlers.filter((h) => h.owner !== owner)
53
+ }
54
+
55
+ /** Remove all handlers. */
56
+ clear(): void {
57
+ this.handlers = []
58
+ }
59
+
60
+ /** Number of registered handlers (for testing). */
61
+ get size(): number {
62
+ return this.handlers.length
63
+ }
64
+
65
+ /** Insert a registration in priority-sorted position (descending). */
66
+ private insert(reg: EventRegistration): void {
67
+ let i = 0
68
+ while (i < this.handlers.length && this.handlers[i].priority >= reg.priority) {
69
+ i++
70
+ }
71
+ this.handlers.splice(i, 0, reg)
72
+ }
73
+
74
+ /** Remove a specific registration by reference. */
75
+ private remove(reg: EventRegistration): void {
76
+ const idx = this.handlers.indexOf(reg)
77
+ if (idx !== -1) this.handlers.splice(idx, 1)
78
+ }
79
+ }
80
+
81
+ /** Singleton event bus shared across the app and all scripts. */
82
+ export const eventBus = new EventBus()
@@ -0,0 +1,26 @@
1
+ export { EventBus, eventBus } from "./event-bus"
2
+ export { createScriptAPI, scriptCommands } from "./api"
3
+ export {
4
+ loadScript,
5
+ unloadScript,
6
+ reloadScript,
7
+ getLoadedScripts,
8
+ getAvailableScripts,
9
+ autoloadScripts,
10
+ isLoaded,
11
+ } from "./manager"
12
+ export { EventPriority } from "./types"
13
+ export type {
14
+ KokoAPI,
15
+ ScriptMeta,
16
+ ScriptModule,
17
+ ScriptCommandDef,
18
+ EventHandler,
19
+ EventContext,
20
+ EventRegistration,
21
+ TimerHandle,
22
+ StoreAccess,
23
+ IrcAccess,
24
+ UiAccess,
25
+ ScriptConfigAccess,
26
+ } from "./types"
@@ -0,0 +1,154 @@
1
+ import { join } from "node:path"
2
+ import { useStore } from "@/core/state/store"
3
+ import { SCRIPTS_DIR } from "@/core/constants"
4
+ import { createScriptAPI } from "./api"
5
+ import type { ScriptMeta, ScriptModule } from "./types"
6
+
7
+ interface LoadedScript {
8
+ name: string
9
+ path: string
10
+ meta: ScriptMeta
11
+ cleanup: () => void
12
+ scriptCleanup?: () => void
13
+ }
14
+
15
+ const loaded = new Map<string, LoadedScript>()
16
+
17
+ /** Resolve a script name or path to an absolute file path. */
18
+ function resolvePath(nameOrPath: string): string {
19
+ if (nameOrPath.startsWith("/") || nameOrPath.startsWith("./") || nameOrPath.startsWith("~")) {
20
+ return nameOrPath
21
+ }
22
+ // Strip .ts extension if given, we'll add it back
23
+ const base = nameOrPath.replace(/\.ts$/, "")
24
+ return join(SCRIPTS_DIR, `${base}.ts`)
25
+ }
26
+
27
+ /** Load a script by name or path. */
28
+ export async function loadScript(nameOrPath: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
29
+ const path = resolvePath(nameOrPath)
30
+
31
+ // Check file exists
32
+ const file = Bun.file(path)
33
+ if (!(await file.exists())) {
34
+ return { ok: false, error: `Script not found: ${path}` }
35
+ }
36
+
37
+ let mod: ScriptModule
38
+ try {
39
+ mod = await import(`file://${path}?t=${Date.now()}`)
40
+ } catch (err: any) {
41
+ return { ok: false, error: `Failed to import: ${err.message}` }
42
+ }
43
+
44
+ if (typeof mod.default !== "function") {
45
+ return { ok: false, error: `Script has no default export function` }
46
+ }
47
+
48
+ const meta: ScriptMeta = mod.meta ?? {
49
+ name: nameOrPath.replace(/\.ts$/, "").split("/").pop() ?? nameOrPath,
50
+ }
51
+
52
+ // Unload existing version if already loaded
53
+ if (loaded.has(meta.name)) {
54
+ unloadScript(meta.name)
55
+ }
56
+
57
+ const scriptDefaults = mod.config ?? {}
58
+ const { api, cleanup } = createScriptAPI(meta, scriptDefaults)
59
+
60
+ let scriptCleanup: (() => void) | undefined
61
+ try {
62
+ const result = mod.default(api)
63
+ if (typeof result === "function") {
64
+ scriptCleanup = result
65
+ }
66
+ } catch (err: any) {
67
+ cleanup()
68
+ return { ok: false, error: `init() threw: ${err.message}` }
69
+ }
70
+
71
+ loaded.set(meta.name, { name: meta.name, path, meta, cleanup, scriptCleanup })
72
+ return { ok: true, name: meta.name }
73
+ }
74
+
75
+ /** Unload a script by name. */
76
+ export function unloadScript(name: string): boolean {
77
+ const script = loaded.get(name)
78
+ if (!script) return false
79
+
80
+ // Call script's own cleanup first
81
+ try {
82
+ script.scriptCleanup?.()
83
+ } catch {
84
+ // Ignore cleanup errors
85
+ }
86
+
87
+ // Then clean up all tracked registrations
88
+ script.cleanup()
89
+ loaded.delete(name)
90
+ return true
91
+ }
92
+
93
+ /** Reload a script (unload + load with cache bust). */
94
+ export async function reloadScript(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }> {
95
+ const script = loaded.get(name)
96
+ if (!script) {
97
+ return { ok: false, error: `Script '${name}' is not loaded` }
98
+ }
99
+
100
+ const path = script.path
101
+ unloadScript(name)
102
+
103
+ // Re-import with cache bust
104
+ return loadScript(path)
105
+ }
106
+
107
+ /** Get list of loaded scripts. */
108
+ export function getLoadedScripts(): Array<{ name: string; path: string; meta: ScriptMeta }> {
109
+ return Array.from(loaded.values()).map(({ name, path, meta }) => ({ name, path, meta }))
110
+ }
111
+
112
+ /** Get list of available script files in SCRIPTS_DIR. */
113
+ export async function getAvailableScripts(): Promise<Array<{ name: string; path: string; loaded: boolean }>> {
114
+ const glob = new Bun.Glob("*.ts")
115
+ const results: Array<{ name: string; path: string; loaded: boolean }> = []
116
+
117
+ try {
118
+ for await (const file of glob.scan({ cwd: SCRIPTS_DIR })) {
119
+ const name = file.replace(/\.ts$/, "")
120
+ results.push({
121
+ name,
122
+ path: join(SCRIPTS_DIR, file),
123
+ loaded: loaded.has(name),
124
+ })
125
+ }
126
+ } catch {
127
+ // Directory might not exist yet
128
+ }
129
+
130
+ return results.sort((a, b) => a.name.localeCompare(b.name))
131
+ }
132
+
133
+ /** Load all scripts listed in config.scripts.autoload. */
134
+ export async function autoloadScripts(): Promise<void> {
135
+ const config = useStore.getState().config
136
+ const autoload: string[] = (config as any)?.scripts?.autoload ?? []
137
+ const debug: boolean = (config as any)?.scripts?.debug ?? false
138
+
139
+ for (const name of autoload) {
140
+ const result = await loadScript(name)
141
+ if (debug) {
142
+ if (result.ok) {
143
+ console.log(`[scripts] autoloaded: ${result.name}`)
144
+ } else {
145
+ console.error(`[scripts] autoload failed for '${name}': ${result.error}`)
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ /** Check if a script is loaded. */
152
+ export function isLoaded(name: string): boolean {
153
+ return loaded.has(name)
154
+ }