ruby_todo 0.4.1 → 1.0.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.
@@ -0,0 +1,130 @@
1
+ # Implementation Guide: Adding AI Assistant to Ruby Todo
2
+
3
+ This guide outlines the specific steps to add AI assistant functionality to the Ruby Todo gem, allowing users to interact with the application using natural language through Claude or OpenAI.
4
+
5
+ ## Step 1: Update the gemspec file
6
+
7
+ Edit `ruby_todo.gemspec` to add the required dependencies:
8
+
9
+ ```ruby
10
+ # Runtime dependencies
11
+ spec.add_dependency "anthropic", "~> 0.1.0" # For Claude API
12
+ spec.add_dependency "ruby-openai", "~> 6.3.0" # For OpenAI API
13
+ spec.add_dependency "dotenv", "~> 2.8" # For API key management
14
+ ```
15
+
16
+ ## Step 2: Create the folder structure
17
+
18
+ ```bash
19
+ mkdir -p lib/ruby_todo/commands
20
+ ```
21
+
22
+ ## Step 3: Create the AI Assistant command file
23
+
24
+ Create a new file at `lib/ruby_todo/commands/ai_assistant.rb` with the implementation from the plan document.
25
+
26
+ ## Step 4: Update the CLI class
27
+
28
+ Edit `lib/ruby_todo/cli.rb` to add the AI Assistant command:
29
+
30
+ 1. Add the require statement at the top:
31
+ ```ruby
32
+ require_relative "commands/ai_assistant"
33
+ ```
34
+
35
+ 2. Register the subcommand inside the CLI class:
36
+ ```ruby
37
+ # Register AI Assistant subcommand
38
+ desc "ai SUBCOMMAND", "Use AI assistant"
39
+ subcommand "ai", AIAssistantCommand
40
+ ```
41
+
42
+ ## Step 5: Create .env template
43
+
44
+ Create a `.env.template` file in the project root:
45
+
46
+ ```
47
+ # Claude API key (if using Claude)
48
+ ANTHROPIC_API_KEY=your_claude_api_key_here
49
+
50
+ # OpenAI API key (if using OpenAI)
51
+ OPENAI_API_KEY=your_openai_api_key_here
52
+ ```
53
+
54
+ Add `.env` to `.gitignore` to prevent accidental check-in of API keys.
55
+
56
+ ## Step 6: Update README
57
+
58
+ Add the AI Assistant documentation to the README.md file:
59
+
60
+ 1. Add "AI Assistant" to the Features list at the top
61
+ 2. Add the full AI Assistant section after the Templates section
62
+
63
+ ## Step 7: Create tests
64
+
65
+ Create tests for the AI Assistant functionality:
66
+
67
+ 1. Create a new file `test/ai_assistant_test.rb`
68
+ 2. Add unit tests for the AI Assistant command class
69
+ 3. Add integration tests for the CLI integration
70
+ 4. Add mock tests for API interactions
71
+
72
+ ## Step 8: Install and test
73
+
74
+ 1. Install the updated gem: `bundle exec rake install`
75
+ 2. Run `ruby_todo ai configure` to set up your API key
76
+ 3. Test with a simple prompt: `ruby_todo ai ask "Create a task in the Work notebook"`
77
+
78
+ ## Usage Examples
79
+
80
+ Here are some examples of how to use the AI assistant:
81
+
82
+ 1. Configure the AI assistant:
83
+ ```bash
84
+ ruby_todo ai configure
85
+ ```
86
+
87
+ 2. Create a task using natural language:
88
+ ```bash
89
+ ruby_todo ai ask "Add a high priority task to update documentation in my Work notebook due next Friday"
90
+ ```
91
+
92
+ 3. Move tasks to a different status:
93
+ ```bash
94
+ ruby_todo ai ask "Move all tasks related to documentation in my Work notebook to in_progress"
95
+ ```
96
+
97
+ 4. Generate a JSON import file:
98
+ ```bash
99
+ ruby_todo ai ask "Create a JSON file with 5 tasks for my upcoming vacation planning"
100
+ ```
101
+
102
+ 5. Get task statistics:
103
+ ```bash
104
+ ruby_todo ai ask "Give me a summary of my Work notebook"
105
+ ```
106
+
107
+ 6. Search for specific tasks:
108
+ ```bash
109
+ ruby_todo ai ask "Find all high priority tasks that are overdue"
110
+ ```
111
+
112
+ ## Troubleshooting
113
+
114
+ 1. API key issues:
115
+ - Ensure your API key is correctly configured
116
+ - Try passing the API key directly: `ruby_todo ai ask "..." --api-key=your_key`
117
+
118
+ 2. JSON parsing errors:
119
+ - Enable verbose mode to see the raw response: `ruby_todo ai ask "..." --verbose`
120
+ - Check if the model is generating valid JSON
121
+
122
+ 3. Permission issues:
123
+ - Check file permissions on `~/.ruby_todo/ai_config.json`
124
+
125
+ 4. Missing dependencies:
126
+ - Run `bundle install` to ensure all gems are installed
127
+
128
+ 5. Command not found:
129
+ - Ensure the gem is properly installed: `gem list ruby_todo`
130
+ - Reinstall if necessary: `gem uninstall ruby_todo && bundle exec rake install`
data/lib/ruby_todo/cli.rb CHANGED
@@ -11,6 +11,7 @@ require_relative "models/notebook"
11
11
  require_relative "models/task"
