yaic 0.1.0 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/ralph-qa.md +101 -0
  3. data/.claude/ralph/bin/dump-pid.sh +3 -0
  4. data/.claude/ralph/bin/kill-claude +6 -0
  5. data/.claude/ralph/bin/start-ralph +44 -0
  6. data/.claude/ralph/bin/stop-hook.sh +9 -0
  7. data/.claude/ralph/bin/stop-ralph +17 -0
  8. data/.claude/ralph/prompt.md +218 -0
  9. data/.claude/settings.json +26 -0
  10. data/.gitmodules +3 -0
  11. data/CLAUDE.md +65 -0
  12. data/README.md +106 -17
  13. data/Rakefile +8 -0
  14. data/devenv.nix +1 -0
  15. data/docs/agents/data-model.md +150 -0
  16. data/docs/agents/ralph/features/01-message-parsing.md.done +160 -0
  17. data/docs/agents/ralph/features/01-tcpsocket-refactor.md.done +109 -0
  18. data/docs/agents/ralph/features/02-connection-socket.md.done +138 -0
  19. data/docs/agents/ralph/features/02-simplified-client-api.md.done +306 -0
  20. data/docs/agents/ralph/features/03-registration.md.done +147 -0
  21. data/docs/agents/ralph/features/04-ping-pong.md.done +109 -0
  22. data/docs/agents/ralph/features/05-event-system.md.done +167 -0
  23. data/docs/agents/ralph/features/06-privmsg-notice.md.done +163 -0
  24. data/docs/agents/ralph/features/07-join-part.md.done +190 -0
  25. data/docs/agents/ralph/features/08-quit.md.done +118 -0
  26. data/docs/agents/ralph/features/09-nick-change.md.done +109 -0
  27. data/docs/agents/ralph/features/10-topic.md.done +145 -0
  28. data/docs/agents/ralph/features/11-kick.md.done +122 -0
  29. data/docs/agents/ralph/features/12-names.md.done +124 -0
  30. data/docs/agents/ralph/features/13-mode.md.done +174 -0
  31. data/docs/agents/ralph/features/14-who-whois.md.done +188 -0
  32. data/docs/agents/ralph/features/15-client-api.md.done +180 -0
  33. data/docs/agents/ralph/features/16-ssl-test-infrastructure.md.done +50 -0
  34. data/docs/agents/ralph/features/17-github-actions-ci.md.done +70 -0
  35. data/docs/agents/ralph/features/18-brakeman-security-scanning.md.done +67 -0
  36. data/docs/agents/ralph/features/19-fix-qa.md.done +73 -0
  37. data/docs/agents/ralph/features/20-test-optimization.md.done +70 -0
  38. data/docs/agents/ralph/features/21-test-parallelization.md.done +56 -0
  39. data/docs/agents/ralph/features/22-wait-until-pattern.md.done +90 -0
  40. data/docs/agents/ralph/features/23-ping-test-optimization.md.done +46 -0
  41. data/docs/agents/ralph/features/24-blocking-who-whois.md.done +159 -0
  42. data/docs/agents/ralph/features/25-verbose-mode.md.done +166 -0
  43. data/docs/agents/ralph/plans/test-optimization-plan.md +172 -0
  44. data/docs/agents/ralph/progress.md +731 -0
  45. data/docs/agents/todo.md +5 -0
  46. data/lib/yaic/channel.rb +22 -0
  47. data/lib/yaic/client.rb +821 -0
  48. data/lib/yaic/event.rb +35 -0
  49. data/lib/yaic/message.rb +119 -0
  50. data/lib/yaic/registration.rb +17 -0
  51. data/lib/yaic/socket.rb +120 -0
  52. data/lib/yaic/source.rb +39 -0
  53. data/lib/yaic/version.rb +1 -1
  54. data/lib/yaic/who_result.rb +17 -0
  55. data/lib/yaic/whois_result.rb +20 -0
  56. data/lib/yaic.rb +13 -1
  57. metadata +51 -1
