taskai-cli 0.1.3__py3-none-any.whl

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.
taskai/cli.py ADDED
@@ -0,0 +1,312 @@
1
+ # standard lib
2
+ import argparse
3
+ import os
4
+ from datetime import datetime
5
+ import builtins
6
+
7
+ # local
8
+ from taskai.json_dir_database import JsonDirectoryDatabase
9
+ from taskai.views import view_lists, view_item, view_items
10
+ from taskai.models import Base, TodoItem, TodoList, Comment
11
+ from taskai.services.ai import ai_headstart_service, ai_natural_language_service
12
+ from taskai.services.user_setup import user_setup_service
13
+ from taskai.help_menu import help_menu
14
+ from taskai.config import GlobalConfig
15
+
16
+ # external
17
+ from rich import print
18
+
19
+ # config
20
+ DB_PATH = ".taskai/task_db"
21
+ USER = os.getenv("USER")
22
+ db = JsonDirectoryDatabase(
23
+ DB_PATH,
24
+ USER
25
+ )
26
+ db.connect()
27
+
28
+ og_help = builtins.help
29
+ def new_help(*args, **kwargs):
30
+ print(f"help: args: {args}, kwargs: {kwargs}")
31
+ return og_help(*args, **kwargs)
32
+ builtins.help = new_help
33
+
34
+ class Controller:
35
+
36
+ # utilities
37
+ def _find_model_by_substring(attr: str, value: str) -> TodoItem|TodoList|Comment|None:
38
+ for id_ in [*db.lists.keys(),*db.items.keys(),*db.comments.keys()]:
39
+ model = db.read(id_)
40
+ if (
41
+ hasattr(model, attr) and
42
+ isinstance(getattr(model, attr), str) and
43
+ value in getattr(model, attr)
44
+ ):
45
+ return model
46
+
47
+ return None
48
+
49
+
50
+ def _debug(args, kwargs):
51
+ print("args:", args)
52
+ print("kwargs:", kwargs)
53
+
54
+ # CRUD
55
+ def show_all(show_done=False):
56
+ view_lists(db, db.lists, show_done=show_done)
57
+
58
+ def show_by_id(id_, show_done=False):
59
+ if id_ in db.items:
60
+ Controller.show_item(id_)
61
+ elif id_ in db.lists:
62
+ Controller.show_list(id_, show_done=show_done)
63
+
64
+ def show_by_list_name(value: str, show_done=False):
65
+ model = Controller._find_model_by_substring("name", value)
66
+ if isinstance(model, TodoList):
67
+ Controller.show_list(model.id, show_done=show_done)
68
+ else:
69
+ print(f"Could not find list with substring '{value}'")
70
+
71
+ def show_list(list_id: int|str, show_done=False):
72
+ view_lists(db, [list_id], show_done=show_done)
73
+
74
+ def show_item(item_id: int|str):
75
+ view_item(db, item_id)
76
+
77
+ def show_items(item_ids: str):
78
+ item_ids = item_ids.split(",")
79
+ view_items(db, item_ids)
80
+
81
+ def show_examples():
82
+ ...
83
+ print("Not implemented yet")
84
+
85
+ def create_list(name: str):
86
+ list = TodoList(name=name)
87
+ list_id = db.create(list)
88
+ db.commit()
89
+ print(f"Creating list {list_id} - {list.name}")
90
+
91
+ def create_item(list_id: int|str, title: str, **kwargs):
92
+ try:
93
+ int(list_id)
94
+ except ValueError:
95
+ list_ = Controller._find_model_by_substring("name", list_id)
96
+ # TODO this should be just lists, not models
97
+ list_id = list_.id
98
+
99
+ item = TodoItem(title=title, list_id=list_id)
100
+
101
+ for k, v in kwargs.items():
102
+ if v is None:
103
+ continue
104
+ match k:
105
+ case "completed": item.completed = bool(v)
106
+ case "description": item.description = str(v)
107
+ case "due_by": item.due_by = datetime.strptime(v, "%Y-%m-%d")
108
+ case "parent": item.parent = str(v)
109
+ case "priority": item.priority = int(v)
110
+ case "depends_on": item.dependency_ids.extend(v.split(","))
111
+ # TODO handle recurrence
112
+ db.create(item)
113
+ db.commit()
114
+
115
+ def create_comment(item_id: int|str, content: str):
116
+ comment = Comment(
117
+ item_id=item_id,
118
+ content=content
119
+ )
120
+ db.create(comment)
121
+ db.commit()
122
+
123
+ def update_item(item_id: int|str, **kwargs):
124
+ item: TodoItem = db.read(item_id)
125
+ for k, v in kwargs.items():
126
+ if v is None:
127
+ continue
128
+ match k:
129
+ case "title": item.title = str(v)
130
+ case "list_id": item.list_id = str(v)
131
+ case "completed": item.completed = bool(v)
132
+ case "description": item.description = str(v)
133
+ case "due_by": item.due_by = datetime.strptime(v, "%Y-%m-%d")
134
+ case "parent": item.parent = str(v)
135
+ case "priority": item.priority = int(v)
136
+ # TODO handle recurrence
137
+
138
+ db.update(item)
139
+ db.commit()
140
+
141
+ def delete(id_: int|str):
142
+ db.delete(id_)
143
+ db.commit()
144
+ print(f"Deleted {id_}")
145
+
146
+ def delete_completed():
147
+ for item_id in db.items.copy():
148
+ item: TodoItem = db.read(item_id)
149
+ if item.completed:
150
+ db.delete(item_id)
151
+ db.commit()
152
+
153
+ def ai_headstart(item_id: int|str):
154
+ ai_response_text = ai_headstart_service(db, item_id)
155
+ comment_content = f"AI: {ai_response_text}"
156
+ Controller.create_comment(item_id, comment_content)
157
+ print(comment_content)
158
+
159
+ def ai_natural_language(prompt: str):
160
+ ai_natural_language_service(db, prompt)
161
+
162
+ def throw_error(error_description: str, *args, **kwargs):
163
+ print(f"[red]ERROR: {error_description}[/red]\nargs={args}\nkwargs={kwargs}")
164
+
165
+ def get_config_value(key: str):
166
+ print(db.get_config_value(key))
167
+
168
+ def list_config():
169
+ for k, v in db.get_config().items():
170
+ print(f"{k}={v}")
171
+
172
+ def set_config_value(key: str, value: any):
173
+ db.set_config_value(key, value)
174
+ db.commit()
175
+ print(f"setting {key}={value}")
176
+
177
+ def remove_config_value(key: str):
178
+ db.config.pop(key)
179
+ db.commit()
180
+
181
+ def run_setup_service():
182
+ user_setup_service(db)
183
+
184
+ # utilities
185
+ def _parse_remaining(remaining_args: list[str]) -> tuple[list, dict]:
186
+
187
+ for i, _arg in enumerate(remaining_args):
188
+ remaining_args[i] = _arg.replace(" ", "+-*/")
189
+ remaining_args = " ".join(remaining_args).replace("="," ").split(" ")
190
+ for i, _arg in enumerate(remaining_args):
191
+ remaining_args[i] = _arg.replace("+-*/", " ")
192
+
193
+ # outputs
194
+ args = []
195
+ kwargs = {}
196
+
197
+ while remaining_args:
198
+ next_arg = remaining_args.pop(0)
199
+ if next_arg.startswith("--"):
200
+ assert remaining_args, "kwarg specified with no value provided"
201
+ kwargs[next_arg[2:]] = remaining_args.pop(0)
202
+ else:
203
+ args.append(next_arg)
204
+
205
+ return args, kwargs
206
+
207
+ def _is_int(val: any) -> bool:
208
+ try:
209
+ int(val)
210
+ return True
211
+ except:
212
+ return False
213
+
214
+ def _matches_string(val: str, target: str) -> bool:
215
+ # TODO make this follow linux rules
216
+ if val in target:
217
+ return True
218
+ return False
219
+
220
+
221
+
222
+ def entry_point():
223
+
224
+ arg_parser = argparse.ArgumentParser()
225
+ _, argv = arg_parser.parse_known_args()
226
+
227
+
228
+ if not argv:
229
+ print(help_menu["general"])
230
+ return
231
+
232
+ if argv[0] in ("help","--help"):
233
+ print(help_menu["general"])
234
+ return
235
+
236
+ args, kwargs = _parse_remaining(argv)
237
+ GlobalConfig.load_dict(db.config)
238
+
239
+ try:
240
+ match args[0]:
241
+ case "setup":
242
+ Controller.run_setup_service()
243
+
244
+ case "show":
245
+ match args[1]:
246
+ case "all": Controller.show_all(*args[2:], **kwargs)
247
+ case "list": Controller.show_list(*args[2:], **kwargs)
248
+ case "item": Controller.show_item(*args[2:], **kwargs)
249
+ case "items": Controller.show_item(*args[2:], **kwargs)
250
+ case "examples": Controller.show_examples()
251
+ case _ if _is_int(args[1]): Controller.show_by_id(*args[1:], **kwargs)
252
+ case _: Controller.show_by_list_name(args[1], **kwargs)
253
+
254
+ case "create":
255
+ match args[1]:
256
+ case "item": Controller.create_item(*args[2:], **kwargs)
257
+ case "list": Controller.create_list(*args[2:], **kwargs)
258
+ case "comment": Controller.create_comment(*args[2:], **kwargs)
259
+ case _: Controller.throw_error("uncrecognized create command", *args, **kwargs)
260
+
261
+ case "update":
262
+ match args[1]:
263
+ case _ if _is_int(args[1]): Controller.update_item(args[1], **kwargs)
264
+ case _: Controller.throw_error("uncregnozed update command", *args, **kwargs)
265
+
266
+ case "delete" | "remove":
267
+ match args[1]:
268
+ case _ if _is_int(args[1]): Controller.delete(args[1])
269
+ case "item": Controller.delete(args[2])
270
+ case "list": Controller.delete(args[2])
271
+ case "completed" | "done": Controller.delete_completed()
272
+ case _: Controller.throw_error("unrecognized delete command", *args, **kwargs)
273
+
274
+ case "comment":
275
+ match args[1]:
276
+ case _ if _is_int(args[1]): Controller.create_comment(*args[1:], **kwargs)
277
+
278
+ case "config":
279
+ match args[1]:
280
+ case "set": Controller.set_config_value(key=args[2], value=args[3])
281
+ case "get": Controller.get_config_value(key=args[2])
282
+ case "list"|"show": Controller.list_config()
283
+ case "pop": Controller.remove_config_value(key=args[2])
284
+ case _: Controller.throw_error("unrecognized command", *args, **kwargs)
285
+
286
+ case "ai":
287
+ match args[1]:
288
+ case "headstart": Controller.ai_headstart(*args[2:], **kwargs)
289
+ case _: Controller.ai_natural_language(" ".join(args[1:]))
290
+
291
+ case "nuke":
292
+ db.remove()
293
+
294
+ case "add":
295
+ Controller.create_item(*args[1:], **kwargs)
296
+
297
+ case "complete" | "done":
298
+ match args[1]:
299
+ case _ if _is_int(args[1]): Controller.update_item(args[1], completed=True)
300
+ case _: Controller.throw_error("unrecognized complete command", *args, **kwargs)
301
+
302
+ case "examples":
303
+ Controller.show_examples()
304
+
305
+ case _: Controller.throw_error("unrecognized command", *args, **kwargs)
306
+ except Exception as e:
307
+ Controller.throw_error(f"encountered exception '{e}'", *args, **kwargs)
308
+
309
+
310
+
311
+ if __name__ == "__main__":
312
+ entry_point()
taskai/config.py ADDED
@@ -0,0 +1,36 @@
1
+
2
+
3
+ class GlobalConfig:
4
+ """Global store for configuration values"""
5
+
6
+
7
+ _data_store: dict[str, any]
8
+
9
+ @classmethod
10
+ def load_dict(cls, d: dict):
11
+ """loads values from a configuration"""
12
+ cls._data_store = d
13
+
14
+ @classmethod
15
+ def get(cls, key: str):
16
+ """Gets a value from a configuration"""
17
+ if key not in cls._data_store:
18
+ return None
19
+ return cls._data_store[key]
20
+
21
+ @classmethod
22
+ def set(cls, key: str, value: any):
23
+ cls._data_store[key] = value
24
+
25
+
26
+
27
+ def config(config_key: str, kwarg_name: str):
28
+ def decorator(fn):
29
+ def inner(*args, **kwargs):
30
+ kwargs[kwarg_name] = GlobalConfig.get(config_key)
31
+ result = fn(*args, **kwargs)
32
+ return result
33
+ return inner
34
+ return decorator
35
+
36
+
taskai/help_menu.py ADDED
@@ -0,0 +1,35 @@
1
+
2
+
3
+
4
+ help_general = """
5
+
6
+ Welcome to Task! Here's what you can do:
7
+
8
+ 'task show all' --> show all of your lists and item titles, with their respective IDs prepended
9
+ 'task show {id or substring}' --> find the list or item matching your identifier and show it using its respective type's show command
10
+ 'task show list {id or substring}' --> show the list and all of its item titles
11
+ 'task show item {id}' --> show the associated item and all of its specified information
12
+ 'task show items {id1},{id2},...,{idx}' --> show the associated items and all of their specified information
13
+ 'task create item {list id or substring} {title} {**kwargs}' --> Create a new item for the associated list. Can specify kwargs as --optional cli arguments.
14
+ 'task create list {name}' --> create a new list by that name
15
+ 'task delete {id}' --> deletes the list or item associated with that id
16
+ 'task delete item {id}' --> deletes the item associated with that id
17
+ 'task delete list {id}' --> deletes the list associated with that id
18
+ 'task delete completed' --> deletes all items that have been completed
19
+ 'task remove ...' --> aliases directly to 'task delete ...
20
+ 'task update {item id} {**kwargs}' --> updates the associated attributes on the item
21
+ 'task comment {item id} {content}' --> adds a comment to that items comment thread
22
+ 'task ai {prompt}' --> Feeds a prompt directly to an LLM, which constructs and executes a series of task commands according to its interpretation of the prompt
23
+ 'task ai headstart {item id}' --> Feeds the item context to an LLM, which responds with a concise description of the next step to perform. The response is added as a comment in the items comment thread
24
+ 'task nuke' --> deletes all of your task data, letting you have a fresh start
25
+ 'task add ...' -> aliases directly to 'task create item ...'
26
+ 'task complete {item id}' --> sets the associated item's .completed attribute to true
27
+
28
+ Run 'task show examples' to print a comprehensive set of examples, and grep for the ones that you're interested in!
29
+
30
+ """
31
+
32
+
33
+ help_menu = {
34
+ "general": help_general,
35
+ }
@@ -0,0 +1,176 @@
1
+ # standard lib
2
+ import os
3
+ from pathlib import Path
4
+ import shutil
5
+
6
+ # local
7
+ from taskai.models import *
8
+
9
+ # external
10
+ import orjson as json
11
+
12
+ class JsonDirectoryDatabase:
13
+
14
+ def __init__(
15
+ self,
16
+ dirpath: os.PathLike,
17
+ user: str
18
+ ):
19
+
20
+ self.db_dir = Path(dirpath)
21
+ self.user_name = user
22
+ self.user_data_path = Path(dirpath) / (user.strip(" ") + ".json")
23
+ self.user_data = None
24
+
25
+ if not os.path.exists(self.db_dir):
26
+ self._setup_directory()
27
+
28
+ if not os.path.exists(self.user_data_path):
29
+ self._setup_user_data()
30
+
31
+ def remove(self):
32
+ if os.path.exists(self.db_dir):
33
+ shutil.rmtree(self.db_dir)
34
+
35
+ # setup utils
36
+ def _setup_directory(self):
37
+ os.makedirs(self.db_dir, exist_ok=True)
38
+
39
+ def _setup_user_data(self):
40
+ user_data = User(
41
+ id="0",
42
+ name=self.user_name
43
+ ).model_dump()
44
+
45
+ user_data[TodoList.__name__] = {}
46
+ user_data[TodoItem.__name__] = {}
47
+ user_data[Comment.__name__] = {}
48
+ user_data["config"] = {}
49
+
50
+ self.user_data = user_data
51
+ self.commit()
52
+
53
+ # data accessors
54
+ @property
55
+ def lists(self) -> dict:
56
+ return self.user_data[TodoList.__name__]
57
+
58
+ @property
59
+ def items(self) -> dict:
60
+ return self.user_data[TodoItem.__name__]
61
+
62
+ @property
63
+ def comments(self) -> dict:
64
+ return self.user_data[Comment.__name__]
65
+
66
+ @property
67
+ def user_id(self):
68
+ return self.user_data["id"]
69
+
70
+ @property
71
+ def config(self) -> dict:
72
+ return self.user_data["config"]
73
+
74
+ # io
75
+ def connect(self):
76
+ with open(self.user_data_path, "rb") as f:
77
+ json_bstring = f.read()
78
+ self.user_data = json.loads(json_bstring)
79
+
80
+ def commit(self):
81
+ with open(self.user_data_path, "wb") as f:
82
+ json_bstring = json.dumps(self.user_data)
83
+ f.write(json_bstring)
84
+
85
+ def flush(self):
86
+ self.user_data = None
87
+
88
+ def close(self):
89
+ self.flush()
90
+
91
+ # crud
92
+ def create(self, record: Base) -> int:
93
+
94
+ record.id = str(self.user_data["id_counter"])
95
+ record.user_id = self.user_id
96
+
97
+ if isinstance(record, TodoList):
98
+ self.lists[record.id] = record.model_dump()
99
+
100
+ elif isinstance(record, TodoItem):
101
+ self.items[record.id] = record.model_dump()
102
+ self.lists[record.list_id]["item_ids"].append(record.id)
103
+
104
+ elif isinstance(record, Comment):
105
+ self.comments[record.id] = record.model_dump()
106
+ self.items[record.item_id]["comment_ids"].append(record.id)
107
+ else:
108
+ raise RuntimeError("don't know what you're creating")
109
+
110
+ self.user_data["id_counter"] += 1
111
+ return record.id
112
+
113
+ def read(self, id_: int|str) -> Base:
114
+ id_ = str(id_)
115
+ if id_ in self.user_data[TodoList.__name__]:
116
+ return TodoList(**self.lists[id_])
117
+ elif id_ in self.user_data[TodoItem.__name__]:
118
+ return TodoItem(**self.items[id_])
119
+ elif id_ in self.user_data[Comment.__name__]:
120
+ return Comment(**self.comments[id_])
121
+ else:
122
+ raise RuntimeError("Can't find record to read")
123
+
124
+ def update(self, record: Base) -> None:
125
+
126
+ if isinstance(record, TodoItem):
127
+
128
+ assert record.id in self.items, "item doesn't exist"
129
+
130
+ # update dependencies
131
+ old_item: TodoItem = self.items[record.id]
132
+ if old_item["list_id"] != record.list_id:
133
+ old_list: TodoList = self.read(old_item["list_id"])
134
+ old_list.item_ids.remove(record.id)
135
+ new_list: TodoList = self.read(record.list_id)
136
+ new_list.item_ids.append(record.id)
137
+
138
+ self.items[record.id] = record.model_dump()
139
+
140
+ else:
141
+ raise RuntimeError("don't know what you're creating")
142
+
143
+ def delete(self, id_: int|str) -> None:
144
+ id_ = str(id_)
145
+ if id_ in self.user_data[TodoList.__name__]:
146
+ list_: TodoList = TodoList(**self.lists.pop(id_))
147
+ for item_id in list_.item_ids:
148
+ self.items.pop(item_id)
149
+ elif id_ in self.user_data[TodoItem.__name__]:
150
+ item: TodoItem = TodoItem(**self.items.pop(id_))
151
+ self.lists[item.list_id]["item_ids"].remove(id_)
152
+ elif id_ in self.user_data[Comment.__name__]:
153
+ comment: Comment = Comment(**self.comments.pop(id_))
154
+ self.items[comment.item_id].comment_ids.remove(id_)
155
+ else:
156
+ raise RuntimeError("Don't know what you're deleting")
157
+
158
+
159
+ # Utilities
160
+ def get_list_by_name(self, name: str):
161
+ for list_id in self.lists:
162
+ list_: TodoList = self.read(list_id)
163
+ if list_.name.lower() == name.lower():
164
+ return list_
165
+ raise RuntimeError("Don't recognize list")
166
+
167
+
168
+ # Config
169
+ def get_config_value(self, key: str) -> any:
170
+ return self.user_data["config"].get(key)
171
+
172
+ def get_config(self) -> dict:
173
+ return self.user_data.get("config", {})
174
+
175
+ def set_config_value(self, key: str, value: any):
176
+ self.user_data["config"][key] = value
taskai/models.py ADDED
@@ -0,0 +1,57 @@
1
+ # standard lib
2
+ from dataclasses import dataclass
3
+ from typing import Optional
4
+ from datetime import datetime, timedelta
5
+
6
+ # external
7
+ from pydantic import Field, BaseModel
8
+
9
+ class Base(BaseModel):
10
+ pass
11
+
12
+ @dataclass
13
+ class PostgresConfig:
14
+ pass
15
+
16
+ class User(Base):
17
+ id: Optional[str] = Field(default=None, primary_key=True)
18
+ name: str
19
+
20
+ id_counter: int = 1
21
+ # password stuff
22
+
23
+ class TodoList(Base):
24
+ name: str
25
+
26
+ id: Optional[str] = Field(default=None, primary_key=True)
27
+ user_id: Optional[str] = None
28
+ item_ids: Optional[list[str]] = Field(default_factory=list)
29
+
30
+ class TodoItem(Base):
31
+ title: str = Field(index=True)
32
+
33
+ id: Optional[str] = Field(default=None, primary_key=True)
34
+ list_id: Optional[str] = None
35
+ user_id: Optional[str] = None
36
+ created_on: datetime = Field(default_factory=datetime.now)
37
+ completed: bool = False
38
+ description: str = ""
39
+ due_by: Optional[datetime] = None
40
+ parent: Optional[str] = None
41
+ comment_ids: list[str] = Field(default_factory=list)
42
+ dependency_ids: list[str] = Field(default_factory=list)
43
+ priority: int = 0
44
+ recurs_every: Optional[list[timedelta]] = None
45
+ recurs_until: Optional[datetime] = None
46
+ recur_keep_incomplete: bool = False
47
+
48
+ class Comment(Base):
49
+ content: str
50
+
51
+ item_id: Optional[str] = None
52
+ id: Optional[str] = Field(default=None, primary_key=True)
53
+ user_id: Optional[str] = None
54
+ created_on: datetime = Field(default_factory=datetime.now)
55
+
56
+ class LLMConfig(Base):
57
+ pass
taskai/services/ai.py ADDED
@@ -0,0 +1,179 @@
1
+ """
2
+ This file defines services for calling an LLM.
3
+
4
+ All services are idempotent
5
+ """
6
+ # standard lib
7
+ import json
8
+
9
+ # local
10
+ from taskai.json_dir_database import JsonDirectoryDatabase
11
+ from taskai.models import TodoItem, TodoList, Comment
12
+ from taskai.config import config
13
+ from taskai.help_menu import help_general
14
+
15
+ @config("GEMINI_API_KEY", "api_key")
16
+ @config("GEMINI_MODEL", "model_name")
17
+ def ai_headstart_service(
18
+ db: JsonDirectoryDatabase,
19
+ item_id: str,
20
+ api_key: str,
21
+ model_name: str
22
+ ):
23
+ """
24
+ This service queries an LLM for your task and asks it to give you
25
+ a headstart on a given task. It will perform the following operations:
26
+
27
+ - Compile the context for the query, including task description, dependencies
28
+ and previous comments
29
+ - Query the LLM
30
+ - Parse and return its response
31
+ """
32
+
33
+
34
+ item:TodoItem = db.read(item_id)
35
+
36
+
37
+ from google import genai
38
+
39
+
40
+
41
+ # contruct prompt
42
+ prompt = f"""
43
+ Hi Gemini, you're job is to provide a very succinct headstart for a taskai item.
44
+ This will involve the following:
45
+ - parsing the task information, including description, comments and dependencies
46
+ - performing any necessary internet searches in order to acquire relevant information
47
+ - deciding on what the next immediate step to be taken is
48
+ - returning a very succinct command to the user, with the information necessary to execute that command
49
+
50
+
51
+ Examples of good responses:
52
+
53
+ "Call the florist: 443-869-2158"
54
+ "Email HR @ hr@comapany.com 'Hi, I won't be able to make it in today'"
55
+ "Write the natural language service interface:\ndef natural_language_service(db:):\n\t..."
56
+
57
+ These should be short and to the point. Your response should contain NOTHING but the comment for the user.
58
+
59
+ Here's the relevant information:
60
+
61
+ task title: {item.title}
62
+ """
63
+
64
+ if item.description:
65
+ prompt += f"\ntask description: {item.description}"
66
+ if item.comment_ids:
67
+
68
+ prompt += f"\ntask comments:"
69
+ for comment_id in item.comment_ids:
70
+ comment: Comment = db.read(comment_id)
71
+ prompt += f"\n\t- {comment.content}"
72
+
73
+ # return "Survey says go fuck yourself"
74
+
75
+ # query model
76
+ client = genai.Client(api_key=api_key)
77
+ response = client.models.generate_content(
78
+ model=model_name,
79
+ contents=prompt
80
+ )
81
+
82
+ return response.text
83
+
84
+ @config("GEMINI_API_KEY", "api_key")
85
+ @config("GEMINI_MODEL", "model_name")
86
+ def ai_natural_language_service(
87
+ db: JsonDirectoryDatabase,
88
+ prompt: str,
89
+ api_key: str,
90
+ model_name: str
91
+ ):
92
+ """
93
+ This service queries an LLM with a natural language
94
+ prompt from the user. The response is a series of terminal commands
95
+ called directly
96
+ """
97
+ print(f"Ai prompt: {prompt}")
98
+
99
+ # build user info
100
+ user_info = []
101
+ for id_ in db.lists:
102
+ list: TodoList = db.read(id_)
103
+ user_info.append(f"{list.id} {list.name}")
104
+ for item_id in list.item_ids:
105
+
106
+ item: TodoItem = db.read(item_id)
107
+ if not item.completed:
108
+ user_info.append(f"\t {item.id} {item.title}")
109
+ user_info = "\n".join(user_info)
110
+
111
+ # build ai prompt
112
+ ai_prompt = f"""
113
+ You are a todo-list agent. Your job is to convert a natural language description from a user into a set
114
+ of CLI operations using our app. Here are a comprehensive list of operations that can be performed
115
+
116
+ {help_general}
117
+
118
+ Here are all of the user's list and task titles, each prepended with their id
119
+
120
+ {user_info}
121
+
122
+ Here is the user's prompt:
123
+
124
+ {prompt}
125
+ """
126
+
127
+ ai_prompt += """
128
+ Your response should be a JSON output with a valid list of commands, as specified by the description above.
129
+ Each command should be structured in the following format:
130
+ {
131
+ "command": ... -> the subcommand you want to use (omit the first 'task' here)
132
+ "args": [...] -> the positional arguments to use
133
+ "kwargs": {"..." : "...", ...} -> the keyword arguments to use. All keyword argument names should be prepended with '--'
134
+ }
135
+
136
+ Here is an example of a response that you could return:
137
+
138
+ ```
139
+ [
140
+ {
141
+ "command": "add",
142
+ "args": ["Daily", "Take out the trash"],
143
+ "kwargs": {"--description": "some description"}
144
+ },
145
+ {
146
+ "command": "add",
147
+ "args": ["Daily", "Walk the dog"]
148
+ },
149
+ {
150
+ "command": "delete",
151
+ "args": ["Old Daily List"]
152
+ }
153
+ ]
154
+ ```
155
+
156
+ It is ABSOLUTELY IMPERATIVE that your response be valid json, as your output is going to be parsed directly. Return NOTHING but the json output.
157
+
158
+ """
159
+
160
+
161
+ from google import genai
162
+
163
+ print(ai_prompt)
164
+ client = genai.Client(api_key=api_key)
165
+ response = client.models.generate_content(
166
+ model=model_name,
167
+ contents=prompt
168
+ )
169
+
170
+ try:
171
+ response_json = json.loads(response.text)
172
+ print(response_json)
173
+ except json.JSONDecodeError:
174
+ print(response.text)
175
+ print("\n\ndecode error")
176
+ import sys
177
+ sys.exit(-1)
178
+
179
+
@@ -0,0 +1,63 @@
1
+ """
2
+ This service will handle user setup and configuration
3
+
4
+ Basically, we're going to have a set of steps that we're going to iterate through
5
+ """
6
+ from taskai.json_dir_database import JsonDirectoryDatabase
7
+
8
+ from rich.prompt import Prompt
9
+ from rich import print
10
+ import os
11
+
12
+
13
+ def user_setup_service(
14
+ db: JsonDirectoryDatabase
15
+ ):
16
+
17
+ config = db.config
18
+ # setup gemini model
19
+ print("Beginning setup")
20
+ if "GEMINI_API_KEY" not in config:
21
+ api_key = _get_gemini_api_key()
22
+ if api_key:
23
+ config["GEMINI_API_KEY"] = api_key
24
+ else:
25
+ print("gemini key already specified")
26
+
27
+ if "GEMINI_MODEL" not in config:
28
+ model = _select_gemini_model()
29
+ if model:
30
+ config["GEMINI_MODEL"] = model
31
+ else:
32
+ print("gemini model already specified")
33
+ print("Setup complete! Use 'task config set|get|list' to interact with your configuration options")
34
+ db.commit()
35
+
36
+ def _get_gemini_api_key() -> str|None:
37
+ response = Prompt.ask("Please enter your Gemini API key (use '$--' to access env vars)")
38
+
39
+ if response.startswith("$"):
40
+ response = os.getenv(response[1:])
41
+ if not response:
42
+ return
43
+ print(f"Storing: [green]{response}[/green]")
44
+ return response
45
+
46
+
47
+ def _select_gemini_model():
48
+ response = Prompt.ask(
49
+ "Please select which Gemini model you would like:",
50
+ choices=[
51
+ "gemini-3.5-flash"
52
+ ]
53
+ )
54
+ return response
55
+
56
+
57
+ if __name__ == "__main__":
58
+ import os
59
+ db = JsonDirectoryDatabase(
60
+ ".taskai/task_db", user=os.getenv("USER")
61
+ )
62
+ db.connect()
63
+ user_setup_service(db)
taskai/views.py ADDED
@@ -0,0 +1,93 @@
1
+ # local
2
+ from taskai.json_dir_database import JsonDirectoryDatabase
3
+ from taskai.models import TodoList, TodoItem, Comment
4
+
5
+ # external
6
+ from rich import print
7
+ from rich.console import Console
8
+ import rich
9
+
10
+
11
+ def view_lists(db: JsonDirectoryDatabase, ids: list[str], show_done=False):
12
+ """Shows all the lists"""
13
+
14
+
15
+ for id_ in ids:
16
+
17
+ if id_ in db.lists:
18
+ list: TodoList = db.read(id_)
19
+ elif isinstance(id_, str):
20
+ for lid in db.lists:
21
+ list = db.read(lid)
22
+ if list.name.lower() == id_.lower():
23
+ break
24
+ else:
25
+ raise RuntimeError("cannot match list")
26
+
27
+ print(list.id, list.name)
28
+ for item_id in list.item_ids:
29
+
30
+ # silently repair lists with
31
+ if (item_id not in db.items):
32
+ list.item_ids.remove(item_id)
33
+ continue
34
+
35
+ item: TodoItem = db.read(item_id)
36
+ if not item.completed:
37
+ print("\t",item.id, item.title)
38
+ elif show_done:
39
+ print(f"\t [strike]{item.id} {item.title}[/strike]")
40
+
41
+
42
+ def view_item(db: JsonDirectoryDatabase, item: str|TodoItem):
43
+ """Show details for item"""
44
+
45
+ if isinstance(item, (str,int)):
46
+ item: TodoItem = db.read(item)
47
+ console = Console()
48
+ console.print(f"[bold green]Title:[/bold green] {item.title}")
49
+ if item.due_by:
50
+ console.print(f"[bold green]Due By:[/bold green] {item.due_by or ""}")
51
+
52
+ if item.description:
53
+ console.print(f"\n[bold green]Description:[/bold green]\n{item.description}")
54
+
55
+ if item.dependency_ids:
56
+ console.print(f"\n[bold green]Depends on:[/bold green]{''.join([
57
+ f'\n{depend_id} - {db.read(depend_id).title}' for depend_id in item.dependency_ids
58
+ ])}")
59
+
60
+ if item.comment_ids:
61
+ console.print("\n[bold green]\nComments:[/bold green]")
62
+ for comment_id in item.comment_ids:
63
+ comment: Comment = db.read(comment_id)
64
+ console.print(f"{comment.created_on.strftime("%Y-%m-%d %H:%M:%S")} - {comment.content}")
65
+
66
+
67
+ def view_items(
68
+ db: JsonDirectoryDatabase,
69
+ ids: list[str],
70
+ sort_by: str = "due_by",
71
+ ascending: bool = False
72
+ ):
73
+
74
+ items = {}
75
+ for id_ in ids:
76
+ if id_ in db.items:
77
+ item = db.read(id_)
78
+ items[id_] = (item)
79
+ elif id_ in db.lists:
80
+ list: TodoList = db.read(id_)
81
+ for item_id in list.item_ids:
82
+ item = db.read(item_id)
83
+ items[item_id] = item
84
+
85
+
86
+ # sort
87
+ items = sorted(items.values(), key=lambda item: str(getattr(item, sort_by)), reverse=ascending)
88
+
89
+ # view
90
+ for item in items:
91
+ rich.console.Console().rule(style="bold white")
92
+ view_item(db, item)
93
+ print()
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: taskai-cli
3
+ Version: 0.1.3
4
+ Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: google-genai>=2.8
7
+ Requires-Dist: orjson>=3.11
8
+ Requires-Dist: pydantic>=2.13
9
+ Requires-Dist: rich>=15
10
+ Description-Content-Type: text/markdown
11
+
12
+ # Task AI
13
+
14
+ Welcome to Task AI - a command-line todo list with some extra ai features.
15
+
16
+ Here's what you can do:
17
+
18
+ - `task show all` --> show all of your lists and item titles, with their respective IDs prepended
19
+ - `task show {id or substring}` --> find the list or item matching your identifier and show it using its respective type's show command
20
+ - `task show list {id or substring}` --> show the list and all of its item titles
21
+ - `task show item {id}` --> show the associated item and all of its specified information
22
+ - `task show items {id1},{id2},...,{idx}` --> show the associated items and all of their specified information
23
+ - `task create item {list id or substring} {title} {**kwargs}` --> Create a new item for the associated list. Can specify kwargs as --optional cli arguments.
24
+ - `task create list {name}` --> create a new list by that name
25
+ - `task delete {id}` --> deletes the list or item associated with that id
26
+ - `task delete item {id}` --> deletes the item associated with that id
27
+ - `task delete list {id}` --> deletes the list associated with that id
28
+ - `task delete completed` --> deletes all items that have been completed
29
+ - `task remove ...` --> aliases directly to 'task delete ...
30
+ - `task update {item id} {**kwargs}` --> updates the associated attributes on the item
31
+ - `task comment {item id} {content}` --> adds a comment to that items comment thread
32
+ - `task ai {prompt}` --> Feeds a prompt directly to an LLM, which constructs and executes a series of task commands according to its interpretation of the prompt
33
+ - `task ai headstart {item id}` --> Feeds the item context to an LLM, which responds with a concise description of the next step to perform. The response is added as a comment in the items comment thread
34
+ - `task nuke` --> deletes all of your task data, letting you have a fresh start
35
+ - `task add ...` -> aliases directly to `task create item ...`
36
+ - `task complete {item id}` --> sets the associated item's .completed attribute to true
37
+
38
+ Run `task show examples` to print a comprehensive set of examples, and grep for the ones that you're interested in!
39
+
40
+
41
+ # Installation
42
+
43
+
44
+ # Configuration
45
+
46
+
47
+
@@ -0,0 +1,12 @@
1
+ taskai/cli.py,sha256=hXC8Ksp9VsGoCji-yihd0e_cl48K374JN1h7cQ1X4BM,10392
2
+ taskai/config.py,sha256=TJWcIUCvACwSrEQNPm4yIzIu3MfGL4DFlj0LjyO_6As,808
3
+ taskai/help_menu.py,sha256=H91sgHc9pkfe21CPWoijBMLJ93UBpJ3ZGW6wVTupW0Y,2053
4
+ taskai/json_dir_database.py,sha256=5jwzytRimoceXYJ8CEhFSWW9EQnbmAXohyPAqhB0NY4,5292
5
+ taskai/models.py,sha256=PKy2FNNU0r5azO0GBw1RoiW2H9-KY1scaGHvYLrbFlw,1509
6
+ taskai/views.py,sha256=gvd0mt8xTyNJYpn6uzg6r9trZNnOgCrfZA_8cIkluh8,2834
7
+ taskai/services/ai.py,sha256=ZQKii_fU-mUWfSLkiC6y7EIk1pTdNS-Av3o5Fd6uBNc,4950
8
+ taskai/services/user_setup.py,sha256=F68b6aITPI3Ez4yGE6yu3fGRJ7k1tIroLux84Uw7-m8,1627
9
+ taskai_cli-0.1.3.dist-info/METADATA,sha256=dR_ILfRS3vnbfxIct8spMavL_p7VXQJfL8poxiPMlb4,2410
10
+ taskai_cli-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ taskai_cli-0.1.3.dist-info/entry_points.txt,sha256=BWVOsXn4B7dsx_uNYjxEmX6YSW_qjCXqwNQVyIqctO4,48
12
+ taskai_cli-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ task = taskai.cli:entry_point