12
12
  require_relative "models/template"
13
13
  require_relative "database"
14
+ require_relative "commands/ai_assistant"
14
15
 
15
16
  module RubyTodo
16
17
  class CLI < Thor
@@ -210,6 +211,10 @@ module RubyTodo
210
211
  desc "template SUBCOMMAND", "Manage task templates"
211
212
  subcommand "template", TemplateCommand
212
213
 
214
+ # Register AI Assistant subcommand
215
+ desc "ai SUBCOMMAND", "Use AI assistant"
216
+ subcommand "ai", AIAssistantCommand
217
+
213
218
  desc "task add [NOTEBOOK] [TITLE]", "Add a new task to a notebook"
214
219
  method_option :description, type: :string, desc: "Task description"
215
220
  method_option :due_date, type: :string, desc: "Due date (YYYY-MM-DD HH:MM)"
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+ require "openai"
6
+ require "dotenv/load"
7
+
8
+ module RubyTodo
9
+ class AIAssistantCommand < Thor
10
+ desc "ask [PROMPT]", "Ask the AI assistant to perform tasks using natural language"
11
+ method_option :api_key, type: :string, desc: "OpenAI API key"
12
+ method_option :verbose, type: :boolean, default: false, desc: "Show detailed response"
13
+ def ask(*prompt_args)
14
+ prompt = prompt_args.join(" ")
15
+ unless prompt && !prompt.empty?
16
+ say "Please provide a prompt for the AI assistant".red
17
+ return
18
+ end
19
+
20
+ # Get API key from options, env var, or config file
21
+ api_key = options[:api_key] || ENV["OPENAI_API_KEY"] || load_api_key_from_config
22
+ unless api_key
23
+ say "No API key found. Please provide an API key using --api-key or set OPENAI_API_KEY environment variable".red
24
+ return
25
+ end
26
+
27
+ # Set up context for the AI
28
+ context = build_context
29
+
30
+ response = query_openai(prompt, context, api_key)
31
+
32
+ if response[:error]
33
+ say "Error: #{response[:error]}".red
34
+ return
35
+ end
36
+
37
+ # Parse the AI's response for potential actions
38
+ parse_and_execute_actions(response[:content])
39
+
40
+ # Print the AI's response if verbose mode
41
+ if options[:verbose]
42
+ say "\nAI Assistant Response:".blue
43
+ say response[:content]
44
+ end
45
+ end
46
+
47
+ desc "configure", "Configure the AI assistant settings"
48
+ def configure
49
+ prompt = TTY::Prompt.new
50
+ api_key = prompt.mask("Enter your OpenAI API key:")
51
+ save_config("openai", api_key)
52
+ say "Configuration saved successfully!".green
53
+ end
54
+
55
+ private
56
+
57
+ def build_context
58
+ # Build a context object with information about the current state of the app
59
+ notebooks = Notebook.all.map do |notebook|
60
+ {
61
+ id: notebook.id,
62
+ name: notebook.name,
63
+ task_count: notebook.tasks.count,
64
+ todo_count: notebook.tasks.todo.count,
65
+ in_progress_count: notebook.tasks.in_progress.count,
66
+ done_count: notebook.tasks.done.count,
67
+ archived_count: notebook.tasks.archived.count
68
+ }
69
+ end
70
+
71
+ {
72
+ notebooks: notebooks,
73
+ commands: {
74
+ notebook_commands: %w[create list],
75
+ task_commands: %w[add list show edit delete move search],
76
+ export_commands: %w[export import],
77
+ template_commands: %w[create list show use delete]
78
+ },
79
+ app_version: RubyTodo::VERSION
80
+ }
81
+ end
82
+
83
+ def query_openai(prompt, context, api_key)
84
+ client = OpenAI::Client.new(access_token: api_key)
85
+ system_message = build_system_message(context)
86
+ make_openai_request(client, system_message, prompt)
87
+ end
88
+
89
+ def build_system_message(context)
90
+ <<~SYSTEM
91
+ You are an AI assistant for Ruby Todo, a command-line task management application.
92
+
93
+ Application Information:
94
+ #{JSON.pretty_generate(context)}
95
+
96
+ Your job is to help the user manage their tasks through natural language.
97
+ You can create tasks, move tasks between statuses, and more.
98
+
99
+ IMPORTANT FORMATTING REQUIREMENTS:
100
+ - Due dates must be in "YYYY-MM-DD HH:MM" format (e.g., "2024-04-10 14:00")
101
+ - Valid priority values are ONLY: "high", "medium", or "low" (lowercase)
102
+ - Valid status values are ONLY: "todo", "in_progress", "done", "archived" (lowercase)
103
+ - If you're unsure about any values, omit them rather than guessing
104
+
105
+ When the user asks you to perform an action, generate a response in this JSON format:
106
+ {
107
+ "explanation": "Brief explanation of what you're doing",
108
+ "actions": [
109
+ {"type": "create_task", "notebook": "Work", "title": "Task title", "description": "Task description", "priority": "high", "tags": "tag1,tag2", "due_date": "2024-04-10 14:00"},
110
+ {"type": "move_task", "notebook": "Work", "task_id": 1, "status": "in_progress"},
111
+ {"type": "create_notebook", "name": "Personal"},
112
+ {"type": "generate_import_json", "notebook": "Work", "tasks": [{...task data...}]}
113
+ ]
114
+ }
115
+
116
+ Action types:
117
+ - create_task: Create a new task in a notebook (requires notebook, title; optional: description, priority, tags, due_date)
118
+ - move_task: Move a task to a different status (requires notebook, task_id, status)
119
+ - create_notebook: Create a new notebook (requires name)
120
+ - generate_import_json: Generate JSON for importing tasks (requires notebook, tasks array)
121
+ - list_tasks: List tasks in a notebook (requires notebook; optional: status, priority)
122
+ - search_tasks: Search for tasks (requires query; optional: notebook)
123
+
124
+ EXTREMELY IMPORTANT:
125
+ 1. Always respond with raw JSON only
126
+ 2. DO NOT use markdown code blocks (like ```json)
127
+ 3. DO NOT include any explanatory text before or after the JSON
128
+ 4. The response must be parseable as JSON
129
+
130
+ Always validate all fields according to the requirements before including them in your response.
131
+ SYSTEM
132
+ end
133
+
134
+ def make_openai_request(client, system_message, prompt)
135
+ response = client.chat(
136
+ parameters: {
137
+ model: "gpt-4o-mini",
138
+ messages: [
139
+ { role: "system", content: system_message },
140
+ { role: "user", content: prompt }
141
+ ],
142
+ max_tokens: 1024
143
+ }
144
+ )
145
+ { content: response.dig("choices", 0, "message", "content") }
146
+ rescue StandardError => e
147
+ { error: e.message }
148
+ end
149
+
150
+ def parse_and_execute_actions(response)
151
+ cleaned_response = response.gsub(/```(?:json)?\s*/, "").gsub(/```\s*$/, "")
152
+
153
+ data = JSON.parse(cleaned_response)
154
+
155
+ say data["explanation"].green if data["explanation"]
156
+
157
+ if data["actions"] && data["actions"].is_a?(Array)
158
+ data["actions"].each do |action|
159
+ execute_action(action)
160
+ end
161
+ else
162
+ say "No actions found in response".yellow
163
+ end
164
+ rescue JSON::ParserError => e
165
+ say "Couldn't parse AI response: #{e.message}".red
166
+ say "Response starts with: #{response[0..100]}..." if options[:verbose]
167
+ say "Full response: #{response}" if options[:verbose]
168
+ end
169
+
170
+ def execute_action(action)
171
+ case action["type"]
172
+ when "create_task"
173
+ create_task(action)
174
+ when "move_task"
175
+ move_task(action)
176
+ when "create_notebook"
177
+ create_notebook(action)
178
+ when "generate_import_json"
179
+ generate_import_json(action)
180
+ when "list_tasks"
181
+ list_tasks(action)
182
+ when "search_tasks"
183
+ search_tasks(action)
184
+ else
185
+ say "Unknown action type: #{action["type"]}".yellow
186
+ end
187
+ end
188
+
189
+ def create_task(action)
190
+ unless action["notebook"] && action["title"]
191
+ say "Missing required fields for create_task".red
192
+ return
193
+ end
194
+
195
+ notebook = Notebook.find_by(name: action["notebook"])
196
+ unless notebook
197
+ say "Notebook '#{action["notebook"]}' not found".red
198
+ return
199
+ end
200
+
201
+ due_date = parse_task_due_date(action["due_date"])
202
+ priority = validate_task_priority(action["priority"])
203
+
204
+ task = Task.create(
205
+ notebook: notebook,
206
+ title: action["title"],
207
+ description: action["description"],
208
+ due_date: due_date,
209
+ priority: priority,
210
+ tags: action["tags"],
211
+ status: "todo"
212
+ )
213
+
214
+ if task.valid?
215
+ say "Added task: #{action["title"]}".green
216
+ else
217
+ say "Error creating task: #{task.errors.full_messages.join(", ")}".red
218
+ end
219
+ end
220
+
221
+ def parse_task_due_date(due_date_str)
222
+ return nil unless due_date_str
223
+
224
+ begin
225
+ Time.parse(due_date_str)
226
+ rescue ArgumentError
227
+ say "Invalid date format '#{due_date_str}'. Using no due date.".yellow
228
+ nil
229
+ end
230
+ end
231
+
232
+ def validate_task_priority(priority_str)
233
+ return nil unless priority_str
234
+
235
+ if %w[high medium low].include?(priority_str.downcase)
236
+ priority_str.downcase
237
+ else
238
+ msg = "Invalid priority '#{priority_str}'. Valid values are 'high', 'medium', or 'low'."
239
+ say "#{msg} Using default.".yellow
240
+ nil
241
+ end
242
+ end
243
+
244
+ def move_task(action)
245
+ unless action["notebook"] && action["task_id"] && action["status"]
246
+ say "Missing required fields for move_task".red
247
+ return
248
+ end
249
+
250
+ notebook = Notebook.find_by(name: action["notebook"])
251
+ unless notebook
252
+ say "Notebook '#{action["notebook"]}' not found".red
253
+ return
254
+ end
255
+
256
+ task = notebook.tasks.find_by(id: action["task_id"])
257
+ unless task
258
+ say "Task with ID #{action["task_id"]} not found".red
259
+ return
260
+ end
261
+
262
+ valid_statuses = %w[todo in_progress done archived]
263
+ status = action["status"].downcase
264
+ unless valid_statuses.include?(status)
265
+ say "Invalid status '#{status}'. Valid values are: #{valid_statuses.join(", ")}".red
266
+ return
267
+ end
268
+
269
+ if task.update(status: status)
270
+ say "Moved task #{action["task_id"]} to #{status}".green
271
+ else
272
+ say "Error moving task: #{task.errors.full_messages.join(", ")}".red
273
+ end
274
+ end
275
+
276
+ def create_notebook(action)
277
+ unless action["name"]
278
+ say "Missing required field 'name' for create_notebook".red
279
+ return
280
+ end
281
+
282
+ notebook = Notebook.create(name: action["name"])
283
+ if notebook.valid?
284
+ say "Created notebook: #{action["name"]}".green
285
+ else
286
+ say "Error creating notebook: #{notebook.errors.full_messages.join(", ")}".red
287
+ end
288
+ end
289
+
290
+ def generate_import_json(action)
291
+ unless action["notebook"] && action["tasks"] && action["tasks"].is_a?(Array)
292
+ say "Missing required fields for generate_import_json".red
293
+ return
294
+ end
295
+
296
+ data = {
297
+ "name" => action["notebook"],
298
+ "created_at" => Time.now.iso8601,
299
+ "updated_at" => Time.now.iso8601,
300
+ "tasks" => action["tasks"].map do |task|
301
+ {
302
+ "title" => task["title"],
303
+ "description" => task["description"],
304
+ "status" => task["status"] || "todo",
305
+ "priority" => task["priority"],
306
+ "tags" => task["tags"],
307
+ "due_date" => task["due_date"]
308
+ }
309
+ end
310
+ }
311
+
312
+ filename = action["filename"] || "#{action["notebook"].downcase.gsub(/\s+/, "_")}_tasks.json"
313
+ File.write(filename, JSON.pretty_generate(data))
314
+ say "Generated import JSON file: #{filename}".green
315
+
316
+ if @prompt.yes?("Do you want to import these tasks now?")
317
+ import_result = RubyTodo::CLI.new.import(filename)
318
+ say "Import complete: #{import_result}"
319
+ end
320
+ end
321
+
322
+ def list_tasks(action)
323
+ unless action["notebook"]
324
+ say "Missing required field 'notebook' for list_tasks".red
325
+ return
326
+ end
327
+
328
+ notebook = Notebook.find_by(name: action["notebook"])
329
+ unless notebook
330
+ say "Notebook '#{action["notebook"]}' not found".red
331
+ return
332
+ end
333
+
334
+ tasks = notebook.tasks
335
+ tasks = tasks.where(status: action["status"]) if action["status"]
336
+ tasks = tasks.where(priority: action["priority"]) if action["priority"]
337
+
338
+ if tasks.empty?
339
+ say "No tasks found".yellow
340
+ return
341
+ end
342
+
343
+ display_tasks_table(tasks)
344
+ end
345
+
346
+ def display_tasks_table(tasks)
347
+ rows = tasks.map do |t|
348
+ [
349
+ t.id,
350
+ t.title,
351
+ format_status(t.status),
352
+ t.priority || "None",
353
+ t.due_date ? t.due_date.strftime("%Y-%m-%d %H:%M") : "No due date",
354
+ t.tags || "None"
355
+ ]
356
+ end
357
+
358
+ table = TTY::Table.new(
359
+ header: ["ID", "Title", "Status", "Priority", "Due Date", "Tags"],
360
+ rows: rows
361
+ )
362
+ puts table.render(:ascii)
363
+ end
364
+
365
+ def search_tasks(action)
366
+ unless action["query"]
367
+ say "Missing required field 'query' for search_tasks".red
368
+ return
369
+ end
370
+
371
+ notebooks = if action["notebook"]
372
+ [Notebook.find_by(name: action["notebook"])].compact
373
+ else
374
+ Notebook.all
375
+ end
376
+
377
+ if notebooks.empty?
378
+ say "No notebooks found".yellow
379
+ return
380
+ end
381
+
382
+ results = find_matching_tasks(notebooks, action["query"])
383
+
384
+ if results.empty?
385
+ say "No tasks matching '#{action["query"]}' found".yellow
386
+ return
387
+ end
388
+
389
+ display_search_results(results)
390
+ end
391
+
392
+ def find_matching_tasks(notebooks, query)
393
+ results = []
394
+ notebooks.each do |notebook|
395
+ notebook.tasks.each do |task|
396
+ next unless task.title.downcase.include?(query.downcase) ||
397
+ (task.description && task.description.downcase.include?(query.downcase)) ||
398
+ (task.tags && task.tags.downcase.include?(query.downcase))
399
+
400
+ results << [notebook.name, task.id, task.title, format_status(task.status)]
401
+ end
402
+ end
403
+ results
404
+ end
405
+
406
+ def display_search_results(results)
407
+ table = TTY::Table.new(
408
+ header: %w[Notebook ID Title Status],
409
+ rows: results
410
+ )
411
+ puts table.render(:ascii)
412
+ end
413
+
414
+ def load_api_key_from_config
415
+ config_file = File.expand_path("~/.ruby_todo/ai_config.json")
416
+ return nil unless File.exist?(config_file)
417
+
418
+ config = JSON.parse(File.read(config_file))
419
+ config["api_key"]
420
+ end
421
+
422
+ def save_config(api, api_key)
423
+ config_dir = File.expand_path("~/.ruby_todo")
424
+ FileUtils.mkdir_p(config_dir)
425
+
426
+ config_file = File.join(config_dir, "ai_config.json")
427
+ config = {
428
+ "api" => api,
429
+ "api_key" => api_key
430
+ }
431
+
432
+ File.write(config_file, JSON.pretty_generate(config))
433
+ FileUtils.chmod(0o600, config_file) # Secure the file with private permissions
434
+ end
435
+
436
+ def format_status(status)
437
+ case status
438
+ when "todo" then "Todo".yellow
439
+ when "in_progress" then "In Progress".blue
440
+ when "done" then "Done".green
441
+ when "archived" then "Archived".gray
442
+ else status
443
+ end
444
+ end
445
+
446
+ def parse_due_date(date_string)
447
+ Time.parse(date_string)
448
+ rescue ArgumentError
449
+ say "Invalid date format. Use YYYY-MM-DD HH:MM format.".red
450
+ nil
451
+ end
452
+ end
453
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubyTodo
4
- VERSION = "0.4.1"
4
+ VERSION = "1.0.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_todo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeremiah Parrack
@@ -38,6 +38,34 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dotenv
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.8'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.8'
55
+ - !ruby/object:Gem::Dependency
56
+ name: ruby-openai
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 6.3.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 6.3.0
41
69
  - !ruby/object:Gem::Dependency
42
70
  name: sqlite3
43
71
  requirement: !ruby/object:Gem::Requirement
@@ -172,6 +200,7 @@ executables:
172
200
  extensions: []
173
201
  extra_rdoc_files: []
174
202
  files:
203
+ - ".env.template"
175
204
  - ".rubocop.yml"
176
205
  - CHANGELOG.md
177
206
  - CODE_OF_CONDUCT.md
@@ -180,9 +209,13 @@ files:
180
209
  - PROGRESS.md
181
210
  - README.md
182
211
  - Rakefile
212
+ - ai_assistant_implementation.md
213
+ - delete_notebooks.rb
183
214
  - exe/ruby_todo
215
+ - implementation_steps.md
184
216
  - lib/ruby_todo.rb
185
217
  - lib/ruby_todo/cli.rb
218
+ - lib/ruby_todo/commands/ai_assistant.rb
186
219
  - lib/ruby_todo/database.rb
187
220
  - lib/ruby_todo/models/notebook.rb
188
221
  - lib/ruby_todo/models/task.rb