@@ -0,0 +1,109 @@
1
+ # NICK Change
2
+
3
+ ## Description
4
+
5
+ Implement changing nickname after registration and tracking nickname changes from other users.
6
+
7
+ ## Behavior
8
+
9
+ ### Changing Own Nick
10
+
11
+ ```ruby
12
+ client.nick("newnick")
13
+ ```
14
+
15
+ Format: `NICK <nickname>`
16
+
17
+ ### Server Response
18
+
19
+ On success: `:oldnick!user@host NICK newnick`
20
+
21
+ Update internal state to track new nickname.
22
+
23
+ ### Nick Change Errors
24
+
25
+ - 431 ERR_NONICKNAMEGIVEN - No nick provided
26
+ - 432 ERR_ERRONEUSNICKNAME - Invalid characters
27
+ - 433 ERR_NICKNAMEINUSE - Nick taken
28
+ - 436 ERR_NICKCOLLISION - Collision (rare)
29
+
30
+ ### Other Users Changing Nick
31
+
32
+ When another user changes nick: `:oldnick!user@host NICK newnick`
33
+
34
+ Update user tracking in all shared channels.
35
+
36
+ ### Events
37
+
38
+ Emit `:nick` event with:
39
+ - `old_nick` - Previous nickname
40
+ - `new_nick` - New nickname
41
+ - `user` - Source (optional, has old nick info)
42
+
43
+ ## Models
44
+
45
+ ```ruby
46
+ client.nick # => current nickname (String)
47
+ ```
48
+
49
+ ## Tests
50
+
51
+ ### Integration Tests
52
+
53
+ **Change own nick**
54
+ - Given: Connected as "oldnick"
55
+ - When: `client.nick("newnick")`
56
+ - Then: Receive NICK confirmation, client.nick = "newnick"
57
+
58
+ **Nick in use**
59
+ - Given: Connected, "taken" nick exists
60
+ - When: `client.nick("taken")`
61
+ - Then: Receive 433 ERR_NICKNAMEINUSE
62
+
63
+ **Invalid nick**
64
+ - Given: Connected
65
+ - When: `client.nick("#invalid")`
66
+ - Then: Receive 432 ERR_ERRONEUSNICKNAME
67
+
68
+ **Other user changes nick**
69
+ - Given: Client in #test with "bob", :nick handler
70
+ - When: Bob changes nick to "robert"
71
+ - Then: Handler called with old_nick="bob", new_nick="robert"
72
+
73
+ ### Unit Tests
74
+
75
+ **Format NICK**
76
+ - Given: nickname = "newnick"
77
+ - When: Build NICK
78
+ - Then: Output = "NICK newnick\r\n"
79
+
80
+ **Parse NICK event**
81
+ - Given: `:old!u@h NICK new`
82
+ - When: Parse
83
+ - Then: event.old_nick = "old", event.new_nick = "new"
84
+
85
+ **Track own nick change**
86
+ - Given: client.nick = "old"
87
+ - When: Receive `:old!u@h NICK new` from self
88
+ - Then: client.nick = "new"
89
+
90
+ ### Channel User Tracking
91
+
92
+ **Update user in channels**
93
+ - Given: Client tracks "bob" in #test
94
+ - When: Bob changes nick to "robert"
95
+ - Then: #test user list shows "robert" not "bob"
96
+
97
+ ## Implementation Notes
98
+
99
+ - Store current nick in client.nick
100
+ - On NICK event, check if source matches own nick to update self
101
+ - Update all channel user lists when any nick changes
102
+ - Nick comparison should be case-insensitive per server CASEMAPPING
103
+
104
+ ## Dependencies
105
+
106
+ - Requires `01-message-parsing.md`
107
+ - Requires `02-connection-socket.md`
108
+ - Requires `03-registration.md`
109
+ - Requires `05-event-system.md`
@@ -0,0 +1,145 @@
1
+ # Channel TOPIC
2
+
3
+ ## Description
4
+
5
+ Implement getting and setting channel topics.
6
+
7
+ ## Behavior
8
+
9
+ ### Getting Topic
10
+
11
+ ```ruby
12
+ client.topic("#ruby") # Request current topic
13
+ ```
14
+
15
+ Format: `TOPIC <channel>`
16
+
17
+ Server responds with:
18
+ - 332 RPL_TOPIC - The topic text
19
+ - 333 RPL_TOPICWHOTIME - Who set it and when
20
+ - 331 RPL_NOTOPIC - No topic set
21
+
22
+ ### Setting Topic
23
+
24
+ ```ruby
25
+ client.topic("#ruby", "Welcome to #ruby!")
26
+ client.topic("#ruby", "") # Clear topic
27
+ ```
28
+
29
+ Format: `TOPIC <channel> :<topic>`
30
+
31
+ ### Topic Change Notification
32
+
33
+ When topic changes: `:nick!user@host TOPIC #channel :New topic`
34
+
35
+ ### Topic Errors
36
+
37
+ - 442 ERR_NOTONCHANNEL - Not in channel (when getting)
38
+ - 482 ERR_CHANOPRIVSNEEDED - Not op (when setting protected topic)
39
+
40
+ ### Events
41
+
42
+ Emit `:topic` event with:
43
+ - `channel` - Channel name
44
+ - `topic` - Topic text (may be nil if cleared)
45
+ - `setter` - Who set it (nick)
46
+ - `time` - When it was set (Time, optional)
47
+
48
+ ## Models
49
+
50
+ ```ruby
51
+ channel.topic # => String or nil
52
+ channel.topic_setter # => String
53
+ channel.topic_time # => Time
54
+ ```
55
+
56
+ ## Tests
57
+
58
+ ### Integration Tests
59
+
60
+ **Get topic**
61
+ - Given: Client in #test which has topic
62
+ - When: `client.topic("#test")`
63
+ - Then: Receive RPL_TOPIC with topic text
64
+
65
+ **Get topic when none set**
66
+ - Given: Client in #test with no topic
67
+ - When: `client.topic("#test")`
68
+ - Then: Receive RPL_NOTOPIC
69
+
70
+ **Set topic**
71
+ - Given: Client is op in #test
72
+ - When: `client.topic("#test", "New topic")`
73
+ - Then: Receive TOPIC confirmation, topic changed
74
+
75
+ **Clear topic**
76
+ - Given: Client is op in #test with topic
77
+ - When: `client.topic("#test", "")`
78
+ - Then: Topic cleared
79
+
80
+ **Set topic without permission**
81
+ - Given: Client in #test (not op), channel has +t mode
82
+ - When: `client.topic("#test", "New topic")`
83
+ - Then: Receive 482 ERR_CHANOPRIVSNEEDED
84
+
85
+ **Receive topic change**
86
+ - Given: Client in #test with :topic handler
87
+ - When: Op changes topic
88
+ - Then: Handler called with channel, new topic, setter
89
+
90
+ ### Unit Tests
91
+
92
+ **Format TOPIC get**
93
+ - Given: channel = "#test"
94
+ - When: Build TOPIC (no text)
95
+ - Then: Output = "TOPIC #test\r\n"
96
+
97
+ **Format TOPIC set**
98
+ - Given: channel = "#test", topic = "Hello"
99
+ - When: Build TOPIC
100
+ - Then: Output = "TOPIC #test :Hello\r\n"
101
+
102
+ **Format TOPIC clear**
103
+ - Given: channel = "#test", topic = ""
104
+ - When: Build TOPIC
105
+ - Then: Output = "TOPIC #test :\r\n"
106
+
107
+ **Parse TOPIC event**
108
+ - Given: `:nick!u@h TOPIC #test :New topic`
109
+ - When: Parse
110
+ - Then: event.channel = "#test", event.topic = "New topic", event.setter = "nick"
111
+
112
+ **Parse RPL_TOPIC**
113
+ - Given: `:server 332 mynick #test :The topic`
114
+ - When: Parse
115
+ - Then: channel = "#test", topic = "The topic"
116
+
117
+ **Parse RPL_TOPICWHOTIME**
118
+ - Given: `:server 333 mynick #test setter 1234567890`
119
+ - When: Parse
120
+ - Then: setter = "setter", time = Time.at(1234567890)
121
+
122
+ ### State Tracking
123
+
124
+ **Update channel topic on change**
125
+ - Given: Client tracking #test
126
+ - When: TOPIC message received
127
+ - Then: channel.topic updated
128
+
129
+ **Topic from JOIN**
130
+ - Given: Client joins #test with topic
131
+ - When: JOIN completes
132
+ - Then: channel.topic populated from RPL_TOPIC
133
+
134
+ ## Implementation Notes
135
+
136
+ - Topic can be received at JOIN time (RPL_TOPIC) or via TOPIC command/event
137
+ - RPL_TOPICWHOTIME time is Unix timestamp
138
+ - Some channels have +t (topic protected) - only ops can change
139
+ - Empty string topic clears it
140
+
141
+ ## Dependencies
142
+
143
+ - Requires `01-message-parsing.md`
144
+ - Requires `05-event-system.md`
145
+ - Requires `07-join-part.md` (topic received at join)
@@ -0,0 +1,122 @@
1
+ # Channel KICK
2
+
3
+ ## Description
4
+
5
+ Implement kicking users from channels and handling being kicked.
6
+
7
+ ## Behavior
8
+
9
+ ### Sending KICK
10
+
11
+ ```ruby
12
+ client.kick("#ruby", "baduser")
13
+ client.kick("#ruby", "baduser", "No spamming")
14
+ ```
15
+
16
+ Format: `KICK <channel> <nick> [:<reason>]`
17
+
18
+ Requires channel operator privileges.
19
+
20
+ ### Server Response
21
+
22
+ Server confirms: `:kicker!user@host KICK #channel kicked :reason`
23
+
24
+ ### Being Kicked
25
+
26
+ When client is kicked:
27
+ 1. Receive KICK message
28
+ 2. Remove channel from tracking
29
+ 3. Emit :kick event
30
+
31
+ ### Kick Errors
32
+
33
+ - 441 ERR_USERNOTINCHANNEL - Target not in channel
34
+ - 442 ERR_NOTONCHANNEL - Kicker not in channel
35
+ - 482 ERR_CHANOPRIVSNEEDED - Kicker not op
36
+
37
+ ### Events
38
+
39
+ Emit `:kick` event with:
40
+ - `channel` - Channel name
41
+ - `user` - Who was kicked (nick)
42
+ - `by` - Who kicked (Source)
43
+ - `reason` - Kick reason
44
+
45
+ ## Models
46
+
47
+ No new models.
48
+
49
+ ## Tests
50
+
51
+ ### Integration Tests
52
+
53
+ **Kick user**
54
+ - Given: Client is op in #test, "target" is in #test
55
+ - When: `client.kick("#test", "target")`
56
+ - Then: Target removed from channel
57
+
58
+ **Kick with reason**
59
+ - Given: Client is op in #test
60
+ - When: `client.kick("#test", "target", "Breaking rules")`
61
+ - Then: Reason included in KICK
62
+
63
+ **Kick without permission**
64
+ - Given: Client in #test (not op)
65
+ - When: `client.kick("#test", "target")`
66
+ - Then: Receive 482 ERR_CHANOPRIVSNEEDED
67
+
68
+ **Kick non-existent user**
69
+ - Given: Client is op in #test
70
+ - When: `client.kick("#test", "nobody")`
71
+ - Then: Receive 441 ERR_USERNOTINCHANNEL
72
+
73
+ **Receive kick (others)**
74
+ - Given: Client in #test with :kick handler
75
+ - When: Op kicks "baduser"
76
+ - Then: Handler called with channel, user="baduser", by=op
77
+
78
+ **Receive kick (self)**
79
+ - Given: Client in #test
80
+ - When: Op kicks client
81
+ - Then: :kick event emitted, #test removed from channels
82
+
83
+ ### Unit Tests
84
+
85
+ **Format KICK**
86
+ - Given: channel = "#test", nick = "target"
87
+ - When: Build KICK
88
+ - Then: Output = "KICK #test target\r\n"
89
+
90
+ **Format KICK with reason**
91
+ - Given: channel = "#test", nick = "target", reason = "Bye"
92
+ - When: Build KICK
93
+ - Then: Output = "KICK #test target :Bye\r\n"
94
+
95
+ **Parse KICK event**
96
+ - Given: `:op!u@h KICK #test target :reason`
97
+ - When: Parse
98
+ - Then: event.channel = "#test", event.user = "target", event.by.nick = "op", event.reason = "reason"
99
+
100
+ ### State Updates
101
+
102
+ **Remove kicked user from channel**
103
+ - Given: Client tracking "target" in #test
104
+ - When: KICK for "target"
105
+ - Then: "target" removed from #test user list
106
+
107
+ **Remove channel when self kicked**
108
+ - Given: Client in #test
109
+ - When: Kicked from #test
110
+ - Then: #test removed from client.channels
111
+
112
+ ## Implementation Notes
113
+
114
+ - Kick requires op status (@) in channel
115
+ - Server may have kick reason length limits
116
+ - After being kicked, must rejoin if allowed
117
+
118
+ ## Dependencies
119
+
120
+ - Requires `01-message-parsing.md`
121
+ - Requires `05-event-system.md`
122
+ - Requires `07-join-part.md` (channel tracking)
@@ -0,0 +1,124 @@
1
+ # Channel NAMES
2
+
3
+ ## Description
4
+
5
+ Implement querying channel user lists. NAMES returns all users in a channel with their mode prefixes.
6
+
7
+ ## Behavior
8
+
9
+ ### Requesting NAMES
10
+
11
+ ```ruby
12
+ client.names("#ruby")
13
+ ```
14
+
15
+ Format: `NAMES <channel>`
16
+
17
+ ### Server Response
18
+
19
+ - 353 RPL_NAMREPLY - User list (may be multiple)
20
+ - 366 RPL_ENDOFNAMES - End of list
21
+
22
+ RPL_NAMREPLY format: `:server 353 mynick = #channel :@op +voice regular`
23
+
24
+ User prefixes:
25
+ - `@` - Operator
26
+ - `+` - Voice
27
+ - `%` - Half-op (some servers)
28
+ - `~` - Owner (some servers)
29
+ - `&` - Admin (some servers)
30
+
31
+ ### Events
32
+
33
+ Emit `:names` event with:
34
+ - `channel` - Channel name
35
+ - `users` - Array of {nick:, modes:} or similar
36
+
37
+ Or update channel.users and emit general update event.
38
+
39
+ ## Models
40
+
41
+ ```ruby
42
+ channel.users # => Hash[String, Set[Symbol]]
43
+ # e.g., {"dan" => Set[:op], "bob" => Set[:voice]}
44
+ ```
45
+
46
+ ## Tests
47
+
48
+ ### Integration Tests
49
+
50
+ **Get names**
51
+ - Given: Client in #test with users
52
+ - When: `client.names("#test")`
53
+ - Then: Receive RPL_NAMREPLY with user list
54
+
55
+ **Names with prefixes**
56
+ - Given: #test has @op and +voice users
57
+ - When: `client.names("#test")`
58
+ - Then: Response shows prefixes
59
+
60
+ **Names at join**
61
+ - Given: Client joins #test
62
+ - When: JOIN completes
63
+ - Then: channel.users populated from NAMREPLY
64
+
65
+ **Multi-message names**
66
+ - Given: Channel with many users
67
+ - When: Request NAMES
68
+ - Then: Multiple RPL_NAMREPLY collected until RPL_ENDOFNAMES
69
+
70
+ ### Unit Tests
71
+
72
+ **Parse RPL_NAMREPLY**
73
+ - Given: `:server 353 me = #test :@op +voice regular`
74
+ - When: Parse
75
+ - Then: Extract users with modes: op=>[:op], voice=>[:voice], regular=>[]
76
+
77
+ **Parse prefix @ (op)**
78
+ - Given: User string "@dan"
79
+ - When: Parse user
80
+ - Then: nick = "dan", modes = [:op]
81
+
82
+ **Parse prefix + (voice)**
83
+ - Given: User string "+bob"
84
+ - When: Parse user
85
+ - Then: nick = "bob", modes = [:voice]
86
+
87
+ **Parse multiple prefixes**
88
+ - Given: User string "@+admin"
89
+ - When: Parse user
90
+ - Then: nick = "admin", modes = [:op, :voice]
91
+
92
+ **Parse no prefix**
93
+ - Given: User string "regular"
94
+ - When: Parse user
95
+ - Then: nick = "regular", modes = []
96
+
97
+ **Collect until ENDOFNAMES**
98
+ - Given: Multiple RPL_NAMREPLY then RPL_ENDOFNAMES
99
+ - When: Process all
100
+ - Then: All users aggregated before emitting event
101
+
102
+ ### State Updates
103
+
104
+ **Populate users on join**
105
+ - Given: Client joins #test
106
+ - When: NAMREPLY received
107
+ - Then: channel.users contains all listed users
108
+
109
+ **Update user modes**
110
+ - Given: Client tracking #test
111
+ - When: New NAMES requested and received
112
+ - Then: channel.users updated
113
+
114
+ ## Implementation Notes
115
+
116
+ - NAMREPLY at JOIN is same as explicit NAMES command
117
+ - May receive multiple NAMREPLY messages - collect all before processing
118
+ - PREFIX in ISUPPORT defines which prefixes map to which modes
119
+ - Default: @ = op, + = voice
120
+
121
+ ## Dependencies
122
+
123
+ - Requires `01-message-parsing.md`
124
+ - Requires `07-join-part.md` (NAMES comes with JOIN)
@@ -0,0 +1,174 @@
1
+ # MODE Command
2
+
3
+ ## Description
4
+
5
+ Implement user and channel mode queries and changes. This covers both viewing and setting modes.
6
+
7
+ ## Behavior
8
+
9
+ ### User Modes
10
+
11
+ Query own modes:
12
+ ```ruby
13
+ client.mode(client.nick) # Get current modes
14
+ ```
15
+
16
+ Set own modes:
17
+ ```ruby
18
+ client.mode(client.nick, "+i") # Set invisible
19
+ client.mode(client.nick, "-i") # Unset invisible
20
+ ```
21
+
22
+ Common user modes:
23
+ - `+i` - Invisible (hide from WHO unless sharing channel)
24
+ - `+w` - Receive wallops
25
+ - `+o` - Operator (set via OPER, not MODE)
26
+
27
+ ### Channel Modes
28
+
29
+ Query channel modes:
30
+ ```ruby
31
+ client.mode("#ruby") # Get channel modes
32
+ ```
33
+
34
+ Set channel modes (requires op):
35
+ ```ruby
36
+ client.mode("#ruby", "+m") # Set moderated
37
+ client.mode("#ruby", "+o", "nick") # Give op to nick
38
+ client.mode("#ruby", "+k", "secret") # Set channel key
39
+ client.mode("#ruby", "-k") # Remove key
40
+ ```
41
+
42
+ Common channel modes:
43
+ - `+o nick` - Channel operator
44
+ - `+v nick` - Voice
45
+ - `+m` - Moderated (only +v/+o can speak)
46
+ - `+i` - Invite only
47
+ - `+k key` - Require key to join
48
+ - `+l limit` - User limit
49
+ - `+b mask` - Ban mask
50
+ - `+t` - Only ops can change topic
51
+
52
+ ### Server Response
53
+
54
+ Mode query: `324 RPL_CHANNELMODEIS :server 324 mynick #chan +nt`
55
+
56
+ Mode change: `:nick!u@h MODE #chan +o target`
57
+
58
+ ### Mode Errors
59
+
60
+ - 472 ERR_UNKNOWNMODE - Unknown mode character
61
+ - 482 ERR_CHANOPRIVSNEEDED - Need op to change channel mode
62
+ - 501 ERR_UMODEUNKNOWNFLAG - Unknown user mode
63
+
64
+ ### Events
65
+
66
+ Emit `:mode` event with:
67
+ - `target` - Channel or nick
68
+ - `modes` - Mode string (e.g., "+o-v")
69
+ - `params` - Mode parameters array
70
+
71
+ ## Models
72
+
73
+ ```ruby
74
+ channel.modes # => Hash[Symbol, Object]
75
+ # e.g., {moderated: true, key: "secret", limit: 50}
76
+ ```
77
+
78
+ ## Tests
79
+
80
+ ### Integration Tests - User Modes
81
+
82
+ **Get own modes**
83
+ - Given: Connected client
84
+ - When: `client.mode(client.nick)`
85
+ - Then: Receive 221 RPL_UMODEIS
86
+
87
+ **Set invisible**
88
+ - Given: Connected client
89
+ - When: `client.mode(client.nick, "+i")`
90
+ - Then: Mode confirmed, hidden from WHO
91
+
92
+ **Cannot set other user's modes**
93
+ - Given: Connected client
94
+ - When: `client.mode("other", "+i")`
95
+ - Then: Receive 502 ERR_USERSDONTMATCH
96
+
97
+ ### Integration Tests - Channel Modes
98
+
99
+ **Get channel modes**
100
+ - Given: Client in #test
101
+ - When: `client.mode("#test")`
102
+ - Then: Receive 324 RPL_CHANNELMODEIS
103
+
104
+ **Set channel mode as op**
105
+ - Given: Client is op in #test
106
+ - When: `client.mode("#test", "+m")`
107
+ - Then: Mode confirmed
108
+
109
+ **Give op to user**
110
+ - Given: Client is op in #test, "target" in channel
111
+ - When: `client.mode("#test", "+o", "target")`
112
+ - Then: Target becomes op
113
+
114
+ **Set key**
115
+ - Given: Client is op in #test
116
+ - When: `client.mode("#test", "+k", "secret")`
117
+ - Then: Channel now requires key
118
+
119
+ **Mode without permission**
120
+ - Given: Client in #test (not op)
121
+ - When: `client.mode("#test", "+m")`
122
+ - Then: Receive 482 ERR_CHANOPRIVSNEEDED
123
+
124
+ ### Unit Tests
125
+
126
+ **Format MODE query**
127
+ - Given: target = "#test"
128
+ - When: Build MODE (no modes)
129
+ - Then: Output = "MODE #test\r\n"
130
+
131
+ **Format MODE set**
132
+ - Given: target = "#test", modes = "+m"
133
+ - When: Build MODE
134
+ - Then: Output = "MODE #test +m\r\n"
135
+
136
+ **Format MODE with params**
137
+ - Given: target = "#test", modes = "+o", params = ["nick"]
138
+ - When: Build MODE
139
+ - Then: Output = "MODE #test +o nick\r\n"
140
+
141
+ **Parse MODE event**
142
+ - Given: `:op!u@h MODE #test +o target`
143
+ - When: Parse
144
+ - Then: target = "#test", modes = "+o", params = ["target"]
145
+
146
+ **Parse multi-mode**
147
+ - Given: `:op!u@h MODE #test +ov target1 target2`
148
+ - When: Parse
149
+ - Then: modes = "+ov", params = ["target1", "target2"]
150
+
151
+ ### State Updates
152
+
153
+ **Track channel modes**
154
+ - Given: Client in #test
155
+ - When: MODE +m received
156
+ - Then: channel.modes[:moderated] = true
157
+
158
+ **Track user op status**
159
+ - Given: Client tracking "nick" in #test
160
+ - When: MODE +o nick received
161
+ - Then: "nick" has :op in user modes
162
+
163
+ ## Implementation Notes
164
+
165
+ - Parse CHANMODES from ISUPPORT for parameter requirements
166
+ - Mode types: A (list), B (always param), C (param on set), D (no param)
167
+ - Multiple modes can be set at once: +ov nick1 nick2
168
+ - Track modes received both at join and via MODE messages
169
+
170
+ ## Dependencies
171
+
172
+ - Requires `01-message-parsing.md`
173
+ - Requires `05-event-system.md`
174
+ - Requires `07-join-part.md` (channel tracking)