pocketrb 0.1.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 (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. metadata +327 -0
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Pocketrb
7
+ module Channels
8
+ # WhatsApp channel via Node.js bridge WebSocket
9
+ # Connects to a whatsapp-web.js bridge running on localhost
10
+ #
11
+ # Bridge Protocol:
12
+ # - Receive: {type: "message", sender: "...", content: "...", timestamp: ..., isGroup: bool}
13
+ # - Send: {type: "send", to: "phone@s.whatsapp.net", text: "..."}
14
+ # - Status: {type: "status", status: "connected"}
15
+ # - QR: {type: "qr", qr: "data:image/png;base64,..."}
16
+ class WhatsApp < Base
17
+ RECONNECT_DELAY = 5
18
+ DEFAULT_BRIDGE_URL = "ws://localhost:3001"
19
+
20
+ def initialize(bus:, bridge_url: DEFAULT_BRIDGE_URL, allowed_users: nil, download_media: true)
21
+ super(bus: bus, name: :whatsapp)
22
+ @bridge_url = bridge_url
23
+ @allowed_users = allowed_users # Array of phone numbers
24
+ @download_media = download_media
25
+ @ws = nil
26
+ @connected = false
27
+ @media_processor = Media::Processor.new
28
+ end
29
+
30
+ # Check if we can connect to the bridge
31
+ def available?
32
+ require "websocket-client-simple"
33
+ true
34
+ rescue LoadError
35
+ false
36
+ end
37
+
38
+ protected
39
+
40
+ def run_inbound_loop
41
+ require "websocket-client-simple"
42
+
43
+ Pocketrb.logger.info("Starting WhatsApp channel (bridge: #{@bridge_url})")
44
+
45
+ loop do
46
+ break unless @running
47
+
48
+ begin
49
+ connect_and_listen
50
+ rescue StandardError => e
51
+ Pocketrb.logger.error("WhatsApp connection error: #{e.message}")
52
+ end
53
+
54
+ if @running
55
+ Pocketrb.logger.info("Reconnecting to WhatsApp bridge in #{RECONNECT_DELAY}s...")
56
+ sleep RECONNECT_DELAY
57
+ end
58
+ end
59
+ rescue LoadError
60
+ Pocketrb.logger.error("websocket-client-simple gem not installed. Run: gem install websocket-client-simple")
61
+ raise
62
+ end
63
+
64
+ def send_message(message)
65
+ return unless @ws && @connected
66
+
67
+ jid = to_jid(message.chat_id)
68
+ payload = {
69
+ type: "send",
70
+ to: jid,
71
+ text: message.content
72
+ }
73
+
74
+ @ws.send(payload.to_json)
75
+ Pocketrb.logger.debug("WhatsApp: sent message to #{jid}")
76
+ rescue StandardError => e
77
+ Pocketrb.logger.error("WhatsApp send error: #{e.message}")
78
+ end
79
+
80
+ private
81
+
82
+ def connect_and_listen
83
+ channel = self
84
+
85
+ @ws = WebSocket::Client::Simple.connect(@bridge_url)
86
+
87
+ @ws.on :open do
88
+ channel.instance_variable_set(:@connected, true)
89
+ Pocketrb.logger.info("WhatsApp: connected to bridge")
90
+ end
91
+
92
+ @ws.on :message do |msg|
93
+ channel.send(:handle_bridge_message, msg.data)
94
+ end
95
+
96
+ @ws.on :error do |e|
97
+ Pocketrb.logger.error("WhatsApp WebSocket error: #{e.message}")
98
+ end
99
+
100
+ @ws.on :close do |_e|
101
+ channel.instance_variable_set(:@connected, false)
102
+ Pocketrb.logger.warn("WhatsApp: disconnected from bridge")
103
+ end
104
+
105
+ # Block until disconnected
106
+ sleep 0.5 while @ws.open? && @running
107
+ end
108
+
109
+ def handle_bridge_message(data)
110
+ message = JSON.parse(data)
111
+
112
+ case message["type"]
113
+ when "message"
114
+ handle_incoming_message(message)
115
+ when "status"
116
+ Pocketrb.logger.info("WhatsApp status: #{message["status"]}")
117
+ when "qr"
118
+ handle_qr(message)
119
+ when "ready"
120
+ Pocketrb.logger.info("WhatsApp: ready")
121
+ when "authenticated"
122
+ Pocketrb.logger.info("WhatsApp: authenticated")
123
+ when "error"
124
+ Pocketrb.logger.error("WhatsApp error: #{message["error"]}")
125
+ else
126
+ Pocketrb.logger.debug("WhatsApp: unknown message type: #{message["type"]}")
127
+ end
128
+ rescue JSON::ParserError => e
129
+ Pocketrb.logger.warn("WhatsApp: invalid JSON: #{e.message}")
130
+ end
131
+
132
+ def handle_incoming_message(message)
133
+ sender = extract_phone(message["sender"] || message["from"])
134
+ return unless allowed_user?(sender)
135
+
136
+ # Skip if it's from self
137
+ return if message["fromMe"]
138
+
139
+ content = message["content"] || message["body"] || ""
140
+ chat_id = message["sender"] || message["from"]
141
+ is_group = message["isGroup"] || chat_id.include?("@g.us")
142
+
143
+ # Process media if present
144
+ media = []
145
+ if @download_media && message["hasMedia"]
146
+ media_item = process_whatsapp_media(message)
147
+ media << media_item if media_item
148
+
149
+ # Add indicator to content
150
+ content = "[Image attached - I can see this image]\n#{content}".strip if media_item&.image?
151
+ content = "[Media attached: #{media_item&.filename}]\n#{content}".strip if media_item && !media_item.image?
152
+ end
153
+
154
+ # Skip if no content and no media
155
+ return if content.empty? && media.empty?
156
+
157
+ Pocketrb.logger.debug("WhatsApp message from #{sender}: #{content[0..50]}... (#{media.size} media)")
158
+
159
+ inbound = create_inbound_message(
160
+ sender_id: sender,
161
+ chat_id: chat_id,
162
+ content: content.empty? ? "[media only]" : content,
163
+ media: media,
164
+ metadata: {
165
+ is_group: is_group,
166
+ timestamp: message["timestamp"],
167
+ message_id: message["id"]
168
+ }
169
+ )
170
+
171
+ @bus.publish_inbound(inbound)
172
+ end
173
+
174
+ def process_whatsapp_media(message)
175
+ return nil unless message["mediaData"] || message["mediaUrl"]
176
+
177
+ mime_type = message["mimetype"] || message["mediaType"] || "application/octet-stream"
178
+ filename = message["filename"] || "media_#{Time.now.to_i}.#{extension_for_mime(mime_type)}"
179
+
180
+ if message["mediaData"]
181
+ # Base64 encoded data from bridge
182
+ require "base64"
183
+ bytes = Base64.decode64(message["mediaData"])
184
+ @media_processor.from_bytes(bytes, mime_type: mime_type, filename: filename)
185
+ elsif message["mediaUrl"]
186
+ # URL to download
187
+ @media_processor.download(message["mediaUrl"], filename: filename, mime_type: mime_type)
188
+ end
189
+ rescue StandardError => e
190
+ Pocketrb.logger.warn("Failed to process WhatsApp media: #{e.message}")
191
+ nil
192
+ end
193
+
194
+ def extension_for_mime(mime_type)
195
+ case mime_type
196
+ when %r{^image/jpeg} then "jpg"
197
+ when %r{^image/png} then "png"
198
+ when %r{^image/gif} then "gif"
199
+ when %r{^image/webp} then "webp"
200
+ when %r{^audio/ogg} then "ogg"
201
+ when %r{^audio/mpeg} then "mp3"
202
+ when %r{^video/mp4} then "mp4"
203
+ else "bin"
204
+ end
205
+ end
206
+
207
+ def handle_qr(message)
208
+ return unless message["qr"]
209
+
210
+ Pocketrb.logger.info("WhatsApp: QR code received. Scan in bridge terminal or use the data URL.")
211
+ # Could save to file or display in terminal if needed
212
+ end
213
+
214
+ def allowed_user?(phone)
215
+ return true if @allowed_users.nil? || @allowed_users.empty?
216
+
217
+ normalized = normalize_phone(phone)
218
+ @allowed_users.any? { |allowed| normalize_phone(allowed) == normalized }
219
+ end
220
+
221
+ def to_jid(phone)
222
+ return phone if phone.include?("@")
223
+
224
+ # Remove any non-digits
225
+ clean = phone.gsub(/\D/, "")
226
+ "#{clean}@s.whatsapp.net"
227
+ end
228
+
229
+ def extract_phone(jid)
230
+ return jid unless jid
231
+
232
+ jid.split("@").first
233
+ end
234
+
235
+ def normalize_phone(phone)
236
+ return nil unless phone
237
+
238
+ phone.to_s.gsub(/\D/, "")
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Pocketrb
6
+ class CLI
7
+ # Base class for CLI commands with common utilities
8
+ class Base < Thor
9
+ include Thor::Actions
10
+
11
+ # Configures Thor to exit with error status on command failure
12
+ # @return [Boolean] true to exit on failure
13
+ def self.exit_on_failure?
14
+ true
15
+ end
16
+
17
+ private
18
+
19
+ # Set up logging based on verbose/quiet options
20
+ def setup_logging
21
+ if options[:verbose]
22
+ Pocketrb.logger.level = Logger::DEBUG
23
+ elsif options[:quiet]
24
+ Pocketrb.logger.level = Logger::ERROR
25
+ end
26
+ end
27
+
28
+ # Resolve workspace directory from options or current directory
29
+ # @return [Pathname] workspace path
30
+ def resolve_workspace
31
+ path = options[:workspace] || Dir.pwd
32
+ Pathname.new(path).expand_path
33
+ end
34
+
35
+ # Resolve memory directory from options or workspace
36
+ # @return [Pathname] memory directory path
37
+ def resolve_memory_dir
38
+ path = options[:memory_dir] || options[:workspace] || Dir.pwd
39
+ Pathname.new(path).expand_path
40
+ end
41
+
42
+ # Create provider instance from config
43
+ # @param config [Config] configuration object
44
+ # @return [Providers::Base] provider instance
45
+ def create_provider(config)
46
+ provider_name = config[:provider]&.to_sym || :anthropic
47
+ Providers::Registry.get(provider_name, config.provider_config)
48
+ end
49
+
50
+ # Handle cron job execution
51
+ # @param agent_loop [Agent::Loop] agent loop instance
52
+ # @param bus [Bus::MessageBus] message bus
53
+ # @param job [Cron::Job] cron job to execute
54
+ def handle_cron_job(agent_loop, bus, job)
55
+ if job.payload.deliver
56
+ # Deliver message directly to channel
57
+ outbound = Pocketrb::Bus::OutboundMessage.new(
58
+ channel: job.payload.channel&.to_sym || :cli,
59
+ chat_id: job.payload.to || "cron",
60
+ content: job.payload.message
61
+ )
62
+ bus.publish_outbound(outbound)
63
+ else
64
+ # Process as agent task
65
+ msg = Pocketrb::Bus::InboundMessage.new(
66
+ channel: :cron,
67
+ sender_id: "cron",
68
+ chat_id: job.id,
69
+ content: job.payload.message,
70
+ metadata: { job_id: job.id, job_name: job.name }
71
+ )
72
+ agent_loop.process_message(msg)
73
+ end
74
+ end
75
+
76
+ # Process heartbeat message
77
+ # @param agent_loop [Agent::Loop] agent loop instance
78
+ # @param prompt [String] heartbeat prompt
79
+ # @return [String, nil] response content
80
+ def process_heartbeat(agent_loop, prompt)
81
+ msg = Pocketrb::Bus::InboundMessage.new(
82
+ channel: :heartbeat,
83
+ sender_id: "heartbeat",
84
+ chat_id: "heartbeat",
85
+ content: prompt
86
+ )
87
+ response = agent_loop.process_message(msg)
88
+ response&.content
89
+ end
90
+
91
+ # Default TOOLS.md content for new workspaces
92
+ # @return [String] markdown content
93
+ def default_tools_content
94
+ <<~MD
95
+ # Tools
96
+
97
+ This document describes the tools and skills available in this workspace.
98
+
99
+ ## Built-in Tools
100
+
101
+ - **read_file**: Read file contents
102
+ - **write_file**: Write content to a file
103
+ - **edit_file**: Edit a file with search/replace
104
+ - **list_dir**: List directory contents
105
+ - **exec**: Execute shell commands
106
+ - **web_search**: Search the web
107
+ - **web_fetch**: Fetch web page content
108
+ - **think**: Internal reasoning tool
109
+ - **plan**: Create and manage execution plans
110
+ - **memory**: Search and store in long-term memory
111
+
112
+ ## Skills
113
+
114
+ Create custom skills in the `skills/` directory.
115
+ MD
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ class CLI
5
+ # Chat command - interactive chat mode
6
+ class Chat < Base
7
+ desc "chat", "Interactive chat mode (single session)"
8
+ option :model, type: :string, aliases: "-m", desc: "Model to use"
9
+ option :provider, type: :string, aliases: "-p", desc: "LLM provider"
10
+ option :system_prompt, type: :string, aliases: "-s", desc: "Custom system prompt"
11
+
12
+ # Starts an interactive chat session with the agent
13
+ # @return [void]
14
+ def call
15
+ setup_logging
16
+ workspace = resolve_workspace
17
+ memory_dir = resolve_memory_dir
18
+
19
+ config = Pocketrb::Config.load(memory_dir)
20
+ config[:model] = options[:model] if options[:model]
21
+ config[:provider] = options[:provider] if options[:provider]
22
+
23
+ provider = create_provider(config)
24
+ bus = Pocketrb::Bus::MessageBus.new
25
+
26
+ agent_loop = Pocketrb::Agent::Loop.new(
27
+ bus: bus,
28
+ provider: provider,
29
+ workspace: workspace,
30
+ memory_dir: memory_dir,
31
+ model: config[:model],
32
+ system_prompt: options[:system_prompt],
33
+ mcp_endpoint: config[:mcp_endpoint]
34
+ )
35
+
36
+ say "Pocketrb Chat - #{config[:model]}", :green
37
+ say "Memory: #{memory_dir}" if memory_dir != workspace
38
+ say "Type 'exit' or 'quit' to end session\n"
39
+
40
+ Async do
41
+ # Simple REPL
42
+ loop do
43
+ print "\n> "
44
+ input = $stdin.gets&.chomp
45
+ break if input.nil? || %w[exit quit].include?(input.downcase)
46
+
47
+ next if input.empty?
48
+
49
+ msg = Pocketrb::Bus::InboundMessage.new(
50
+ channel: :cli,
51
+ sender_id: "user",
52
+ chat_id: "chat",
53
+ content: input
54
+ )
55
+
56
+ response = agent_loop.process_message(msg)
57
+ puts "\n#{response.content}" if response
58
+ end
59
+ end
60
+
61
+ say "\nGoodbye!", :yellow
62
+ end
63
+
64
+ default_task :call
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ class CLI
5
+ # Config command - manages configuration
6
+ class Config < Thor
7
+ desc "show", "Show current configuration"
8
+ def show
9
+ workspace = options[:workspace] || Dir.pwd
10
+ config = Pocketrb::Config.load(workspace)
11
+
12
+ say "Configuration for #{workspace}:"
13
+ config.to_h.each do |key, value|
14
+ # Don't show API keys
15
+ display_value = key.to_s.include?("api_key") ? "[REDACTED]" : value
16
+ say " #{key}: #{display_value}"
17
+ end
18
+ end
19
+
20
+ desc "set KEY VALUE", "Set a configuration value"
21
+ def set(key, value)
22
+ workspace = options[:workspace] || Dir.pwd
23
+ config = Pocketrb::Config.load(workspace)
24
+
25
+ # Type conversion
26
+ value = case value
27
+ when /^\d+$/ then value.to_i
28
+ when /^\d+\.\d+$/ then value.to_f
29
+ when "true" then true
30
+ when "false" then false
31
+ else value
32
+ end
33
+
34
+ config.set(key, value)
35
+ say "Set #{key} = #{value}", :green
36
+ end
37
+
38
+ desc "get KEY", "Get a configuration value"
39
+ def get(key)
40
+ workspace = options[:workspace] || Dir.pwd
41
+ config = Pocketrb::Config.load(workspace)
42
+
43
+ value = config[key]
44
+ if value
45
+ say "#{key}: #{value}"
46
+ else
47
+ say "Key '#{key}' not found", :yellow
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pocketrb
4
+ class CLI
5
+ # Cron command - manages scheduled jobs
6
+ class Cron < Thor
7
+ desc "list", "List scheduled jobs"
8
+ option :all, type: :boolean, aliases: "-a", desc: "Include disabled jobs"
9
+ def list
10
+ workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
11
+ cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
12
+
13
+ service = Pocketrb::Cron::Service.new(
14
+ store_path: cron_store,
15
+ on_job: ->(_) {}
16
+ )
17
+
18
+ jobs = service.list_jobs(include_disabled: options[:all])
19
+ if jobs.empty?
20
+ say "No scheduled jobs", :yellow
21
+ return
22
+ end
23
+
24
+ say "Scheduled jobs:"
25
+ jobs.each do |job|
26
+ status = job.enabled ? "enabled" : "disabled"
27
+ next_run = job.state.next_run_at_ms ? Time.at(job.state.next_run_at_ms / 1000).strftime("%Y-%m-%d %H:%M") : "never"
28
+ say " #{job.id}: #{job.name} [#{status}] - next: #{next_run}"
29
+ end
30
+ end
31
+
32
+ desc "add", "Add a scheduled job"
33
+ option :name, type: :string, required: true, desc: "Job name"
34
+ option :message, type: :string, required: true, desc: "Message to process"
35
+ option :every, type: :numeric, desc: "Run every N seconds"
36
+ option :cron, type: :string, desc: "Cron expression (e.g., '0 9 * * *')"
37
+ option :at, type: :string, desc: "Run once at ISO datetime"
38
+ option :deliver, type: :boolean, default: false, desc: "Deliver to channel instead of processing"
39
+ option :channel, type: :string, desc: "Target channel for delivery"
40
+ option :to, type: :string, desc: "Target chat ID for delivery"
41
+ def add
42
+ workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
43
+ cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
44
+
45
+ service = Pocketrb::Cron::Service.new(
46
+ store_path: cron_store,
47
+ on_job: ->(_) {}
48
+ )
49
+
50
+ job = if options[:every]
51
+ service.add_interval_job(
52
+ name: options[:name],
53
+ every: options[:every],
54
+ message: options[:message],
55
+ deliver: options[:deliver],
56
+ channel: options[:channel],
57
+ to: options[:to]
58
+ )
59
+ elsif options[:cron]
60
+ service.add_cron_job(
61
+ name: options[:name],
62
+ cron: options[:cron],
63
+ message: options[:message],
64
+ deliver: options[:deliver],
65
+ channel: options[:channel],
66
+ to: options[:to]
67
+ )
68
+ elsif options[:at]
69
+ at_time = Time.parse(options[:at])
70
+ service.add_one_time_job(
71
+ name: options[:name],
72
+ at: at_time,
73
+ message: options[:message],
74
+ deliver: options[:deliver],
75
+ channel: options[:channel],
76
+ to: options[:to]
77
+ )
78
+ else
79
+ say "Error: Must specify --every, --cron, or --at", :red
80
+ exit 1
81
+ end
82
+
83
+ say "Created job: #{job.id} (#{job.name})", :green
84
+ end
85
+
86
+ desc "remove JOB_ID", "Remove a scheduled job"
87
+ def remove(job_id)
88
+ workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
89
+ cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
90
+
91
+ service = Pocketrb::Cron::Service.new(
92
+ store_path: cron_store,
93
+ on_job: ->(_) {}
94
+ )
95
+
96
+ if service.remove_job(job_id)
97
+ say "Removed job: #{job_id}", :green
98
+ else
99
+ say "Job not found: #{job_id}", :red
100
+ end
101
+ end
102
+
103
+ desc "enable JOB_ID", "Enable a scheduled job"
104
+ def enable(job_id)
105
+ workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
106
+ cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
107
+
108
+ service = Pocketrb::Cron::Service.new(
109
+ store_path: cron_store,
110
+ on_job: ->(_) {}
111
+ )
112
+
113
+ if service.enable_job(job_id, enabled: true)
114
+ say "Enabled job: #{job_id}", :green
115
+ else
116
+ say "Job not found: #{job_id}", :red
117
+ end
118
+ end
119
+
120
+ desc "disable JOB_ID", "Disable a scheduled job"
121
+ def disable(job_id)
122
+ workspace = Pathname.new(options[:workspace] || Dir.pwd).expand_path
123
+ cron_store = workspace.join(".pocketrb", "data", "cron", "jobs.json")
124
+
125
+ service = Pocketrb::Cron::Service.new(
126
+ store_path: cron_store,
127
+ on_job: ->(_) {}
128
+ )
129
+
130
+ if service.enable_job(job_id, enabled: false)
131
+ say "Disabled job: #{job_id}", :green
132
+ else
133
+ say "Job not found: #{job_id}", :red
134
+ end
135
+ end
136
+
137
+ desc "trigger JOB_ID", "Trigger a job manually"
138
+ def trigger(_job_id)
139
+ say "Manual job execution requires running gateway", :yellow
140
+ say "Use 'pocketrb gateway' and the job will be executed", :yellow
141
+ end
142
+ end
143
+ end
144
+ end