jasem 0.2.0__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.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: jasem
3
+ Version: 0.2.0
4
+ Summary: A plain-text task manager and time tracker with pluggable AI parsing (Ollama / OpenAI-compatible / Anthropic).
5
+ Author-email: mrfatolahi1 <fatolahi.cs@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mrfatolahi1/Jasem
8
+ Project-URL: Source, https://github.com/mrfatolahi1/Jasem
9
+ Keywords: todo,tasks,cli,time-tracking,ollama,llm,anthropic,openai
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business :: Scheduling
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.8
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # jasem
20
+
21
+ A plain-text **task manager** and **time tracker** for your terminal.
22
+
23
+ You write tasks in natural language; an AI backend extracts the structure
24
+ (title, deadline, priority, tags) and plain Python turns the deadline phrase
25
+ into a real date. Everything is stored in a human-readable Markdown table, so
26
+ you can read, grep, edit, sync, or version-control it however you like.
27
+
28
+ - **Zero dependencies.** A single Python file using only the standard library.
29
+ - **Local by default.** Works fully offline with [Ollama](https://ollama.com).
30
+ - **Bring your own AI.** Point it at any OpenAI-compatible API or at Anthropic
31
+ with two environment variables — no code changes.
32
+ - **Degrades gracefully.** If no model is reachable, your task is still saved
33
+ (dates are parsed with regex; you just don't get auto title/priority/tags).
34
+
35
+ ```text
36
+ $ jasem "pay rent next friday, high priority, finance"
37
+ ✓ added #1: pay rent
38
+ priority=high deadline=2026-06-19 tags=finance
39
+
40
+ $ jasem today
41
+ Due today
42
+ ☐ 2 [medium] 2026-06-15 (today) call dentist #health
43
+
44
+ $ jasem track "1h 30min, code review, work"
45
+ ✓ tracked 1h 30min · code review · today · #work
46
+ ```
47
+
48
+ ## Install
49
+
50
+ jasem needs **Python 3.8+** and nothing else.
51
+
52
+ ### With pipx (recommended)
53
+
54
+ ```sh
55
+ pipx install .
56
+ # or straight from a checkout / git URL once you publish it:
57
+ # pipx install git+https://github.com/your-username/jasem
58
+ ```
59
+
60
+ ### With pip
61
+
62
+ ```sh
63
+ pip install .
64
+ ```
65
+
66
+ Both install a `jasem` command on your `PATH`.
67
+
68
+ ### No install at all
69
+
70
+ It's one file — copy it and run it:
71
+
72
+ ```sh
73
+ cp jasem.py ~/.local/bin/jasem && chmod +x ~/.local/bin/jasem
74
+ ```
75
+
76
+ ## Choosing an AI backend
77
+
78
+ Natural-language parsing is the only step that uses a model. Pick a backend
79
+ with `JASEM_PROVIDER`; the rest is config via environment variables.
80
+
81
+ ### Ollama (default — local, free, private)
82
+
83
+ ```sh
84
+ ollama serve
85
+ ollama pull qwen2.5:3b # or any model you like
86
+ jasem "submit report by friday, work"
87
+ ```
88
+
89
+ No keys, nothing leaves your machine. Override the model with
90
+ `JASEM_MODEL=qwen2.5:7b` and the host with `OLLAMA_HOST`.
91
+
92
+ ### Any OpenAI-compatible API (OpenAI, Groq, OpenRouter, Together, LM Studio, vLLM, …)
93
+
94
+ ```sh
95
+ export JASEM_PROVIDER=openai
96
+ export JASEM_API_KEY=sk-...
97
+ export JASEM_MODEL=gpt-4o-mini # default for this provider
98
+ # For a non-OpenAI host, also set the base URL:
99
+ export JASEM_API_BASE=https://api.groq.com/openai/v1
100
+ ```
101
+
102
+ ### Anthropic (Claude)
103
+
104
+ ```sh
105
+ export JASEM_PROVIDER=anthropic
106
+ export JASEM_API_KEY=sk-ant-...
107
+ export JASEM_MODEL=claude-opus-4-8 # default; e.g. claude-haiku-4-5 for cheaper/faster
108
+ ```
109
+
110
+ Put whichever block you use in your shell profile (`~/.zshrc`, `~/.bashrc`) so
111
+ it's set for every session.
112
+
113
+ ## Commands
114
+
115
+ ```text
116
+ ADD
117
+ jasem "pay rent next friday, high priority, finance"
118
+ jasem add "..." force-add even if the text starts with a command word
119
+
120
+ VIEW (append a category to filter, e.g. jasem list work)
121
+ jasem list (ls) open tasks, soonest deadline first
122
+ jasem today due today
123
+ jasem week due within the next 7 days
124
+ jasem overdue past deadline, not done
125
+ jasem all everything, including completed
126
+ jasem tags list categories in use, with counts
127
+
128
+ UPDATE
129
+ jasem done <id>... mark task(s) complete
130
+ jasem rm <id>... delete task(s)
131
+ jasem set <id> priority high | medium | low
132
+ jasem set <id> deadline next friday | in 3 days | 2026-07-01 | none
133
+ jasem set <id> category work finance (space/comma-separated; "none" clears)
134
+
135
+ TIME TRACKING format: "<time>, <work>[, <date>][, <tag>]"
136
+ jasem track "2h, coding"
137
+ jasem track "30 min, coding, yesterday, work"
138
+ jasem track today's entries, with a daily total
139
+ jasem track week last 7 days, grouped by day
140
+ jasem track all [tag] everything; optional tag filter
141
+
142
+ jasem help full, colorized help
143
+ ```
144
+
145
+ Run `jasem help` for the complete reference.
146
+
147
+ ## Configuration
148
+
149
+ | Env var | Default | Purpose |
150
+ |--------------------|---------------------------------|------------------------------------------------------|
151
+ | `JASEM_PROVIDER` | `ollama` | `ollama` \| `openai` \| `anthropic` |
152
+ | `JASEM_MODEL` | per provider | Model id |
153
+ | `JASEM_API_KEY` | — | Key for openai/anthropic (also reads `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`) |
154
+ | `JASEM_API_BASE` | provider default | Base URL for any OpenAI-compatible / custom endpoint |
155
+ | `OLLAMA_HOST` | `http://localhost:11434` | Ollama daemon address |
156
+ | `JASEM_DIR` | `~/.jasem` | Where data is stored |
157
+ | `JASEM_FILE` | `$JASEM_DIR/tasks.md` | Tasks file |
158
+ | `JASEM_TRACK_FILE` | `$JASEM_DIR/timelog.md` | Time-log file |
159
+
160
+ ## How storage works
161
+
162
+ `tasks.md` and `timelog.md` are ordinary Markdown tables. You can hand-edit
163
+ rows, keep them in a synced folder, or commit them to a private repo — just
164
+ keep the column order intact. jasem reads and rewrites the whole file on each
165
+ write, preserving your edits.
166
+
167
+ ## Writing good tasks
168
+
169
+ Clearer cues parse better:
170
+
171
+ - **deadline:** `tomorrow`, `next friday`, `in 3 days`, `june 20`, `2026-07-01` (avoid `asap`/`soon`)
172
+ - **priority:** say `high` or `low` (default `medium`; avoid `urgent`/`important`)
173
+ - **category:** name it plainly — `work`, `finance`, `university`
174
+ - **pattern:** `"<what> by <when>, <priority>, <category>"`
175
+
176
+ ## License
177
+
178
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ jasem.py,sha256=6BlKEV2X1XKgv09DMJg1r-93udHt9d0ISteoQs21-BQ,33663
2
+ jasem-0.2.0.dist-info/licenses/LICENSE,sha256=fHbTfpkuDq63oFe_LHPHDYRkiuBNmwODJUols9Hbc1Y,1068
3
+ jasem-0.2.0.dist-info/METADATA,sha256=kDv2rxVi3usu5bU66GyO7iL2X9931pKRQGT1oiIrUmA,6589
4
+ jasem-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ jasem-0.2.0.dist-info/entry_points.txt,sha256=T9rVf_hEhlxcfG3sOWk0KFCkwpSoBeCO1torCxyIjlE,36
6
+ jasem-0.2.0.dist-info/top_level.txt,sha256=OfN7emyb2SWKK4rpGTI5g6lHt0gVDbG_RdBNhIL-8oY,6
7
+ jasem-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jasem = jasem:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mrfatolahi1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ jasem
jasem.py ADDED
@@ -0,0 +1,911 @@
1
+ #!/usr/bin/env python3
2
+ """jasem — a plain-text task manager and time tracker.
3
+
4
+ Add tasks in natural language; an AI backend extracts the structure and plain
5
+ Python resolves the deadline to a real date. Storage is a human-readable
6
+ Markdown table. By default everything runs locally (Ollama); you can point it
7
+ at any OpenAI-compatible API or at Anthropic instead. If no AI is reachable,
8
+ tasks are still saved (with regex-based date parsing).
9
+
10
+ Usage:
11
+ jasem <free text> Add a task, e.g. jasem pay rent next friday, high, finance
12
+ jasem add <free text> Same as above (explicit)
13
+ jasem list | ls Show open tasks, soonest deadline first
14
+ jasem today Tasks due today
15
+ jasem overdue Tasks past their deadline (not done)
16
+ jasem week Tasks due within the next 7 days
17
+ jasem done <id> [id...] Mark task(s) complete
18
+ jasem set <id> <field> <value> Change priority, deadline, or category
19
+ jasem rm <id> [id...] Delete task(s)
20
+ jasem all Show everything, including completed
21
+ jasem tags List categories in use, with counts
22
+ jasem track "<time>, <work>[, <date>][, <tag>]"
23
+ Log time spent; date blank = today, tag blank = work
24
+ jasem track [today|week|all] [tag]
25
+ Show the time log grouped by day, with totals
26
+ jasem help This help
27
+
28
+ AI parsing (the only step that uses a model):
29
+ JASEM_PROVIDER ollama (default) | openai | anthropic
30
+ JASEM_MODEL model id (default per provider)
31
+ JASEM_API_KEY API key for openai/anthropic (falls back to
32
+ OPENAI_API_KEY / ANTHROPIC_API_KEY)
33
+ JASEM_API_BASE base URL override (any OpenAI-compatible endpoint;
34
+ default https://api.openai.com/v1 or https://api.anthropic.com)
35
+ OLLAMA_HOST default http://localhost:11434
36
+
37
+ Storage (plain Markdown, hand-editable):
38
+ JASEM_DIR default ~/.jasem
39
+ JASEM_FILE default $JASEM_DIR/tasks.md
40
+ JASEM_TRACK_FILE default $JASEM_DIR/timelog.md
41
+ """
42
+
43
+ import os
44
+ import sys
45
+ import re
46
+ import json
47
+ import calendar
48
+ import datetime as dt
49
+ import urllib.request
50
+ import urllib.error
51
+
52
+ # ---------- AI backend configuration ----------
53
+ PROVIDER = os.environ.get("JASEM_PROVIDER", "ollama").strip().lower()
54
+ OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
55
+ API_BASE = os.environ.get("JASEM_API_BASE", "").rstrip("/")
56
+ API_KEY = (
57
+ os.environ.get("JASEM_API_KEY")
58
+ or os.environ.get("OPENAI_API_KEY")
59
+ or os.environ.get("ANTHROPIC_API_KEY")
60
+ or ""
61
+ )
62
+ _DEFAULT_MODEL = {
63
+ "ollama": "qwen2.5:3b",
64
+ "openai": "gpt-4o-mini",
65
+ "anthropic": "claude-opus-4-8",
66
+ }
67
+ MODEL = os.environ.get("JASEM_MODEL") or _DEFAULT_MODEL.get(PROVIDER, "qwen2.5:3b")
68
+
69
+ # ---------- storage configuration ----------
70
+ JASEM_DIR = os.path.expanduser(os.environ.get("JASEM_DIR", "~/.jasem"))
71
+ TASK_FILE = os.path.expanduser(
72
+ os.environ.get("JASEM_FILE", os.path.join(JASEM_DIR, "tasks.md"))
73
+ )
74
+ TRACK_FILE = os.path.expanduser(
75
+ os.environ.get("JASEM_TRACK_FILE", os.path.join(JASEM_DIR, "timelog.md"))
76
+ )
77
+
78
+ COLS = ["ID", "✓", "Priority", "Deadline", "Task", "Tags", "Created"]
79
+ TRACK_COLS = ["Date", "Time", "Work", "Tag"]
80
+ PRIORITY_RANK = {"high": 0, "medium": 1, "low": 2}
81
+
82
+ # ---------- ANSI colors (only when writing to a real terminal) ----------
83
+ _TTY = sys.stdout.isatty()
84
+ def _c(code, s):
85
+ return f"\033[{code}m{s}\033[0m" if _TTY else s
86
+ RED = lambda s: _c("31", s)
87
+ YELLOW = lambda s: _c("33", s)
88
+ GREEN = lambda s: _c("32", s)
89
+ DIM = lambda s: _c("2", s)
90
+ BOLD = lambda s: _c("1", s)
91
+ CYAN = lambda s: _c("36", s)
92
+
93
+
94
+ # ===================== date resolution (pure Python) =====================
95
+ WEEKDAYS = {n.lower(): i for i, n in enumerate(calendar.day_name)}
96
+ WEEKDAYS.update({n.lower(): i for i, n in enumerate(calendar.day_abbr)})
97
+ MONTHS = {n.lower(): i for i, n in enumerate(calendar.month_name) if n}
98
+ MONTHS.update({n.lower(): i for i, n in enumerate(calendar.month_abbr) if n})
99
+
100
+ NO_DATE = {"", "none", "no deadline", "no date", "n/a", "na", "null", "someday", "-"}
101
+
102
+ # Words that mean "empty this field" when editing a task.
103
+ CLEAR_WORDS = {"none", "no", "clear", "remove", "-", "n/a", "na", "null", ""}
104
+
105
+
106
+ def _add_months(d, n):
107
+ m = d.month - 1 + n
108
+ y = d.year + m // 12
109
+ m = m % 12 + 1
110
+ return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
111
+
112
+
113
+ def _next_weekday(today, target, include_today=False):
114
+ days = (target - today.weekday()) % 7
115
+ if days == 0 and not include_today:
116
+ days = 7
117
+ return today + dt.timedelta(days=days)
118
+
119
+
120
+ def resolve_date(phrase, today, llm_date=""):
121
+ """Turn a temporal phrase into YYYY-MM-DD, or '' for no deadline."""
122
+ def fallback():
123
+ d = (llm_date or "").strip()
124
+ return d if re.fullmatch(r"\d{4}-\d{2}-\d{2}", d) else ""
125
+
126
+ p = (phrase or "").strip().lower()
127
+ if p in NO_DATE:
128
+ return fallback()
129
+
130
+ # explicit ISO date anywhere in the phrase
131
+ m = re.search(r"\b(\d{4})-(\d{1,2})-(\d{1,2})\b", p)
132
+ if m:
133
+ try:
134
+ return dt.date(int(m[1]), int(m[2]), int(m[3])).isoformat()
135
+ except ValueError:
136
+ pass
137
+
138
+ # "in N day(s)/week(s)/month(s)"
139
+ m = re.search(r"\bin\s+(\d+)\s+(day|week|month)s?\b", p)
140
+ if m:
141
+ n, unit = int(m[1]), m[2]
142
+ if unit == "day":
143
+ return (today + dt.timedelta(days=n)).isoformat()
144
+ if unit == "week":
145
+ return (today + dt.timedelta(days=7 * n)).isoformat()
146
+ return _add_months(today, n).isoformat()
147
+
148
+ # "next <weekday>" -> always strictly in the future
149
+ m = re.search(r"\bnext\s+(\w+)", p)
150
+ if m and m[1] in WEEKDAYS:
151
+ return _next_weekday(today, WEEKDAYS[m[1]], include_today=False).isoformat()
152
+
153
+ # relative day words (match whether input is a clean phrase or a full sentence)
154
+ if re.search(r"\btomorrow\b", p):
155
+ return (today + dt.timedelta(days=1)).isoformat()
156
+ if re.search(r"\byesterday\b", p):
157
+ return (today - dt.timedelta(days=1)).isoformat()
158
+ if re.search(r"\b(today|tonight|now)\b", p):
159
+ return today.isoformat()
160
+ if re.search(r"\bnext\s+week\b", p):
161
+ return (today + dt.timedelta(days=7)).isoformat()
162
+ if re.search(r"\bnext\s+month\b", p):
163
+ return _add_months(today, 1).isoformat()
164
+ if re.search(r"\b(eow|end of week|this week)\b", p):
165
+ return _next_weekday(today, 4, include_today=True).isoformat() # Friday
166
+
167
+ # "this <weekday>" or a bare weekday -> next occurrence (today counts)
168
+ for name, idx in WEEKDAYS.items():
169
+ if re.search(rf"\b{re.escape(name)}\b", p):
170
+ return _next_weekday(today, idx, include_today=True).isoformat()
171
+
172
+ # "<month> <day>[ , year]"
173
+ m = re.search(r"([a-z]+)\s+(\d{1,2})(?:,?\s*(\d{4}))?", p)
174
+ if m and m[1] in MONTHS:
175
+ year = int(m[3]) if m[3] else today.year
176
+ try:
177
+ d = dt.date(year, MONTHS[m[1]], int(m[2]))
178
+ if not m[3] and d < today:
179
+ d = dt.date(year + 1, MONTHS[m[1]], int(m[2]))
180
+ return d.isoformat()
181
+ except ValueError:
182
+ pass
183
+
184
+ # "<day> <month>[ , year]"
185
+ m = re.search(r"(\d{1,2})\s+([a-z]+)(?:,?\s*(\d{4}))?", p)
186
+ if m and m[2] in MONTHS:
187
+ year = int(m[3]) if m[3] else today.year
188
+ try:
189
+ d = dt.date(year, MONTHS[m[2]], int(m[1]))
190
+ if not m[3] and d < today:
191
+ d = dt.date(year + 1, MONTHS[m[2]], int(m[1]))
192
+ return d.isoformat()
193
+ except ValueError:
194
+ pass
195
+
196
+ return fallback()
197
+
198
+
199
+ # ===================== AI parsing (pluggable backend) =====================
200
+ SCHEMA = {
201
+ "type": "object",
202
+ "properties": {
203
+ "title": {"type": "string"},
204
+ "deadline_phrase": {"type": "string"},
205
+ "deadline_date": {"type": "string"},
206
+ "priority": {"type": "string", "enum": ["low", "medium", "high"]},
207
+ "tags": {"type": "array", "items": {"type": "string"}},
208
+ },
209
+ "required": ["title", "deadline_phrase", "deadline_date", "priority", "tags"],
210
+ }
211
+
212
+ _JSON_SYS = (
213
+ "You output only a single JSON object with the requested fields. "
214
+ "No prose, no markdown, no code fences."
215
+ )
216
+
217
+
218
+ def _build_prompt(text, today):
219
+ rules = (
220
+ "Extract structured fields from the task description below.\n"
221
+ "- title: short imperative summary, WITHOUT any date/priority/tag words.\n"
222
+ "- deadline_phrase: the exact temporal words as written, for example "
223
+ "next thursday / tomorrow / june 20 / in 3 days; empty string if none.\n"
224
+ "- deadline_date: your best YYYY-MM-DD guess for the deadline, empty string if none.\n"
225
+ "- priority: low, medium, or high (default medium).\n"
226
+ "- tags: short topic words mentioned such as work, finance, personal; "
227
+ "empty list if none.\n"
228
+ "Always capture any time words (tomorrow, friday, next week, by june 20) "
229
+ "in deadline_phrase, even when they follow words like 'by' or 'due'.\n"
230
+ )
231
+ examples = (
232
+ 'Example. Task: "review Ali PR by tomorrow, work" -> '
233
+ '{"title": "review Ali PR", "deadline_phrase": "tomorrow", '
234
+ '"deadline_date": "", "priority": "medium", "tags": ["work"]}\n'
235
+ 'Example. Task: "pay rent next friday high priority" -> '
236
+ '{"title": "pay rent", "deadline_phrase": "next friday", '
237
+ '"deadline_date": "", "priority": "high", "tags": []}\n'
238
+ )
239
+ return (
240
+ f"Today is {today.isoformat()} ({today.strftime('%A')}).\n"
241
+ f"{rules}\n{examples}\nTask: {text}"
242
+ )
243
+
244
+
245
+ def _http_json(url, payload, headers, timeout=120):
246
+ req = urllib.request.Request(
247
+ url,
248
+ data=json.dumps(payload).encode(),
249
+ headers={"Content-Type": "application/json", **headers},
250
+ )
251
+ with urllib.request.urlopen(req, timeout=timeout) as r:
252
+ return json.load(r)
253
+
254
+
255
+ def _extract_json(text):
256
+ """Pull a JSON object out of a model reply (tolerates code fences / prose)."""
257
+ text = (text or "").strip()
258
+ m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
259
+ if m:
260
+ text = m.group(1)
261
+ try:
262
+ return json.loads(text)
263
+ except ValueError:
264
+ start = text.find("{")
265
+ if start < 0:
266
+ raise
267
+ depth = 0
268
+ for i in range(start, len(text)):
269
+ if text[i] == "{":
270
+ depth += 1
271
+ elif text[i] == "}":
272
+ depth -= 1
273
+ if depth == 0:
274
+ return json.loads(text[start:i + 1])
275
+ raise
276
+
277
+
278
+ def _parse_ollama(prompt):
279
+ payload = {
280
+ "model": MODEL,
281
+ "messages": [{"role": "user", "content": prompt}],
282
+ "stream": False,
283
+ "format": SCHEMA, # Ollama constrains output to this schema
284
+ "options": {"temperature": 0},
285
+ }
286
+ resp = _http_json(OLLAMA_HOST + "/api/chat", payload, {})
287
+ return json.loads(resp["message"]["content"])
288
+
289
+
290
+ def _parse_openai(prompt):
291
+ base = API_BASE or "https://api.openai.com/v1"
292
+ headers = {"Authorization": "Bearer " + API_KEY} if API_KEY else {}
293
+ body = {
294
+ "model": MODEL,
295
+ "messages": [
296
+ {"role": "system", "content": _JSON_SYS},
297
+ {"role": "user", "content": prompt},
298
+ ],
299
+ "temperature": 0,
300
+ "response_format": {"type": "json_object"},
301
+ }
302
+ try:
303
+ resp = _http_json(base + "/chat/completions", body, headers)
304
+ except urllib.error.HTTPError as e:
305
+ if e.code == 400: # some compatible servers reject response_format
306
+ body.pop("response_format", None)
307
+ resp = _http_json(base + "/chat/completions", body, headers)
308
+ else:
309
+ raise
310
+ return _extract_json(resp["choices"][0]["message"]["content"])
311
+
312
+
313
+ def _parse_anthropic(prompt):
314
+ base = API_BASE or "https://api.anthropic.com"
315
+ headers = {"x-api-key": API_KEY, "anthropic-version": "2023-06-01"}
316
+ body = {
317
+ "model": MODEL,
318
+ "max_tokens": 1024,
319
+ # Force a tool call so the structured fields come back validated.
320
+ "tools": [{
321
+ "name": "record_task",
322
+ "description": "Record the structured fields extracted from the task.",
323
+ "input_schema": SCHEMA,
324
+ }],
325
+ "tool_choice": {"type": "tool", "name": "record_task"},
326
+ "messages": [{"role": "user", "content": prompt}],
327
+ }
328
+ resp = _http_json(base + "/v1/messages", body, headers)
329
+ for block in resp.get("content", []):
330
+ if block.get("type") == "tool_use":
331
+ return block.get("input", {})
332
+ raise ValueError("Anthropic response contained no tool_use block")
333
+
334
+
335
+ PROVIDERS = {
336
+ "ollama": _parse_ollama,
337
+ "openai": _parse_openai,
338
+ "anthropic": _parse_anthropic,
339
+ }
340
+
341
+
342
+ def llm_parse(text, today):
343
+ fn = PROVIDERS.get(PROVIDER)
344
+ if fn is None:
345
+ raise ValueError(
346
+ f"unknown JASEM_PROVIDER={PROVIDER!r}; use ollama, openai, or anthropic"
347
+ )
348
+ return fn(_build_prompt(text, today))
349
+
350
+
351
+ def parse_task(text):
352
+ today = dt.date.today()
353
+ try:
354
+ d = llm_parse(text, today)
355
+ title = (d.get("title") or text).strip()
356
+ deadline = resolve_date(
357
+ d.get("deadline_phrase", ""), today, d.get("deadline_date", "")
358
+ )
359
+ # Models sometimes miss the deadline phrase; deterministically rescan
360
+ # the original text as a fallback before giving up.
361
+ if not deadline:
362
+ deadline = resolve_date(text, today)
363
+ priority = d.get("priority", "medium")
364
+ if priority not in PRIORITY_RANK:
365
+ priority = "medium"
366
+ tags = [str(t).strip() for t in d.get("tags", []) if str(t).strip()]
367
+ except (urllib.error.URLError, urllib.error.HTTPError) as e:
368
+ sys.stderr.write(
369
+ RED(f"! Could not reach the {PROVIDER} backend ({e}); storing raw text.\n")
370
+ + DIM(" Check JASEM_PROVIDER / JASEM_MODEL / JASEM_API_KEY, "
371
+ "or that 'ollama serve' is running.\n")
372
+ )
373
+ title, deadline, priority, tags = _local_parse(text, today)
374
+ except Exception as e:
375
+ sys.stderr.write(RED(f"! Parse error ({e}); storing raw text.\n"))
376
+ title, deadline, priority, tags = _local_parse(text, today)
377
+ return {
378
+ "done": False,
379
+ "priority": priority,
380
+ "deadline": deadline,
381
+ "title": _clean(title),
382
+ "tags": ", ".join(tags),
383
+ "created": today.isoformat(),
384
+ }
385
+
386
+
387
+ def _local_parse(text, today):
388
+ """No-AI fallback: keep the raw text, still resolve any date words in it."""
389
+ return text.strip(), resolve_date(text, today), "medium", []
390
+
391
+
392
+ def _clean(s):
393
+ # Pipes would break the Markdown table; newlines too.
394
+ return s.replace("|", "/").replace("\n", " ").strip()
395
+
396
+
397
+ # ===================== Markdown storage =====================
398
+ PREAMBLE = (
399
+ "# Tasks\n\n"
400
+ "_Managed by the `jasem` CLI. You can hand-edit rows, but keep the column order._\n\n"
401
+ )
402
+
403
+
404
+ def load():
405
+ if not os.path.exists(TASK_FILE):
406
+ return []
407
+ tasks = []
408
+ with open(TASK_FILE, encoding="utf-8") as f:
409
+ for line in f:
410
+ line = line.strip()
411
+ if not line.startswith("|"):
412
+ continue
413
+ cells = [c.strip() for c in line.strip("|").split("|")]
414
+ if len(cells) < 7:
415
+ continue
416
+ if cells[0] == "ID" or set(cells[0]) <= set("-: "):
417
+ continue # header or separator row
418
+ try:
419
+ tid = int(cells[0])
420
+ except ValueError:
421
+ continue
422
+ tasks.append({
423
+ "id": tid,
424
+ "done": cells[1] == "☑",
425
+ "priority": cells[2] or "medium",
426
+ "deadline": cells[3] if cells[3] != "-" else "",
427
+ "title": cells[4],
428
+ "tags": cells[5] if cells[5] != "-" else "",
429
+ "created": cells[6],
430
+ })
431
+ return tasks
432
+
433
+
434
+ def save(tasks):
435
+ rows = [COLS, ["-" * len(c) for c in COLS]]
436
+ for t in tasks:
437
+ rows.append([
438
+ str(t["id"]),
439
+ "☑" if t["done"] else "☐",
440
+ t["priority"],
441
+ t["deadline"] or "-",
442
+ t["title"],
443
+ t["tags"] or "-",
444
+ t["created"],
445
+ ])
446
+ widths = [max(len(r[i]) for r in rows) for i in range(len(COLS))]
447
+ lines = []
448
+ for ri, r in enumerate(rows):
449
+ if ri == 1: # separator row uses dashes padded with dashes
450
+ lines.append("| " + " | ".join("-" * widths[i] for i in range(len(COLS))) + " |")
451
+ else:
452
+ lines.append("| " + " | ".join(r[i].ljust(widths[i]) for i in range(len(COLS))) + " |")
453
+ os.makedirs(os.path.dirname(TASK_FILE), exist_ok=True)
454
+ with open(TASK_FILE, "w", encoding="utf-8") as f:
455
+ f.write(PREAMBLE + "\n".join(lines) + "\n")
456
+
457
+
458
+ def next_id(tasks):
459
+ return max((t["id"] for t in tasks), default=0) + 1
460
+
461
+
462
+ # ===================== display =====================
463
+ def _sort_key(t):
464
+ # open first, then by deadline (none last), then priority
465
+ return (
466
+ t["done"],
467
+ t["deadline"] or "9999-99-99",
468
+ PRIORITY_RANK.get(t["priority"], 1),
469
+ )
470
+
471
+
472
+ def show(tasks, header):
473
+ today = dt.date.today().isoformat()
474
+ if not tasks:
475
+ print(DIM(" (nothing here)"))
476
+ return
477
+ print(BOLD(header))
478
+ for t in sorted(tasks, key=_sort_key):
479
+ mark = GREEN("☑") if t["done"] else "☐"
480
+ dl = t["deadline"] or "—"
481
+ if not t["done"] and t["deadline"]:
482
+ if t["deadline"] < today:
483
+ dl = RED(dl + " (overdue)")
484
+ elif t["deadline"] == today:
485
+ dl = YELLOW(dl + " (today)")
486
+ prio = t["priority"]
487
+ if prio == "high":
488
+ prio = BOLD(RED(prio))
489
+ elif prio == "low":
490
+ prio = DIM(prio)
491
+ tags = DIM(f"#{t['tags'].replace(', ', ' #')}") if t["tags"] else ""
492
+ title = DIM(t["title"]) if t["done"] else t["title"]
493
+ print(f" {mark} {CYAN(str(t['id']).rjust(3))} [{prio}] {dl} {title} {tags}")
494
+
495
+
496
+ # ===================== commands =====================
497
+ def cmd_add(text):
498
+ tasks = load()
499
+ t = parse_task(text)
500
+ t["id"] = next_id(tasks)
501
+ tasks.append(t)
502
+ save(tasks)
503
+ dl = t["deadline"] or "no deadline"
504
+ print(GREEN("✓ added"), f"#{t['id']}:", BOLD(t["title"]))
505
+ print(DIM(f" priority={t['priority']} deadline={dl}"
506
+ + (f" tags={t['tags']}" if t["tags"] else "")))
507
+
508
+
509
+ def cmd_done(ids):
510
+ tasks = load()
511
+ hit = [t for t in tasks if t["id"] in ids]
512
+ for t in hit:
513
+ t["done"] = True
514
+ save(tasks)
515
+ if hit:
516
+ print(GREEN("✓ completed:"), ", ".join(f"#{t['id']} {t['title']}" for t in hit))
517
+ else:
518
+ print(RED("no matching id(s)"))
519
+
520
+
521
+ def cmd_rm(ids):
522
+ tasks = load()
523
+ keep = [t for t in tasks if t["id"] not in ids]
524
+ removed = len(tasks) - len(keep)
525
+ save(keep)
526
+ print(GREEN(f"✓ removed {removed} task(s)") if removed else RED("no matching id(s)"))
527
+
528
+
529
+ # Field name -> accepted aliases, for `jasem set <id> <field> <value>`.
530
+ SET_FIELDS = {
531
+ "priority": {"priority", "prio", "p"},
532
+ "deadline": {"deadline", "due", "date", "d"},
533
+ "category": {"category", "categories", "tag", "tags", "c"},
534
+ }
535
+
536
+
537
+ def _resolve_field(name):
538
+ name = name.lower()
539
+ for field, aliases in SET_FIELDS.items():
540
+ if name in aliases:
541
+ return field
542
+ return None
543
+
544
+
545
+ def cmd_set(args):
546
+ if len(args) < 3:
547
+ print(RED("usage: jasem set <id> <priority|deadline|category> <value>"))
548
+ print(DIM(" e.g. jasem set 3 priority high"))
549
+ print(DIM(" jasem set 3 deadline next friday"))
550
+ print(DIM(" jasem set 3 category work finance (none clears it)"))
551
+ return
552
+
553
+ try:
554
+ tid = int(args[0])
555
+ except ValueError:
556
+ print(RED(f"not a valid id: {args[0]}"))
557
+ return
558
+
559
+ field = _resolve_field(args[1])
560
+ if not field:
561
+ print(RED(f"unknown field: {args[1]}"))
562
+ print(DIM(" fields: priority · deadline · category"))
563
+ return
564
+ value = " ".join(args[2:]).strip()
565
+
566
+ tasks = load()
567
+ t = next((x for x in tasks if x["id"] == tid), None)
568
+ if not t:
569
+ print(RED(f"no task with id #{tid}"))
570
+ return
571
+
572
+ if field == "priority":
573
+ v = value.lower()
574
+ if v not in PRIORITY_RANK:
575
+ print(RED(f"priority must be one of: {', '.join(PRIORITY_RANK)}"))
576
+ return
577
+ t["priority"] = v
578
+ msg = f"priority → {v}"
579
+ elif field == "deadline":
580
+ if value.lower() in CLEAR_WORDS:
581
+ t["deadline"] = ""
582
+ msg = "deadline cleared"
583
+ else:
584
+ resolved = resolve_date(value, dt.date.today())
585
+ if not resolved:
586
+ print(RED(f"could not understand deadline: {value!r}"))
587
+ print(DIM(" try: tomorrow · next friday · in 3 days · "
588
+ "june 20 · 2026-07-01 · none"))
589
+ return
590
+ t["deadline"] = resolved
591
+ msg = f"deadline → {resolved}"
592
+ else: # category
593
+ if value.lower() in CLEAR_WORDS:
594
+ t["tags"] = ""
595
+ msg = "category cleared"
596
+ else:
597
+ parts = [p for p in (s.strip() for s in re.split(r"[,\s]+", value)) if p]
598
+ t["tags"] = _clean(", ".join(parts))
599
+ msg = f"category → {t['tags']}"
600
+
601
+ save(tasks)
602
+ print(GREEN(f"✓ #{tid} updated:"), msg)
603
+ print(DIM(f" {t['title']}"))
604
+
605
+
606
+ def _tags_of(t):
607
+ """Lower-cased list of categories/tags on a task."""
608
+ return [x.strip().lower() for x in t["tags"].split(",") if x.strip()]
609
+
610
+
611
+ def cmd_tags():
612
+ counts = {}
613
+ for t in load():
614
+ if t["done"]:
615
+ continue
616
+ for tag in _tags_of(t):
617
+ counts[tag] = counts.get(tag, 0) + 1
618
+ if not counts:
619
+ print(DIM(" (no categories yet)"))
620
+ return
621
+ print(BOLD("Categories — open tasks"))
622
+ for tag, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])):
623
+ print(f" {n:>3} {CYAN('#' + tag)}")
624
+
625
+
626
+ # ===================== time tracking =====================
627
+ TRACK_PREAMBLE = (
628
+ "# Time log\n\n"
629
+ "_Managed by the `jasem track` CLI. Hand-edit rows freely, but keep the column order._\n\n"
630
+ )
631
+
632
+ # Longest unit spellings first so e.g. "min" wins over a bare "m".
633
+ _DUR_RE = re.compile(
634
+ r"(\d+(?:\.\d+)?)\s*(hours|hour|hrs|hr|h|minutes|minute|mins|min|m)\b"
635
+ )
636
+
637
+
638
+ def parse_duration(text):
639
+ """Best-effort minutes from free text like '2h', '30 min', '1h 30min'. 0 if unreadable."""
640
+ s = (text or "").lower()
641
+ total = 0.0
642
+ for m in _DUR_RE.finditer(s):
643
+ total += float(m[1]) * 60 if m[2][0] == "h" else float(m[1])
644
+ if total == 0: # a bare number means minutes
645
+ m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*", s)
646
+ if m:
647
+ total = float(m[1])
648
+ return int(round(total))
649
+
650
+
651
+ def fmt_duration(mins):
652
+ h, m = divmod(int(mins), 60)
653
+ if h and m:
654
+ return f"{h}h {m}min"
655
+ return f"{h}h" if h else f"{m}min"
656
+
657
+
658
+ def track_load():
659
+ if not os.path.exists(TRACK_FILE):
660
+ return []
661
+ out = []
662
+ with open(TRACK_FILE, encoding="utf-8") as f:
663
+ for line in f:
664
+ line = line.strip()
665
+ if not line.startswith("|"):
666
+ continue
667
+ cells = [c.strip() for c in line.strip("|").split("|")]
668
+ if len(cells) < 4:
669
+ continue
670
+ if cells[0] == "Date" or set(cells[0]) <= set("-: "):
671
+ continue # header or separator row
672
+ out.append({"date": cells[0], "time": cells[1],
673
+ "work": cells[2], "tag": cells[3]})
674
+ return out
675
+
676
+
677
+ def track_save(entries):
678
+ rows = [TRACK_COLS, ["-" * len(c) for c in TRACK_COLS]]
679
+ for e in entries:
680
+ rows.append([e["date"], e["time"], e["work"], e["tag"] or "work"])
681
+ widths = [max(len(r[i]) for r in rows) for i in range(len(TRACK_COLS))]
682
+ lines = []
683
+ for ri, r in enumerate(rows):
684
+ if ri == 1:
685
+ lines.append("| " + " | ".join("-" * widths[i] for i in range(len(TRACK_COLS))) + " |")
686
+ else:
687
+ lines.append("| " + " | ".join(r[i].ljust(widths[i]) for i in range(len(TRACK_COLS))) + " |")
688
+ os.makedirs(os.path.dirname(TRACK_FILE), exist_ok=True)
689
+ with open(TRACK_FILE, "w", encoding="utf-8") as f:
690
+ f.write(TRACK_PREAMBLE + "\n".join(lines) + "\n")
691
+
692
+
693
+ def track_add(text):
694
+ """Parse '<time>, <work>[, <date>][, <tag>]' and append a log entry."""
695
+ parts = [p.strip() for p in text.split(",") if p.strip()]
696
+ if len(parts) < 2:
697
+ print(RED('usage: jasem track "<time>, <work>[, <date>][, <tag>]"'))
698
+ print(DIM(' e.g. jasem track "2h, coding"'))
699
+ print(DIM(' jasem track "30 min, coding, yesterday, work"'))
700
+ return
701
+ today = dt.date.today()
702
+ time_text, work, extra = parts[0], parts[1], parts[2:]
703
+ # Of the trailing fields, the one that reads as a date is the date; the
704
+ # other is the tag — so order between them doesn't matter.
705
+ date_s, tag = "", ""
706
+ for item in extra:
707
+ resolved = resolve_date(item, today)
708
+ if resolved and not date_s:
709
+ date_s = resolved
710
+ elif not tag:
711
+ tag = item
712
+ date_s = date_s or today.isoformat()
713
+ tag = tag or "work"
714
+
715
+ entries = track_load()
716
+ entries.append({"date": date_s, "time": _clean(time_text),
717
+ "work": _clean(work), "tag": _clean(tag)})
718
+ track_save(entries)
719
+
720
+ mins = parse_duration(time_text)
721
+ when = "today" if date_s == today.isoformat() else date_s
722
+ print(GREEN("✓ tracked"), BOLD(time_text), DIM("·"), work,
723
+ DIM(f"· {when} · #{tag}"))
724
+ if mins == 0:
725
+ sys.stderr.write(YELLOW(
726
+ f" (couldn't read a duration from {time_text!r}; "
727
+ "stored as-is, won't count toward totals)\n"))
728
+
729
+
730
+ def track_view(period, tag_filter):
731
+ entries = track_load()
732
+ today = dt.date.today()
733
+ today_s = today.isoformat()
734
+ if period == "all":
735
+ sel, header = entries, "Time log — all"
736
+ elif period == "week":
737
+ since = (today - dt.timedelta(days=6)).isoformat()
738
+ sel = [e for e in entries if e["date"] >= since]
739
+ header = "Time log — last 7 days"
740
+ else: # today
741
+ sel = [e for e in entries if e["date"] == today_s]
742
+ header = "Time log — today"
743
+ if tag_filter:
744
+ sel = [e for e in sel if e["tag"].lower() == tag_filter]
745
+ header += " · #" + tag_filter
746
+
747
+ if not sel:
748
+ print(BOLD(header))
749
+ print(DIM(" (nothing tracked)"))
750
+ return
751
+
752
+ by_date = {}
753
+ for e in sel:
754
+ by_date.setdefault(e["date"], []).append(e)
755
+ grand = 0
756
+ print(BOLD(header))
757
+ for date in sorted(by_date, reverse=True):
758
+ day = by_date[date]
759
+ total = sum(parse_duration(e["time"]) for e in day)
760
+ grand += total
761
+ label = date + (" (today)" if date == today_s else "")
762
+ print("\n " + BOLD(CYAN(label)) + DIM(" — ") + BOLD(fmt_duration(total)))
763
+ for e in day:
764
+ tg = DIM(f"#{e['tag']}") if e["tag"] else ""
765
+ print(f" {e['time'].rjust(9)} {e['work']} {tg}")
766
+ if len(by_date) > 1:
767
+ print("\n " + DIM("total ") + BOLD(fmt_duration(grand)))
768
+
769
+
770
+ def cmd_track(args):
771
+ text = " ".join(args).strip()
772
+ words = text.split()
773
+ first = words[0].lower() if words else ""
774
+ # A new entry has comma-separated fields. A bare duration with no comma is
775
+ # a half-typed entry (missing the work) -> route to add for the usage hint,
776
+ # not to the viewer where it would be mistaken for a tag filter.
777
+ if "," in text or (first not in ("today", "week", "all") and parse_duration(text)):
778
+ track_add(text)
779
+ return
780
+ period = "today"
781
+ if words and words[0] in ("today", "week", "all"):
782
+ period = words.pop(0)
783
+ tag_filter = words[0].lower() if words else None
784
+ track_view(period, tag_filter)
785
+
786
+
787
+ def usage():
788
+ H = lambda s: "\n" + BOLD(CYAN(s)) # section header
789
+ cmd = lambda s: GREEN(s) # command
790
+ ex = lambda s: YELLOW(s) # example / value
791
+ d = DIM # de-emphasised note
792
+
793
+ def row(left, right): # aligned key -> value row
794
+ return " " + GREEN(left.ljust(24)) + right
795
+
796
+ out = [
797
+ BOLD("jasem") + d(" — plain-text task manager + time tracker, pluggable AI parsing"),
798
+
799
+ H("ADD") + d(" wrap the task in quotes; deadline, priority & tags auto-detected"),
800
+ " " + cmd('jasem "') + ex("pay rent next friday, high priority, finance") + cmd('"'),
801
+ " " + cmd('jasem "') + ex("review Ali PR by tomorrow, work") + cmd('"'),
802
+ " " + cmd('jasem add "') + ex("…") + cmd('"') + d(" force-add even if text starts with a command word"),
803
+ " " + d("quotes keep shell chars (& ! * ( )) and words like done/list literal"),
804
+
805
+ H("VIEW") + d(" append a category to filter, e.g. ") + cmd("jasem list work"),
806
+ row("jasem list (ls)", "open tasks, soonest deadline first"),
807
+ row("jasem today", "due today"),
808
+ row("jasem week", "due within the next 7 days"),
809
+ row("jasem overdue", "past deadline, not done " + RED("(red)")),
810
+ row("jasem all", "everything, including completed"),
811
+ row("jasem tags", "list categories in use, with counts"),
812
+
813
+ H("UPDATE"),
814
+ row("jasem done <id>…", "mark task(s) complete"),
815
+ row("jasem rm <id>…", "delete task(s) permanently"),
816
+ row("jasem set <id> priority", ex("high · medium · low")),
817
+ row("jasem set <id> deadline", ex("next friday · in 3 days · 2026-07-01 · none")),
818
+ row("jasem set <id> category", ex("work finance")
819
+ + d(" (space/comma-separated; ") + ex("none") + d(" clears)")),
820
+
821
+ H("TIME TRACKING") + d(" log durations as plain text; date blank = today, tag blank = work"),
822
+ " " + cmd('jasem track "') + ex("2h, coding") + cmd('"'),
823
+ " " + cmd('jasem track "') + ex("30 min, coding, yesterday, work") + cmd('"'),
824
+ " " + d("format: ") + ex('"<time>, <work>[, <date>][, <tag>]"'),
825
+ row("jasem track", "today's entries, with a daily total"),
826
+ row("jasem track week", "last 7 days, grouped by day"),
827
+ row("jasem track all", "everything; append a tag to filter, e.g. " + cmd("jasem track week work")),
828
+
829
+ H("AI PARSING") + d(" the only step that calls a model; pick a backend with JASEM_PROVIDER"),
830
+ row(" ollama (default)", d("local, no key — run ") + cmd("ollama serve") + d(" + a small model")),
831
+ row(" openai", d("any OpenAI-compatible API — set ") + ex("JASEM_API_KEY")
832
+ + d(" (+ ") + ex("JASEM_API_BASE") + d(" for non-OpenAI hosts)")),
833
+ row(" anthropic", d("Claude — set ") + ex("JASEM_PROVIDER=anthropic") + d(" + ") + ex("JASEM_API_KEY")),
834
+ " " + d("if the backend is unreachable, the task is still saved (regex dates, no tags)."),
835
+
836
+ H("FILES & CONFIG"),
837
+ row(" provider", ex(PROVIDER) + d(" (JASEM_PROVIDER: ollama · openai · anthropic)")),
838
+ row(" model", ex(MODEL) + d(" (JASEM_MODEL)")),
839
+ row(" tasks", TASK_FILE + d(" (plain Markdown, hand-editable)")),
840
+ row(" time log", TRACK_FILE + d(" (plain Markdown)")),
841
+ row(" env vars", d("JASEM_DIR · JASEM_FILE · JASEM_TRACK_FILE · JASEM_PROVIDER · "
842
+ "JASEM_MODEL · JASEM_API_KEY · JASEM_API_BASE · OLLAMA_HOST")),
843
+ ]
844
+ print("\n".join(out))
845
+
846
+
847
+ def main(argv):
848
+ if not argv or argv[0] in ("help", "-h", "--help"):
849
+ usage()
850
+ return
851
+ cmd = argv[0]
852
+ rest = argv[1:]
853
+ today = dt.date.today()
854
+ today_s = today.isoformat()
855
+ wk_s = (today + dt.timedelta(days=7)).isoformat()
856
+
857
+ VIEWS = {
858
+ "list": (lambda t: not t["done"], "Open tasks"),
859
+ "ls": (lambda t: not t["done"], "Open tasks"),
860
+ "all": (lambda t: True, "All tasks"),
861
+ "today": (lambda t: not t["done"] and t["deadline"] == today_s, "Due today"),
862
+ "week": (lambda t: not t["done"] and t["deadline"]
863
+ and today_s <= t["deadline"] <= wk_s, "Due within 7 days"),
864
+ "overdue": (lambda t: not t["done"] and t["deadline"]
865
+ and t["deadline"] < today_s, "Overdue"),
866
+ }
867
+
868
+ if cmd == "tags":
869
+ cmd_tags()
870
+ elif cmd in VIEWS:
871
+ pred, header = VIEWS[cmd]
872
+ tasks = [t for t in load() if pred(t)]
873
+ if rest: # optional category filter(s): jasem list work [urgent]
874
+ cats = [c.strip().lower() for c in rest]
875
+ tasks = [t for t in tasks if all(c in _tags_of(t) for c in cats)]
876
+ header += " · " + " ".join("#" + c for c in cats)
877
+ show(tasks, header)
878
+ elif cmd in ("done", "rm"):
879
+ ids = set()
880
+ for a in rest:
881
+ try:
882
+ ids.add(int(a))
883
+ except ValueError:
884
+ pass
885
+ if not ids:
886
+ print(RED(f"usage: jasem {cmd} <id> [id...]"))
887
+ print(DIM(f' (to add a task that starts with "{cmd}", quote it: '
888
+ f'jasem add "{cmd} ...")'))
889
+ return
890
+ (cmd_done if cmd == "done" else cmd_rm)(ids)
891
+ elif cmd in ("set", "edit"):
892
+ cmd_set(rest)
893
+ elif cmd == "track":
894
+ cmd_track(rest)
895
+ elif cmd == "add":
896
+ if len(argv) < 2:
897
+ print(RED("usage: jasem add <description>"))
898
+ return
899
+ cmd_add(" ".join(argv[1:]))
900
+ else:
901
+ # no recognized subcommand -> treat the whole input as a new task
902
+ cmd_add(" ".join(argv))
903
+
904
+
905
+ def cli():
906
+ """Console-script entry point (see pyproject.toml [project.scripts])."""
907
+ main(sys.argv[1:])
908
+
909
+
910
+ if __name__ == "__main__":
911
+ cli()