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,611 @@
1
+ # AI Assistant Integration for Ruby Todo
2
+
3
+ This document outlines the implementation plan for adding AI assistant functionality to the Ruby Todo gem, enabling users to interact with the application using natural language through Claude or OpenAI's APIs.
4
+
5
+ ## Overview
6
+
7
+ The AI assistant will allow users to:
8
+ 1. Create tasks using natural language
9
+ 2. Move tasks between statuses
10
+ 3. Create and import task JSON files
11
+ 4. Get summaries of tasks and notebooks
12
+ 5. Ask questions about their tasks
13
+ 6. Perform bulk operations with simple instructions
14
+
15
+ ## Implementation Plan
16
+
17
+ ### 1. Add Dependencies
18
+
19
+ Add the following gems to the gemspec:
20
+ ```ruby
21
+ spec.add_dependency "anthropic", "~> 0.1.0" # For Claude API
22
+ spec.add_dependency "ruby-openai", "~> 6.3.0" # For OpenAI API
23
+ spec.add_dependency "dotenv", "~> 2.8" # For API key management
24
+ ```
25
+
26
+ ### 2. Create AI Assistant Command Class
27
+
28
+ Create a new class in `lib/ruby_todo/commands/ai_assistant.rb` to handle AI-related commands:
29
+
30
+ ```ruby
31
+ # frozen_string_literal: true
32
+
33
+ require "thor"
34
+ require "json"
35
+ require "anthropic"
36
+ require "openai"
37
+ require "dotenv/load"
38
+
39
+ module RubyTodo
40
+ class AIAssistantCommand < Thor
41
+ desc "ask [PROMPT]", "Ask the AI assistant to perform tasks using natural language"
42
+ method_option :api, type: :string, default: "claude", desc: "API to use (claude or openai)"
43
+ method_option :api_key, type: :string, desc: "API key for the selected service"
44
+ method_option :model, type: :string, desc: "Model to use (claude-3-opus-20240229, gpt-4, etc.)"
45
+ method_option :verbose, type: :boolean, default: false, desc: "Show detailed response"
46
+ def ask(*prompt_args)
47
+ prompt = prompt_args.join(" ")
48
+ unless prompt && !prompt.empty?
49
+ say "Please provide a prompt for the AI assistant".red
50
+ return
51
+ end
52
+
53
+ # Get API key from options, env var, or config file
54
+ api_key = options[:api_key] || ENV["ANTHROPIC_API_KEY"] || ENV["OPENAI_API_KEY"] || load_api_key_from_config
55
+ unless api_key
56
+ say "No API key found. Please provide an API key using --api-key or set ANTHROPIC_API_KEY or OPENAI_API_KEY environment variable".red
57
+ return
58
+ end
59
+
60
+ # Set up context for the AI
61
+ context = build_context
62
+
63
+ response = if options[:api] == "openai"
64
+ query_openai(prompt, context, api_key, options[:model] || "gpt-4")
65
+ else
66
+ query_claude(prompt, context, api_key, options[:model] || "claude-3-opus-20240229")
67
+ end
68
+
69
+ if response[:error]
70
+ say "Error: #{response[:error]}".red
71
+ return
72
+ end
73
+
74
+ # Parse the AI's response for potential actions
75
+ parse_and_execute_actions(response[:content])
76
+
77
+ # Print the AI's response if verbose mode
78
+ if options[:verbose]
79
+ say "\nAI Assistant Response:".blue
80
+ say response[:content]
81
+ end
82
+ end
83
+
84
+ desc "configure", "Configure the AI assistant settings"
85
+ def configure
86
+ prompt = TTY::Prompt.new
87
+ api = prompt.select("Which AI provider would you like to use?", %w[Claude OpenAI])
88
+
89
+ api_key = prompt.mask("Enter your API key:")
90
+
91
+ model = "gpt-4o-mino"
92
+
93
+ save_config(api.downcase, api_key, model)
94
+ say "Configuration saved successfully!".green
95
+ end
96
+
97
+ private
98
+
99
+ def build_context
100
+ # Build a context object with information about the current state of the app
101
+ notebooks = Notebook.all.map do |notebook|
102
+ {
103
+ id: notebook.id,
104
+ name: notebook.name,
105
+ task_count: notebook.tasks.count,
106
+ todo_count: notebook.tasks.todo.count,
107
+ in_progress_count: notebook.tasks.in_progress.count,
108
+ done_count: notebook.tasks.done.count,
109
+ archived_count: notebook.tasks.archived.count
110
+ }
111
+ end
112
+
113
+ {
114
+ notebooks: notebooks,
115
+ commands: {
116
+ notebook_commands: ["create", "list"],
117
+ task_commands: ["add", "list", "show", "edit", "delete", "move", "search"],
118
+ export_commands: ["export", "import"],
119
+ template_commands: ["create", "list", "show", "use", "delete"]
120
+ },
121
+ app_version: RubyTodo::VERSION
122
+ }
123
+ end
124
+
125
+ def query_claude(prompt, context, api_key, model)
126
+ client = Anthropic::Client.new(api_key: api_key)
127
+
128
+ # Prepare system message with context
129
+ system_message = <<~SYSTEM
130
+ You are an AI assistant for Ruby Todo, a command-line task management application.
131
+
132
+ Application Information:
133
+ #{JSON.pretty_generate(context)}
134
+
135
+ Your job is to help the user manage their tasks through natural language.
136
+ You can create tasks, move tasks between statuses, and more.
137
+
138
+ When the user asks you to perform an action, generate a response in this JSON format:
139
+ {
140
+ "explanation": "Brief explanation of what you're doing",
141
+ "actions": [
142
+ {"type": "create_task", "notebook": "Work", "title": "Task title", "description": "Task description", "priority": "high", "tags": "tag1,tag2", "due_date": "2024-04-10 14:00"},
143
+ {"type": "move_task", "notebook": "Work", "task_id": 1, "status": "in_progress"},
144
+ {"type": "create_notebook", "name": "Personal"},
145
+ {"type": "generate_import_json", "notebook": "Work", "tasks": [{...task data...}]}
146
+ ]
147
+ }
148
+
149
+ Action types:
150
+ - create_task: Create a new task in a notebook
151
+ - move_task: Move a task to a different status
152
+ - create_notebook: Create a new notebook
153
+ - generate_import_json: Generate JSON for importing tasks
154
+ - list_tasks: List tasks in a notebook
155
+ - search_tasks: Search for tasks
156
+
157
+ Always respond in valid JSON format.
158
+ SYSTEM
159
+
160
+ begin
161
+ response = client.messages.create(
162
+ model: model,
163
+ system: system_message,
164
+ messages: [
165
+ { role: "user", content: prompt }
166
+ ],
167
+ max_tokens: 1024
168
+ )
169
+
170
+ return { content: response.content.first.text }
171
+ rescue => e
172
+ return { error: e.message }
173
+ end
174
+ end
175
+
176
+ def query_openai(prompt, context, api_key, model)
177
+ client = OpenAI::Client.new(access_token: api_key)
178
+
179
+ # Prepare system message with context
180
+ system_message = <<~SYSTEM
181
+ You are an AI assistant for Ruby Todo, a command-line task management application.
182
+
183
+ Application Information:
184
+ #{JSON.pretty_generate(context)}
185
+
186
+ Your job is to help the user manage their tasks through natural language.
187
+ You can create tasks, move tasks between statuses, and more.
188
+
189
+ When the user asks you to perform an action, generate a response in this JSON format:
190
+ {
191
+ "explanation": "Brief explanation of what you're doing",
192
+ "actions": [
193
+ {"type": "create_task", "notebook": "Work", "title": "Task title", "description": "Task description", "priority": "high", "tags": "tag1,tag2", "due_date": "2024-04-10 14:00"},
194
+ {"type": "move_task", "notebook": "Work", "task_id": 1, "status": "in_progress"},
195
+ {"type": "create_notebook", "name": "Personal"},
196
+ {"type": "generate_import_json", "notebook": "Work", "tasks": [{...task data...}]}
197
+ ]
198
+ }
199
+
200
+ Action types:
201
+ - create_task: Create a new task in a notebook
202
+ - move_task: Move a task to a different status
203
+ - create_notebook: Create a new notebook
204
+ - generate_import_json: Generate JSON for importing tasks
205
+ - list_tasks: List tasks in a notebook
206
+ - search_tasks: Search for tasks
207
+
208
+ Always respond in valid JSON format.
209
+ SYSTEM
210
+
211
+ begin
212
+ response = client.chat(
213
+ parameters: {
214
+ model: model,
215
+ messages: [
216
+ { role: "system", content: system_message },
217
+ { role: "user", content: prompt }
218
+ ],
219
+ max_tokens: 1024
220
+ }
221
+ )
222
+
223
+ return { content: response.dig("choices", 0, "message", "content") }
224
+ rescue => e
225
+ return { error: e.message }
226
+ end
227
+ end
228
+
229
+ def parse_and_execute_actions(response)
230
+ begin
231
+ # Parse the JSON response
232
+ data = JSON.parse(response)
233
+
234
+ # Print explanation
235
+ say data["explanation"].green if data["explanation"]
236
+
237
+ # Execute each action
238
+ if data["actions"] && data["actions"].is_a?(Array)
239
+ data["actions"].each do |action|
240
+ execute_action(action)
241
+ end
242
+ end
243
+ rescue JSON::ParserError => e
244
+ say "Couldn't parse AI response: #{e.message}".red
245
+ say "Raw response: #{response}" if options[:verbose]
246
+ end
247
+ end
248
+
249
+ def execute_action(action)
250
+ case action["type"]
251
+ when "create_task"
252
+ create_task(action)
253
+ when "move_task"
254
+ move_task(action)
255
+ when "create_notebook"
256
+ create_notebook(action)
257
+ when "generate_import_json"
258
+ generate_import_json(action)
259
+ when "list_tasks"
260
+ list_tasks(action)
261
+ when "search_tasks"
262
+ search_tasks(action)
263
+ else
264
+ say "Unknown action type: #{action["type"]}".yellow
265
+ end
266
+ end
267
+
268
+ def create_task(action)
269
+ # Validate required fields
270
+ unless action["notebook"] && action["title"]
271
+ say "Missing required fields for create_task".red
272
+ return
273
+ end
274
+
275
+ # Find notebook
276
+ notebook = Notebook.find_by(name: action["notebook"])
277
+ unless notebook
278
+ say "Notebook '#{action["notebook"]}' not found".red
279
+ return
280
+ end
281
+
282
+ # Parse due date if present
283
+ due_date = action["due_date"] ? parse_due_date(action["due_date"]) : nil
284
+
285
+ # Create task
286
+ task = Task.create(
287
+ notebook: notebook,
288
+ title: action["title"],
289
+ description: action["description"],
290
+ due_date: due_date,
291
+ priority: action["priority"],
292
+ tags: action["tags"],
293
+ status: "todo"
294
+ )
295
+
296
+ if task.valid?
297
+ say "Added task: #{action["title"]}".green
298
+ else
299
+ say "Error creating task: #{task.errors.full_messages.join(", ")}".red
300
+ end
301
+ end
302
+
303
+ def move_task(action)
304
+ # Validate required fields
305
+ unless action["notebook"] && action["task_id"] && action["status"]
306
+ say "Missing required fields for move_task".red
307
+ return
308
+ end
309
+
310
+ # Find notebook
311
+ notebook = Notebook.find_by(name: action["notebook"])
312
+ unless notebook
313
+ say "Notebook '#{action["notebook"]}' not found".red
314
+ return
315
+ end
316
+
317
+ # Find task
318
+ task = notebook.tasks.find_by(id: action["task_id"])
319
+ unless task
320
+ say "Task with ID #{action["task_id"]} not found".red
321
+ return
322
+ end
323
+
324
+ # Update task status
325
+ if task.update(status: action["status"])
326
+ say "Moved task #{action["task_id"]} to #{action["status"]}".green
327
+ else
328
+ say "Error moving task: #{task.errors.full_messages.join(", ")}".red
329
+ end
330
+ end
331
+
332
+ def create_notebook(action)
333
+ # Validate required fields
334
+ unless action["name"]
335
+ say "Missing required field 'name' for create_notebook".red
336
+ return
337
+ end
338
+
339
+ # Create notebook
340
+ notebook = Notebook.create(name: action["name"])
341
+ if notebook.valid?
342
+ say "Created notebook: #{action["name"]}".green
343
+ else
344
+ say "Error creating notebook: #{notebook.errors.full_messages.join(", ")}".red
345
+ end
346
+ end
347
+
348
+ def generate_import_json(action)
349
+ # Validate required fields
350
+ unless action["notebook"] && action["tasks"] && action["tasks"].is_a?(Array)
351
+ say "Missing required fields for generate_import_json".red
352
+ return
353
+ end
354
+
355
+ # Create JSON structure
356
+ data = {
357
+ "name" => action["notebook"],
358
+ "created_at" => Time.now.iso8601,
359
+ "updated_at" => Time.now.iso8601,
360
+ "tasks" => action["tasks"].map do |task|
361
+ {
362
+ "title" => task["title"],
363
+ "description" => task["description"],
364
+ "status" => task["status"] || "todo",
365
+ "priority" => task["priority"],
366
+ "tags" => task["tags"],
367
+ "due_date" => task["due_date"]
368
+ }
369
+ end
370
+ }
371
+
372
+ # Write JSON to file
373
+ filename = action["filename"] || "#{action["notebook"].downcase.gsub(/\s+/, '_')}_tasks.json"
374
+ File.write(filename, JSON.pretty_generate(data))
375
+ say "Generated import JSON file: #{filename}".green
376
+
377
+ # Offer to import the file
378
+ if @prompt.yes?("Do you want to import these tasks now?")
379
+ import_result = RubyTodo::CLI.new.import(filename)
380
+ say "Import complete: #{import_result}"
381
+ end
382
+ end
383
+
384
+ def list_tasks(action)
385
+ # Validate required fields
386
+ unless action["notebook"]
387
+ say "Missing required field 'notebook' for list_tasks".red
388
+ return
389
+ end
390
+
391
+ # Find notebook
392
+ notebook = Notebook.find_by(name: action["notebook"])
393
+ unless notebook
394
+ say "Notebook '#{action["notebook"]}' not found".red
395
+ return
396
+ end
397
+
398
+ # Apply filters
399
+ tasks = notebook.tasks
400
+ tasks = tasks.where(status: action["status"]) if action["status"]
401
+ tasks = tasks.where(priority: action["priority"]) if action["priority"]
402
+
403
+ # Display tasks
404
+ if tasks.empty?
405
+ say "No tasks found".yellow
406
+ return
407
+ end
408
+
409
+ rows = tasks.map do |t|
410
+ [
411
+ t.id,
412
+ t.title,
413
+ format_status(t.status),
414
+ t.priority || "None",
415
+ t.due_date ? t.due_date.strftime("%Y-%m-%d %H:%M") : "No due date",
416
+ t.tags || "None"
417
+ ]
418
+ end
419
+
420
+ table = TTY::Table.new(
421
+ header: ["ID", "Title", "Status", "Priority", "Due Date", "Tags"],
422
+ rows: rows
423
+ )
424
+ puts table.render(:ascii)
425
+ end
426
+
427
+ def search_tasks(action)
428
+ # Validate required fields
429
+ unless action["query"]
430
+ say "Missing required field 'query' for search_tasks".red
431
+ return
432
+ end
433
+
434
+ # Determine notebooks
435
+ notebooks = if action["notebook"]
436
+ [Notebook.find_by(name: action["notebook"])].compact
437
+ else
438
+ Notebook.all
439
+ end
440
+
441
+ if notebooks.empty?
442
+ say "No notebooks found".yellow
443
+ return
444
+ end
445
+
446
+ # Search for tasks
447
+ results = []
448
+ notebooks.each do |notebook|
449
+ notebook.tasks.each do |task|
450
+ next unless task.title.downcase.include?(action["query"].downcase) ||
451
+ (task.description && task.description.downcase.include?(action["query"].downcase)) ||
452
+ (task.tags && task.tags.downcase.include?(action["query"].downcase))
453
+
454
+ results << [notebook.name, task.id, task.title, format_status(task.status)]
455
+ end
456
+ end
457
+
458
+ if results.empty?
459
+ say "No tasks matching '#{action["query"]}' found".yellow
460
+ return
461
+ end
462
+
463
+ table = TTY::Table.new(
464
+ header: %w[Notebook ID Title Status],
465
+ rows: results
466
+ )
467
+ puts table.render(:ascii)
468
+ end
469
+
470
+ def load_api_key_from_config
471
+ config_file = File.expand_path("~/.ruby_todo/ai_config.json")
472
+ return nil unless File.exist?(config_file)
473
+
474
+ config = JSON.parse(File.read(config_file))
475
+ config["api_key"]
476
+ end
477
+
478
+ def save_config(api, api_key, model)
479
+ config_dir = File.expand_path("~/.ruby_todo")
480
+ FileUtils.mkdir_p(config_dir)
481
+
482
+ config_file = File.join(config_dir, "ai_config.json")
483
+ config = {
484
+ "api" => api,
485
+ "api_key" => api_key,
486
+ "model" => model
487
+ }
488
+
489
+ File.write(config_file, JSON.pretty_generate(config))
490
+ FileUtils.chmod(0600, config_file) # Secure the file with private permissions
491
+ end
492
+
493
+ # Helper methods for formatting
494
+ def format_status(status)
495
+ case status
496
+ when "todo" then "Todo".yellow
497
+ when "in_progress" then "In Progress".blue
498
+ when "done" then "Done".green
499
+ when "archived" then "Archived".gray
500
+ else status
501
+ end
502
+ end
503
+
504
+ def parse_due_date(date_string)
505
+ Time.parse(date_string)
506
+ rescue ArgumentError
507
+ say "Invalid date format. Use YYYY-MM-DD HH:MM format.".red
508
+ nil
509
+ end
510
+ end
511
+ end
512
+ ```
513
+
514
+ ### 3. Integrate AI Assistant Command with CLI
515
+
516
+ Update `lib/ruby_todo/cli.rb` to register the AI Assistant command:
517
+
518
+ ```ruby
519
+ # Add the require statement at the top
520
+ require_relative "commands/ai_assistant"
521
+
522
+ module RubyTodo
523
+ class CLI < Thor
524
+ # Existing code...
525
+
526
+ # Register AI Assistant subcommand
527
+ desc "ai SUBCOMMAND", "Use AI assistant"
528
+ subcommand "ai", AIAssistantCommand
529
+
530
+ # More existing code...
531
+ end
532
+ end
533
+ ```
534
+
535
+ ### 4. Add Configuration Files
536
+
537
+ Create sample `.env` file for API key management:
538
+
539
+ ```
540
+ ANTHROPIC_API_KEY=your_claude_api_key_here
541
+ OPENAI_API_KEY=your_openai_api_key_here
542
+ ```
543
+
544
+ ### 5. Update README Documentation
545
+
546
+ Add new section to README.md for AI Assistant usage:
547
+
548
+ ```markdown
549
+ ### AI Assistant
550
+
551
+ Ruby Todo includes an AI assistant powered by Claude or OpenAI that can help you manage your tasks using natural language.
552
+
553
+ #### Configuration
554
+
555
+ Configure your AI assistant:
556
+ ```bash
557
+ $ ruby_todo ai configure
558
+ ```
559
+
560
+ #### Using the AI Assistant
561
+
562
+ Ask the AI assistant to perform actions:
563
+ ```bash
564
+ $ ruby_todo ai ask "Create a new task in my Work notebook to update the documentation by next Friday"
565
+ ```
566
+
567
+ ```bash
568
+ $ ruby_todo ai ask "Move all tasks related to the API project to in_progress status"
569
+ ```
570
+
571
+ ```bash
572
+ $ ruby_todo ai ask "Create a JSON to import 5 new tasks for my upcoming vacation"
573
+ ```
574
+
575
+ Pass in an API key directly (if not configured):
576
+ ```bash
577
+ $ ruby_todo ai ask "What tasks are overdue?" --api-key=your_api_key_here --api=claude
578
+ ```
579
+
580
+ Enable verbose mode to see full AI responses:
581
+ ```bash
582
+ $ ruby_todo ai ask "Summarize my Work notebook" --verbose
583
+ ```
584
+ ```
585
+
586
+ ## Implementation Timeline
587
+
588
+ 1. Add new dependencies to gemspec
589
+ 2. Create the AI Assistant command structure
590
+ 3. Implement API integration (Claude and OpenAI)
591
+ 4. Add action parsing and execution
592
+ 5. Create configuration management
593
+ 6. Update documentation and examples
594
+ 7. Test with various prompts
595
+
596
+ ## Security Considerations
597
+
598
+ 1. API keys stored in `~/.ruby_todo/ai_config.json` should have appropriate file permissions (0600)
599
+ 2. Environmental variables can be used instead of configuration files
600
+ 3. Options for passing API keys directly in CLI should warn about shell history
601
+ 4. Input validation to prevent command injection
602
+ 5. Error handling to prevent exposing sensitive information
603
+
604
+ ## Testing Plan
605
+
606
+ 1. Unit tests for AI command class
607
+ 2. Integration tests for action execution
608
+ 3. Mock API responses for testing
609
+ 4. Test with various prompt patterns
610
+ 5. Test configuration storage and retrieval
611
+ 6. Test error handling and edge cases
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "lib/ruby_todo"
5
+ require_relative "lib/ruby_todo/database"
6
+
7
+ # Setup the database connection
8
+ RubyTodo::Database.setup
9
+
10
+ # Count notebooks and tasks before deletion
11
+ notebook_count = RubyTodo::Notebook.count
12
+ task_count = RubyTodo::Task.count
13
+
14
+ # Delete tasks first to avoid foreign key constraint errors
15
+ RubyTodo::Task.delete_all
16
+ puts "Successfully deleted #{task_count} tasks."
17
+
18
+ # Then delete notebooks
19
+ RubyTodo::Notebook.delete_all
20
+ puts "Successfully deleted #{notebook_count} notebooks."