ruby_todo 0.3.5 → 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.
- checksums.yaml +4 -4
- data/.env.template +2 -0
- data/CHANGELOG.md +17 -1
- data/README.md +37 -73
- data/ai_assistant_implementation.md +611 -0
- data/delete_notebooks.rb +20 -0
- data/implementation_steps.md +130 -0
- data/lib/ruby_todo/cli.rb +33 -21
- data/lib/ruby_todo/commands/ai_assistant.rb +453 -0
- data/lib/ruby_todo/version.rb +1 -1
- metadata +34 -1
@@ -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
|
@@ -26,6 +27,30 @@ module RubyTodo
|
|
26
27
|
true
|
27
28
|
end
|
28
29
|
|
30
|
+
# Notebook commands
|
31
|
+
class NotebookCommand < Thor
|
32
|
+
desc "create NAME", "Create a new notebook"
|
33
|
+
def create(name)
|
34
|
+
Notebook.create(name: name)
|
35
|
+
puts "Created notebook: #{name}".green
|
36
|
+
end
|
37
|
+
|
38
|
+
desc "list", "List all notebooks"
|
39
|
+
def list
|
40
|
+
notebooks = Notebook.all
|
41
|
+
if notebooks.empty?
|
42
|
+
puts "No notebooks found. Create one with 'ruby_todo notebook create NAME'".yellow
|
43
|
+
return
|
44
|
+
end
|
45
|
+
|
46
|
+
table = TTY::Table.new(
|
47
|
+
header: ["ID", "Name", "Tasks", "Created At"],
|
48
|
+
rows: notebooks.map { |n| [n.id, n.name, n.tasks.count, n.created_at] }
|
49
|
+
)
|
50
|
+
puts table.render(:ascii)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
29
54
|
# Template commands
|
30
55
|
class TemplateCommand < Thor
|
31
56
|
desc "create NAME", "Create a new task template"
|
@@ -179,26 +204,16 @@ module RubyTodo
|
|
179
204
|
say "Ruby Todo has been initialized successfully!".green
|
180
205
|
end
|
181
206
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
say "Created notebook: #{name}".green
|
186
|
-
end
|
207
|
+
# Register subcommands
|
208
|
+
desc "notebook SUBCOMMAND", "Manage notebooks"
|
209
|
+
subcommand "notebook", NotebookCommand
|
187
210
|
|
188
|
-
desc "
|
189
|
-
|
190
|
-
notebooks = Notebook.all
|
191
|
-
if notebooks.empty?
|
192
|
-
say "No notebooks found. Create one with 'ruby_todo notebook create [NAME]'".yellow
|
193
|
-
return
|
194
|
-
end
|
211
|
+
desc "template SUBCOMMAND", "Manage task templates"
|
212
|
+
subcommand "template", TemplateCommand
|
195
213
|
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
)
|
200
|
-
puts table.render(:ascii)
|
201
|
-
end
|
214
|
+
# Register AI Assistant subcommand
|
215
|
+
desc "ai SUBCOMMAND", "Use AI assistant"
|
216
|
+
subcommand "ai", AIAssistantCommand
|
202
217
|
|
203
218
|
desc "task add [NOTEBOOK] [TITLE]", "Add a new task to a notebook"
|
204
219
|
method_option :description, type: :string, desc: "Task description"
|
@@ -569,9 +584,6 @@ module RubyTodo
|
|
569
584
|
end
|
570
585
|
end
|
571
586
|
|
572
|
-
desc "template SUBCOMMAND", "Manage task templates"
|
573
|
-
subcommand "template", TemplateCommand
|
574
|
-
|
575
587
|
# Task-related command aliases
|
576
588
|
map "task:list" => "task_list"
|
577
589
|
map "task:show" => "task_show"
|
@@ -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
|
data/lib/ruby_todo/version.rb
CHANGED