taskai-cli 1.5.4__tar.gz → 1.6.2__tar.gz

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.
Files changed (34) hide show
  1. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/DEVLOG.md +116 -0
  2. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/DEVPLAN.md +165 -52
  3. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/PKG-INFO +6 -5
  4. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/README.md +4 -3
  5. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/pyproject.toml +2 -2
  6. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/cli.py +8 -4
  7. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/help_menu.py +2 -0
  8. taskai_cli-1.6.2/taskai/llm_models.py +57 -0
  9. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/models.py +2 -3
  10. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/services/ai.py +52 -29
  11. taskai_cli-1.6.2/taskai/services/user_setup.py +75 -0
  12. taskai_cli-1.5.4/taskai/services/user_setup.py +0 -63
  13. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/.github/workflows/publish-to-pypi.yml +0 -0
  14. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/.gitignore +0 -0
  15. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/.readthedocs.yaml +0 -0
  16. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/docs/conf.py +0 -0
  17. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/docs/index.rst +0 -0
  18. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/docs/requirements.txt +0 -0
  19. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/scripts/bump_version.py +0 -0
  20. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/browser.py +0 -0
  21. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/config.py +0 -0
  22. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/json_dir_database.py +0 -0
  23. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/services/pomodoro.py +0 -0
  24. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/services/repair_database.py +0 -0
  25. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/static/canvas.js +0 -0
  26. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/static/console.js +0 -0
  27. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/static/editpanel.js +0 -0
  28. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/static/index.html +0 -0
  29. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/static/style.css +0 -0
  30. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/taskai/views.py +0 -0
  31. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/test/test_cli.py +0 -0
  32. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/test/test_execution.py +0 -0
  33. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/test/test_json_dir_database.py +0 -0
  34. {taskai_cli-1.5.4 → taskai_cli-1.6.2}/test/test_view.py +0 -0
@@ -1,3 +1,119 @@
1
+ # 8-22
2
+
3
+ Found and fixed a real bug from the `litellm` migration earlier today: it
4
+ auto-loads `.env` via `python-dotenv` on import, and the project's `.env`
5
+ had a stray first line (`source .taskai-venv/bin/activate` — not a
6
+ `KEY=VALUE` pair), so every `task ai` run printed a "could not parse
7
+ statement starting at line 1" warning. Non-fatal (`GEMINI_API_KEY` on line
8
+ 2 still loaded fine) but confusing. Diagnosed by reading `litellm/__init__.py`
9
+ directly rather than guessing — it calls `_dotenv.load_dotenv(...)` at
10
+ import time. Left the actual `.env` edit to Alex since it holds a live key.
11
+
12
+ Also stripped two leftover debug `print()`s from `ai_natural_language_service`
13
+ (the raw prompt and the full constructed `ai_prompt` — fine for iterating on
14
+ the prompt, noise now that this is a user-facing command) and reworked the
15
+ "about to run these commands" printout: instead of one `print(response_json)`
16
+ dump of the raw parsed list, it now prints each command right before running
17
+ it, reconstructed as `ai run 'task <command> ...'` (quoting only values with
18
+ spaces) via a small `_format_command_str()` helper — reads like a real
19
+ command instead of a dict dump, and shows progress as it goes rather than
20
+ front-loading everything.
21
+
22
+ Added `--context` and `--reasoning` to `task ai <prompt>` (DEVPLAN 2.2,
23
+ below, has the durable version of this):
24
+
25
+ - `--context a.md,b.md` reads file(s) and folds them into the prompt.
26
+ - While wiring it up, found `execute_commands`'s `case "ai":` dispatch
27
+ never forwarded `**kwargs` to `Controller.ai_natural_language` at all —
28
+ `--context` would've parsed fine and then been silently dropped before
29
+ reaching the service. Fixed by threading `**kwargs` through the whole
30
+ chain (`cli.py`'s dispatch → `Controller.ai_natural_language` →
31
+ `ai_natural_language_service`).
32
+ - `--reasoning <level>` came out of a tangent about `task ai` latency: Alex
33
+ asked whether "respond quickly" in the prompt does anything (no — output
34
+ token generation is sequential/autoregressive, prompt wording can't speed
35
+ that up) and what actually causes it. Real answer: some Gemini tiers
36
+ generate a hidden "thinking" pass before the visible output, which is a
37
+ genuine invisible latency cost. Read litellm's Gemini transformation
38
+ source directly to confirm the actual knob: `reasoning_effort` is a
39
+ standard litellm kwarg on `completion()`, mapped per-provider — accepts
40
+ `minimal|low|medium|high|disable|none`. Confirmed a real wrinkle for this
41
+ project specifically: on Gemini 2.5-series models it maps to a
42
+ `thinkingBudget` token count and `"none"` zeroes it out completely, but
43
+ on Gemini 3.x (what `llm_models.py`'s menu defaults to) it maps to a
44
+ `thinkingLevel` enum, and litellm's own code comment says Gemini 3
45
+ *cannot* fully disable thinking — `"disable"`/`"none"` just fall back to
46
+ the minimum level there. Only exposed for reasoning-capable tiers
47
+ (`supports_reasoning(...)` gated internally), so it may no-op or error on
48
+ something like Flash-Lite.
49
+ - Tried defaulting `--reasoning` to `"disable"` when omitted, then Alex
50
+ reverted that — omitting the flag now sends no `reasoning_effort` at all,
51
+ leaving it up to whatever the model/provider defaults to.
52
+ - Documented both flags under `task ai` in `help_menu.py` (this file is
53
+ also the AI's own command reference, so it needs to stay accurate even
54
+ though neither flag is something the model itself would ever emit).
55
+
56
+ ---
57
+
58
+ Migrated AI backend off direct `google-genai` calls, landing on `litellm`
59
+ after a detour through `aisuite`:
60
+
61
+ - First tried `aisuite` per DEVPLAN's original Phase 2 plan. Downloaded and
62
+ read the actual package source before committing to it (not just docs) —
63
+ found `aisuite`'s only Google provider is Vertex AI
64
+ (`GOOGLE_PROJECT_ID`/`GOOGLE_REGION`/a service-account JSON), with no path
65
+ to a plain Gemini/AI-Studio API key. Since the existing setup is just a
66
+ simple API key, that would've been a real functionality change disguised
67
+ as a library swap. Flagged it to Alex before writing any code.
68
+ - Alex opted to rework the setup rather than force-fit the old flow, but
69
+ then asked whether another compat layer supported plain Gemini API keys —
70
+ it does: `litellm` treats `gemini` and `vertex_ai` as separate providers
71
+ (confirmed by reading `litellm/utils.py`'s `validate_environment` and
72
+ `litellm/types/utils.py`'s `LlmProviders` enum directly), so
73
+ `gemini/<model>` + `GEMINI_API_KEY` keeps the current simple setup working
74
+ end to end. Redid the migration against `litellm` instead.
75
+ - `pyproject.toml`: `google-genai` → `litellm>=1.98`.
76
+ - `taskai/models.py`: `CLIConfig.GEMINI_MODEL`/`GEMINI_API_KEY` collapsed to
77
+ a single `AI_MODEL: str` (a `provider/model` string, litellm's own
78
+ separator).
79
+ - `taskai/services/ai.py`: both `ai_headstart_service` and
80
+ `ai_natural_language_service` now call
81
+ `litellm.completion(model=model_name, messages=[...])` instead of
82
+ `google.genai`; dropped the `api_key`/`@config("GEMINI_API_KEY", ...)`
83
+ plumbing since litellm reads provider credentials straight from env vars.
84
+ - New [taskai/llm_models.py](taskai/llm_models.py) (top-level, not under
85
+ `services/` — it's reference data, not a service): `PROVIDER_ENV_VARS`
86
+ (which env var(s) litellm expects per provider) and `PROVIDER_MODELS` (a
87
+ best-effort, non-exhaustive menu of known models per provider). Both
88
+ built by reading litellm's source directly rather than guessing.
89
+ - `services/user_setup.py` reworked into a two-step picker
90
+ (`_select_from_list`): pick a provider from a numbered menu, then pick a
91
+ model for that provider from another numbered menu. Either step also
92
+ accepts free-typed text instead of a number, so a stale/missing
93
+ `PROVIDER_MODELS` entry (several providers — `azure`, `ollama` — are
94
+ intentionally empty, since their "models" are a deployment name or
95
+ whatever you've pulled locally) never blocks setup.
96
+ - Caught a live example of "models go stale fast" mid-session: Alex hit
97
+ `gemini-3.6-flash` referenced by Google's own tooling, which wasn't in
98
+ training data. Web-searched Google's current model docs and corrected the
99
+ `gemini`/`vertex_ai` entries in `llm_models.py` — `gemini-2.0-flash` (the
100
+ old hardcoded default) was actually shut down 2026-06-01. If `task
101
+ setup`'s Gemini options look wrong again, re-check against
102
+ https://ai.google.dev/gemini-api/docs/models rather than trusting the
103
+ hardcoded list.
104
+ - README's Config section updated to match (`AI_MODEL` as `provider/model`,
105
+ points at `task setup`).
106
+ - Not yet done: `pip install "litellm>=1.98"` / `pip uninstall google-genai`
107
+ in `.taskai-venv` — Alex needs to run this manually (environment-mutating
108
+ commands aren't run unprompted in this project).
109
+
110
+ DEVPLAN.md's Phase 2 (2.1, 2.3) updated to match — checked off, `aisuite`
111
+ references swapped for `litellm`, and 2.4 (the later tool-calling-loop
112
+ entry point) flagged for re-scoping since it was written around `aisuite`'s
113
+ automatic tool-runner, which `litellm` has no equivalent for.
114
+
115
+ ---
116
+
1
117
  # 8-19
2
118
 
3
119
  Fixed a typo in `browser.py` that caused uvicorn to fail with "Could not import
@@ -24,10 +24,10 @@ depth, and the same node type is used whether it's acting as a "list" or a
24
24
  (`recurs_every`/`recurs_until`/`recur_keep_incomplete`). `Comment` is a
25
25
  simple `{content, item_id, created_on}`. `UserData` is the whole-DB
26
26
  container: `todo_items: dict[str, dict]`, `comments: dict[str, dict]`,
27
- `config: dict`, `id_counter`. `CLIConfig` currently holds
28
- `GEMINI_MODEL`/`GEMINI_API_KEY` (Phase 2 below plans migrating this to a
29
- single `AI_MODEL` field once `aisuite` lands) plus `DISPLAY_STRING`/
30
- `DISPLAY_COLORS` for CLI rendering.
27
+ `config: dict`, `id_counter`. `CLIConfig` holds a single `AI_MODEL` field (a
28
+ `provider/model` string, e.g. `gemini/gemini-3.6-flash` set via `task
29
+ setup`'s two-step provider→model picker, done in Phase 2) plus
30
+ `DISPLAY_STRING`/`DISPLAY_COLORS` for CLI rendering.
31
31
 
32
32
  **Storage.** [taskai/json_dir_database.py](taskai/json_dir_database.py):
33
33
  `JsonDirectoryDatabase` — one JSON file per user under `.taskai/task_db/
@@ -64,9 +64,11 @@ opening a second connection.
64
64
  `ai_headstart_service` (single item → LLM suggests a next concrete step,
65
65
  saved as a comment) and `ai_natural_language_service` (prompt → LLM returns
66
66
  a JSON list of `{command, args, kwargs}` → executed through
67
- `taskai.cli.execute_commands`, one call per entry). Both currently call
68
- Gemini directly via `google-genai`; Phase 2 below covers migrating to
69
- `aisuite`. The natural-language prompt embeds
67
+ `taskai.cli.execute_commands`, one call per entry). Both call
68
+ `litellm.completion(model=model_name, messages=[...])` provider-agnostic
69
+ per the `AI_MODEL` config value, no more hardcoded Gemini/`google-genai`.
70
+ See [taskai/llm_models.py](taskai/llm_models.py) for the provider→env-var
71
+ and provider→model reference data `task setup` uses. The natural-language prompt embeds
70
72
  [taskai/help_menu.py](taskai/help_menu.py)'s `help_general` string as the
71
73
  LLM's command reference — **that file is the single source of truth for the
72
74
  command surface, shown to humans via `task help` and to the LLM verbatim.
@@ -75,12 +77,19 @@ it's gone stale before.
75
77
 
76
78
  **Known code quirks (found but intentionally not fixed — out of scope
77
79
  unless you're told to fix them):**
78
- - `Controller.update_item` (`cli.py`) resolves a non-numeric `item_id` via
80
+ - ~~`Controller.update_item` (`cli.py`) resolves a non-numeric `item_id` via
79
81
  `_find_model_by_stringmatch` but assigns the *whole model object* back
80
- into `item_id` instead of pulling `.id` off it, so it's passed down to
81
- `db.update_item` as a model, not an id, and fails. This means `update`,
82
- `rename`, and `status` (all three route through `update_item`) only work
83
- reliably with a numeric id, not a name.
82
+ into `item_id` instead of pulling `.id` off it~~ **Fixed.** All identifier
83
+ resolution (id or name) now routes through one function,
84
+ `Controller._resolve_item()` (throws via `throw_error` if not found; a
85
+ non-throwing `_find_item_by_identifier` still backs the soft-fail `show`
86
+ path). Every command that takes an item identifier — `update`, `rename`,
87
+ `status`, `done`/`complete`, `comment`, `depend`, `reorder`, `move`,
88
+ `link`, `delete`, `create`'s parent — now accepts a name via `fnmatch`,
89
+ not just a numeric id. Net effect was less code, not more: two
90
+ name-only-duplicate methods (`show_by_item_name`, `delete_item_by_name`)
91
+ and several inline `_is_int(...)` dispatch branches in `execute_commands`
92
+ were deleted outright rather than kept alongside the id path.
84
93
  - `task show <id1>,<id2>,...` is documented in the README but
85
94
  `execute_commands`'s `show` case never dispatches to
86
95
  `Controller.show_items`/`view_items` — there's no wired path to it.
@@ -336,48 +345,152 @@ through the existing `Controller`.
336
345
 
337
346
  ## Phase 2 — AI tools
338
347
 
339
- ### 2.1 Finish the natural-language service
340
-
341
- - [ ] Depends on Phase 0 fixes (prompt bug, `_add_info` bug, actually
342
- executing parsed commands).
343
- - [ ] Add the ability to inject extra context / tools / other agentic framework arguments
344
- supported by aisuite.
345
- - [ ] Have execution reuse the *same* command-string path as the web console
346
- (`execute_commands`, or the `/api/command` handler once it exists) so
347
- AI-issued commands and human-issued commands share one code path.
348
- - [ ] Print the parsed command list before executing it (already partially
349
- there via `print(response_json)`) — cheap safety net given the parsed
350
- commands can include `delete`/`nuke`. Not a full confirmation prompt,
351
- just visibility.
352
-
353
- ### 2.2 Switch to `aisuite`
354
-
355
- - [ ] Add `aisuite` to `pyproject.toml` dependencies; drop the direct
356
- `google-genai` import from `services/ai.py`.
357
- - [ ] Replace both call sites (`ai_headstart_service`,
358
- `ai_natural_language_service`) with `aisuite.Client()` and
359
- `client.chat.completions.create(model=model_name, messages=[...])`.
360
- Model strings become `provider:model`, e.g. `google:gemini-2.0-flash`
361
- or `openai:gpt-4o-mini` — this is what buys multi-provider support for
362
- free.
363
- - [ ] `aisuite` reads provider credentials from standard provider env vars
364
- (`OPENAI_API_KEY`, `GOOGLE_API_KEY`, etc.), not from a key you pass in.
365
- Drop the `@config("GEMINI_API_KEY", "api_key")` plumbing entirely —
348
+ Two entry points, built in stages, not one replacing the other:
349
+ **`task ai <prompt>`** (2.1-2.3 below) is the existing one-shot flow —
350
+ model sees the prompt once, returns a full JSON list of commands up front,
351
+ they execute with no gating, same as today just on `litellm`. **`task ai
352
+ agent <prompt>`** (2.4, later) is a separate, more elaborate entry point for
353
+ multi-step workflows using a real tool-calling loop, added only after 2.1-2.3
354
+ are working and any kinks (context injection, multi-file support) are worked
355
+ out on the simpler path first.
356
+
357
+ ### 2.1 `task ai <prompt>` on `litellm` (no gating, same one-shot shape as today)
358
+
359
+ - [x] Depended on the Phase 0 fixes (prompt bug, `_add_info` bug, actually
360
+ executing parsed commands) — done.
361
+ - [x] Originally planned on `aisuite` (below was written for it), but
362
+ pivoted after reading `aisuite`'s actual provider source: its only
363
+ Google provider is Vertex AI (`GOOGLE_PROJECT_ID`/`GOOGLE_REGION`/a
364
+ service-account JSON) no path to a plain Gemini/AI-Studio API key,
365
+ which is what the existing setup actually used. `litellm` has a
366
+ separate `gemini` provider (distinct from its own `vertex_ai`) that
367
+ reads `GEMINI_API_KEY` directly, so the simple-API-key setup carries
368
+ over unchanged. Added `litellm>=1.98` to `pyproject.toml`; dropped
369
+ `google-genai` and the `from google import genai` import from
370
+ `services/ai.py`.
371
+ - [x] Swapped both call sites (`ai_headstart_service`,
372
+ `ai_natural_language_service`) to
373
+ `litellm.completion(model=model_name, messages=[...])`, reading
374
+ `response.choices[0].message.content`.
375
+ **Scope note:** plain chat completions only, no `tools=`. The model
376
+ still returns one JSON list of `{command, args, kwargs}` up front,
377
+ same shape/contract as today, executed through `execute_commands`
378
+ with no confirmation step — that's deliberate for this pass, not an
379
+ oversight. The tool-calling loop is 2.4, later, not here. Model
380
+ strings are `provider/model` (litellm's separator, e.g.
381
+ `gemini/gemini-3.6-flash`, `openai/gpt-4o-mini`).
382
+ - [x] `litellm` reads provider credentials from standard provider env vars
383
+ (`OPENAI_API_KEY`, `GEMINI_API_KEY`, etc.), not a key passed in.
384
+ Dropped the `@config("GEMINI_API_KEY", "api_key")` plumbing entirely —
366
385
  one less thing to configure and one less place a secret sits in the
367
386
  plaintext user JSON db.
368
- - [ ] Rename `CLIConfig.GEMINI_MODEL`/`GEMINI_API_KEY` to a single
369
- `AI_MODEL` field holding the `provider:model` string.
370
-
371
- ### 2.3 Update setup script for `aisuite`
372
-
373
- - [ ] `services/user_setup.py`: replace `_get_gemini_api_key` /
374
- `_select_gemini_model` with a single prompt for `AI_MODEL`
375
- (`provider:model`), plus a printed reminder to set the matching
376
- provider env var (e.g. "set `OPENAI_API_KEY` in your environment")
377
- rather than storing a key in the db.
378
- - [ ] Update `help_menu.py` / README wherever `GEMINI_API_KEY`/
379
- `GEMINI_MODEL` are referenced (`task config set GEMINI_API_KEY ...`
380
- example in the README) to match the new config shape.
387
+ - [x] Renamed `CLIConfig.GEMINI_MODEL`/`GEMINI_API_KEY` to a single
388
+ `AI_MODEL` field holding the `provider/model` string.
389
+ - [x] Kept printing the parsed command list before executing it
390
+ (`print(response_json)`)cheap safety net given the parsed commands
391
+ can include `delete`/`nuke`. Still just visibility, not a confirmation
392
+ prompt real gating is what 2.4 adds.
393
+
394
+ ### 2.2 Context injection (`--context`), including the multi-file kink
395
+
396
+ - [x] `--context <path1,path2,...>` on `task ai <prompt>`: comma-separated
397
+ as planned (sidesteps the `_parse_remaining` repeated-flag kink
398
+ entirely never needed to touch it). `_read_context_files()` in
399
+ `services/ai.py` reads each file and folds them into the prompt as
400
+ `--- <path> ---\n<contents>` sections, inserted between the item tree
401
+ and the user's actual prompt.
402
+ - [x] Real landmine found while wiring this up, not in the original plan:
403
+ `execute_commands`'s `case "ai":` dispatch built the prompt string
404
+ from `args[1:]` but never forwarded `**kwargs` to
405
+ `Controller.ai_natural_language` at all — any `--flag` on `task ai
406
+ ...` was silently parsed and then dropped on the floor before
407
+ `--context` even had a chance to matter. Fixed by forwarding
408
+ `**kwargs` through the `case "ai":` dispatch and having
409
+ `Controller.ai_natural_language` (`cli.py`) and
410
+ `ai_natural_language_service` (`services/ai.py`) both accept it
411
+ explicitly. A missing/misspelled path just raises `FileNotFoundError`
412
+ naturally, caught by `execute_commands`'s existing catch-all — no new
413
+ error handling needed.
414
+ - [x] Documented `--context` (and `--reasoning`, below) under `task ai` in
415
+ `help_menu.py`, which is also the AI's own command reference — this
416
+ command doesn't affect what the LLM is allowed to emit (it's a
417
+ human-only flag on the `ai` invocation itself, not a command the
418
+ model generates), but the doc was out of sync with reality otherwise.
419
+
420
+ **Also added, not originally scoped:** `--reasoning <level>` on `task ai
421
+ <prompt>`, passed straight through as `reasoning_effort` on the
422
+ `litellm.completion(...)` call — came out of a discussion about `task ai`
423
+ latency. Accepted values (`minimal|low|medium|high|disable|none`) aren't
424
+ validated client-side; an invalid value raises litellm's own
425
+ `Invalid reasoning effort: <value>` error, which already surfaces cleanly
426
+ through the same catch-all. No default is set — omitting the flag means no
427
+ `reasoning_effort` is sent at all, so behavior is whatever the model/provider
428
+ defaults to. (A `"disable"` default was tried and explicitly reverted this
429
+ session — worth knowing if it comes up again.) Confirmed by reading
430
+ litellm's Gemini transformation source directly: on Gemini 2.5-series models
431
+ this maps to a `thinkingBudget` token count and `"none"` genuinely zeroes
432
+ it out, but on Gemini 3.x (the default in `llm_models.py`'s menu) it maps
433
+ to a `thinkingLevel` enum and litellm's own code comment states Gemini 3
434
+ *cannot* fully disable thinking — `"disable"`/`"none"` just fall back to
435
+ the minimum level. Also gated behind `supports_reasoning(model, ...)`
436
+ internally, so it's silently a no-op (or may error) on non-reasoning tiers
437
+ like Flash-Lite.
438
+
439
+ ### 2.3 — Update setup script for `litellm`
440
+
441
+ - [x] `services/user_setup.py` reworked into a two-step picker: select a
442
+ provider, then select a model, both via a numbered-menu-or-free-text
443
+ prompt (`_select_from_list`) — typing a number picks from the menu,
444
+ typing anything else is taken literally, so a stale/missing menu entry
445
+ never blocks you. Combines to `AI_MODEL = "<provider>/<model>"` and
446
+ prints the exact env var(s) that provider needs.
447
+ - [x] New [taskai/llm_models.py](taskai/llm_models.py) (top-level, not
448
+ under `services/`, since it's reference data, not a service):
449
+ `PROVIDER_ENV_VARS` and `PROVIDER_MODELS`, both keyed by litellm's
450
+ real provider names, pulled from `litellm.types.utils.LlmProviders`
451
+ and `litellm.utils.validate_environment` directly rather than
452
+ guessed. `PROVIDER_MODELS` is a non-exhaustive, best-effort menu —
453
+ model names go stale fast, confirmed live mid-session: the `gemini`
454
+ entries needed a web-search correction after `gemini-2.0-flash`
455
+ turned out to have been shut down 2026-06-01 and `gemini-3.6-flash`
456
+ didn't exist yet in training data. Re-verify against
457
+ https://ai.google.dev/gemini-api/docs/models if `task setup`'s Gemini
458
+ options look off again.
459
+ - [x] Updated README's Config section to match the new `AI_MODEL`
460
+ `provider/model` shape and point at `task setup`.
461
+
462
+ ### 2.4 — `task ai agent <prompt>` (later — real tool-calling loop)
463
+
464
+ A separate entry point, not a replacement for `task ai`. `task ai <prompt>`
465
+ stays the fast, ungated, one-shot-plan-and-execute path from 2.1-2.3;
466
+ `task ai agent <prompt>` is for workflows where the model actually needs to
467
+ see intermediate results (e.g. the real id of an item it just created)
468
+ before deciding the next step, which a one-shot plan can't do.
469
+
470
+ **Note (post-litellm-pivot):** this section was originally scoped around
471
+ `aisuite`'s automatic tool-runner (`tools=[...]` + `max_turns`, which drives
472
+ the call → execute → feed-result-back loop for you). `litellm.completion`
473
+ also accepts `tools=[...]` (OpenAI-style function-calling schema) but has no
474
+ automatic multi-turn runner — the loop below would need to be hand-rolled.
475
+ Re-scope this section before starting 2.4.
476
+
477
+ - [ ] Small, explicitly-typed wrapper functions — one per meaningful
478
+ command (create, update, complete, delete, comment, depend, link,
479
+ move; roughly 8-12 total, not one-per-every-CLI-verb) — calling
480
+ straight into the existing `Controller` methods, exposed as
481
+ `tools=[...]` to `litellm.completion`.
482
+ - [ ] Hand-roll the call → execute → feed-result-back loop (no automatic
483
+ runner in `litellm`, unlike the originally-planned `aisuite`), capped
484
+ at some max number of turns.
485
+ - [ ] Gate destructive tools (`delete`, `nuke`) behind an explicit yes/no
486
+ confirmation before executing — not just the print-before-execute
487
+ visibility 2.1 has.
488
+ - [ ] Print/log the tool-call transcript as it runs so the agent shows its
489
+ work — same spirit as 2.1's visibility bullet, scaled to a multi-step
490
+ run.
491
+ - [ ] Flagged, not scoped yet: multi-turn memory across calls in one
492
+ session, a per-call `--model` override, streaming for plain-text
493
+ responses (`headstart`). Revisit after 2.4 ships if still wanted.
381
494
 
382
495
  ---
383
496
 
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: taskai-cli
3
- Version: 1.5.4
3
+ Version: 1.6.2
4
4
  Author-email: Alex Paskal <alexcpaskal@gmail.com>
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: fastapi>=0.141.1
7
- Requires-Dist: google-genai>=2.8
7
+ Requires-Dist: litellm>=1.98
8
8
  Requires-Dist: orjson>=3.11
9
9
  Requires-Dist: pydantic>=2.13
10
10
  Requires-Dist: rich>=15
@@ -84,11 +84,12 @@ Pass any of these as `--field value` to `create`, `add`, or `update`:
84
84
 
85
85
  ## Config
86
86
 
87
- AI features require a Gemini API key:
87
+ AI features require a model, set as a `provider/model` string (via [litellm](https://github.com/BerriAI/litellm)),
88
+ plus that provider's credentials set as environment variables (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`).
89
+ `task setup` will walk you through picking a provider and model and tell you which env vars it needs.
88
90
 
89
91
  ```bash
90
- task config set GEMINI_API_KEY <your-key>
91
- task config set GEMINI_MODEL gemini-2.0-flash
92
+ task config set AI_MODEL gemini/gemini-2.0-flash
92
93
  ```
93
94
 
94
95
  ---
@@ -71,11 +71,12 @@ Pass any of these as `--field value` to `create`, `add`, or `update`:
71
71
 
72
72
  ## Config
73
73
 
74
- AI features require a Gemini API key:
74
+ AI features require a model, set as a `provider/model` string (via [litellm](https://github.com/BerriAI/litellm)),
75
+ plus that provider's credentials set as environment variables (e.g. `GEMINI_API_KEY`, `OPENAI_API_KEY`).
76
+ `task setup` will walk you through picking a provider and model and tell you which env vars it needs.
75
77
 
76
78
  ```bash
77
- task config set GEMINI_API_KEY <your-key>
78
- task config set GEMINI_MODEL gemini-2.0-flash
79
+ task config set AI_MODEL gemini/gemini-2.0-flash
79
80
  ```
80
81
 
81
82
  ---
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskai-cli"
3
- version = "1.5.4"
3
+ version = "1.6.2"
4
4
  description = ""
5
5
  authors = [{name = "Alex Paskal", email = "alexcpaskal@gmail.com"}]
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ dependencies = [
9
9
  "pydantic>=2.13",
10
10
  "rich>=15",
11
11
  "orjson>=3.11",
12
- "google-genai>=2.8",
12
+ "litellm>=1.98",
13
13
  "fastapi>=0.141.1",
14
14
  "uvicorn>=0.52.3",
15
15
  ]
@@ -179,8 +179,12 @@ class Controller:
179
179
  Controller.create_comment(item.id, comment_content)
180
180
  print(comment_content)
181
181
 
182
- def ai_natural_language(prompt: str):
183
- ai_natural_language_service(db, prompt)
182
+ def ai_natural_language(prompt: str, **kwargs):
183
+ ai_natural_language_service(
184
+ db, prompt,
185
+ context=kwargs.get("context"),
186
+ reasoning=kwargs.get("reasoning"),
187
+ )
184
188
 
185
189
  def throw_error(error_description: str, *args, **kwargs):
186
190
  print(f"[red]ERROR: {error_description}[/red]\nargs={args}\nkwargs={kwargs}")
@@ -272,7 +276,7 @@ class Controller:
272
276
  db.commit()
273
277
 
274
278
  def browser_service():
275
- subprocess.run(['uvicorn','taskai.browser:app','--reload'])
279
+ subprocess.run([sys.executable, '-m', 'uvicorn', 'taskai.browser:app', '--reload'])
276
280
 
277
281
 
278
282
 
@@ -399,7 +403,7 @@ def execute_commands(*args, **kwargs) -> int:
399
403
  case "ai":
400
404
  match args[1]:
401
405
  case "headstart": Controller.ai_headstart(*args[2:], **kwargs)
402
- case _: Controller.ai_natural_language(" ".join(args[1:]))
406
+ case _: Controller.ai_natural_language(" ".join(args[1:]), **kwargs)
403
407
 
404
408
  case "nuke":
405
409
  db.remove()
@@ -47,6 +47,8 @@ Item fields (pass as '--field value' to create/add/update):
47
47
 
48
48
  AI:
49
49
  'task ai {prompt}' --> feed a prompt to an LLM, which converts it into a series of the commands above and runs them
50
+ --context {path[,path...]} fold the contents of one or more files into the prompt as extra context
51
+ --reasoning {level} how hard the model should think before answering: minimal|low|medium|high|disable|none
50
52
  'task ai headstart {id}' --> ask an LLM to suggest the next concrete step for an item; the answer is saved as a comment
51
53
 
52
54
  Config:
@@ -0,0 +1,57 @@
1
+ """
2
+ Reference data for AI setup: known providers, the env vars litellm reads for
3
+ each, and a best-effort (non-exhaustive) list of models per provider.
4
+
5
+ Model names go stale fast - this is a helpful menu, not a hard whitelist.
6
+ Provider keys match litellm's `provider/model` naming convention exactly
7
+ (see litellm.types.utils.LlmProviders) - keep in sync if that changes.
8
+ """
9
+
10
+ # env vars litellm reads per provider
11
+ PROVIDER_ENV_VARS = {
12
+ "openai": ["OPENAI_API_KEY"],
13
+ "anthropic": ["ANTHROPIC_API_KEY"],
14
+ "gemini": ["GEMINI_API_KEY"],
15
+ "vertex_ai": ["VERTEXAI_PROJECT", "VERTEXAI_LOCATION", "GOOGLE_APPLICATION_CREDENTIALS"],
16
+ "groq": ["GROQ_API_KEY"],
17
+ "mistral": ["MISTRAL_API_KEY"],
18
+ "together_ai": ["TOGETHERAI_API_KEY"],
19
+ "xai": ["XAI_API_KEY"],
20
+ "deepseek": ["DEEPSEEK_API_KEY"],
21
+ "cohere": ["COHERE_API_KEY"],
22
+ "fireworks_ai": ["FIREWORKS_AI_API_KEY"],
23
+ "nebius": ["NEBIUS_API_KEY"],
24
+ "sambanova": ["SAMBANOVA_API_KEY"],
25
+ "huggingface": ["HUGGINGFACE_API_KEY"],
26
+ "azure": ["AZURE_API_KEY", "AZURE_API_VERSION", "AZURE_API_BASE"],
27
+ "bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"],
28
+ "watsonx": ["WATSONX_API_KEY", "WATSONX_PROJECT_ID", "WATSONX_URL"],
29
+ "cerebras": ["CEREBRAS_API_KEY"],
30
+ "inception": ["INCEPTION_API_KEY"],
31
+ "ollama": ["OLLAMA_API_BASE"],
32
+ }
33
+
34
+ # example/known models per provider - not exhaustive, just a menu to pick from
35
+ # or type past (e.g. a newer model the provider has added since this was written)
36
+ PROVIDER_MODELS = {
37
+ "openai": ["gpt-5", "gpt-4o", "gpt-4o-mini", "o3", "o3-mini"],
38
+ "anthropic": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5-20251001", "claude-fable-5"],
39
+ "gemini": ["gemini-3.7-flash", "gemini-3.1-pro-preview", "gemini-3.6-flash", "gemini-3.5-flash-lite", "gemini-2.5-pro"],
40
+ "vertex_ai": ["gemini-3.7-flash", "gemini-3.1-pro-preview", "gemini-3.6-flash", "gemini-2.5-pro"],
41
+ "groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant", "mixtral-8x7b-32768", "gemma2-9b-it"],
42
+ "mistral": ["mistral-large-latest", "mistral-small-latest", "codestral-latest", "pixtral-large-latest"],
43
+ "together_ai": ["meta-llama/Llama-3.3-70B-Instruct-Turbo", "Qwen/Qwen2.5-72B-Instruct-Turbo", "deepseek-ai/DeepSeek-V3"],
44
+ "xai": ["grok-4", "grok-4-fast", "grok-3", "grok-3-mini"],
45
+ "deepseek": ["deepseek-chat", "deepseek-reasoner"],
46
+ "cohere": ["command-a", "command-r-plus", "command-r"],
47
+ "fireworks_ai": ["accounts/fireworks/models/llama-v3p3-70b-instruct", "accounts/fireworks/models/deepseek-v3"],
48
+ "nebius": ["meta-llama/Llama-3.3-70B-Instruct", "Qwen/Qwen2.5-72B-Instruct"],
49
+ "sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-8B-Instruct"],
50
+ "huggingface": ["meta-llama/Llama-3.3-70B-Instruct", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
51
+ "azure": [], # model choice is your own Azure deployment name, not a fixed list
52
+ "bedrock": ["anthropic.claude-3-5-sonnet-20241022-v2:0", "meta.llama3-1-70b-instruct-v1:0", "amazon.titan-text-premier-v1:0"],
53
+ "watsonx": ["ibm/granite-3-8b-instruct", "meta-llama/llama-3-3-70b-instruct"],
54
+ "cerebras": ["llama-3.3-70b", "llama3.1-8b"],
55
+ "inception": ["mercury-coder-small"],
56
+ "ollama": [], # whatever model you've pulled locally, e.g. "llama3"
57
+ }
@@ -64,9 +64,8 @@ class Comment(Base):
64
64
 
65
65
  class CLIConfig(Base):
66
66
 
67
- # GEMINI
68
- GEMINI_MODEL: str = None
69
- GEMINI_API_KEY: str = None
67
+ # AI
68
+ AI_MODEL: str = None
70
69
 
71
70
  # VIEW
72
71
  DISPLAY_STRING: str = "id name status due_by"
@@ -12,12 +12,10 @@ from taskai.models import TodoItem, Comment
12
12
  from taskai.config import config
13
13
  from taskai.help_menu import help_general
14
14
 
15
- @config("GEMINI_API_KEY", "api_key")
16
- @config("GEMINI_MODEL", "model_name")
15
+ @config("AI_MODEL", "model_name")
17
16
  def ai_headstart_service(
18
17
  db: JsonDirectoryDatabase,
19
18
  item_id: str,
20
- api_key: str,
21
19
  model_name: str
22
20
  ):
23
21
  """
@@ -32,10 +30,7 @@ def ai_headstart_service(
32
30
 
33
31
  item:TodoItem = db.get_item(item_id)
34
32
 
35
-
36
- from google import genai
37
-
38
-
33
+ import litellm
39
34
 
40
35
  # contruct prompt
41
36
  prompt = f"""
@@ -69,29 +64,35 @@ task name: {item.name}
69
64
  comment: Comment = db.get_comment(comment_id)
70
65
  prompt += f"\n\t- {comment.content}"
71
66
  # query model
72
- client = genai.Client(api_key=api_key)
73
- response = client.models.generate_content(
67
+ response = litellm.completion(
74
68
  model=model_name,
75
- contents=prompt
69
+ messages=[{"role": "user", "content": prompt}],
76
70
  )
77
71
 
78
- return response.text
72
+ return response.choices[0].message.content
73
+
74
+ def _read_context_files(context: str) -> str:
75
+ """context is a comma-separated list of file paths"""
76
+ sections = []
77
+ for path in context.split(","):
78
+ path = path.strip()
79
+ with open(path) as f:
80
+ sections.append(f"--- {path} ---\n{f.read()}")
81
+ return "\n\n".join(sections)
79
82
 
80
- @config("GEMINI_API_KEY", "api_key")
81
- @config("GEMINI_MODEL", "model_name")
83
+ @config("AI_MODEL", "model_name")
82
84
  def ai_natural_language_service(
83
85
  db: JsonDirectoryDatabase,
84
86
  prompt: str,
85
- api_key: str,
86
- model_name: str
87
+ model_name: str,
88
+ context: str = None,
89
+ reasoning: str = None
87
90
  ):
88
91
  """
89
92
  This service queries an LLM with a natural language
90
93
  prompt from the user. The response is a series of terminal commands
91
94
  called directly
92
95
  """
93
- print(f"Ai prompt: {prompt}")
94
-
95
96
  # recursively build user info
96
97
  user_info = []
97
98
  _visited_set = set()
@@ -112,6 +113,14 @@ def ai_natural_language_service(
112
113
 
113
114
  user_info = "\n".join(user_info)
114
115
 
116
+ context_block = ""
117
+ if context:
118
+ context_block = f"""
119
+ Here is additional context the user has provided:
120
+
121
+ {_read_context_files(context)}
122
+ """
123
+
115
124
  # build ai prompt
116
125
  ai_prompt = f"""
117
126
  You are a todo agent. Your job is to convert a natural language description from a user into a set
@@ -122,7 +131,7 @@ of CLI operations using our app. Here are a comprehensive list of operations tha
122
131
  Here are all of the user's existing item names, each prepended with their id
123
132
 
124
133
  {user_info}
125
-
134
+ {context_block}
126
135
  Here is the user's prompt:
127
136
 
128
137
  {prompt}
@@ -178,34 +187,48 @@ markdown formatting. Just the raw json string.
178
187
 
179
188
  """
180
189
 
181
-
182
- from google import genai
190
+ import litellm
191
+
192
+ completion_kwargs = {}
193
+ if reasoning:
194
+ completion_kwargs["reasoning_effort"] = reasoning
183
195
 
184
- print(ai_prompt)
185
- client = genai.Client(api_key=api_key)
186
- response = client.models.generate_content(
196
+ response = litellm.completion(
187
197
  model=model_name,
188
- contents=ai_prompt
198
+ messages=[{"role": "user", "content": ai_prompt}],
199
+ **completion_kwargs,
189
200
  )
201
+ response_text = response.choices[0].message.content
190
202
 
191
203
  try:
192
- response_json = json.loads(response.text)
204
+ response_json = json.loads(response_text)
193
205
  except json.JSONDecodeError:
194
- print(response.text)
206
+ print(response_text)
195
207
  print("\n\ndecode error")
196
208
  import sys
197
209
  sys.exit(-1)
198
210
 
199
- print(response_json)
200
-
201
211
  from taskai.cli import execute_commands
202
212
 
203
213
  for entry in response_json:
204
214
  command = entry["command"]
205
215
  cmd_args = entry.get("args", [])
216
+ raw_kwargs = entry.get("kwargs", {})
206
217
  cmd_kwargs = {
207
218
  k.lstrip("-"): v
208
- for k, v in entry.get("kwargs", {}).items()
219
+ for k, v in raw_kwargs.items()
209
220
  }
221
+ print(f"ai run '{_format_command_str(command, cmd_args, raw_kwargs)}'")
210
222
  execute_commands(command, *cmd_args, **cmd_kwargs)
211
223
 
224
+ def _format_command_str(command: str, args: list, kwargs: dict) -> str:
225
+ def quote(value):
226
+ value = str(value)
227
+ return f"'{value}'" if " " in value else value
228
+
229
+ parts = ["task", command] + [quote(a) for a in args]
230
+ for k, v in kwargs.items():
231
+ key = k if k.startswith("--") else f"--{k}"
232
+ parts.append(f"{key} {quote(v)}")
233
+ return " ".join(parts)
234
+
@@ -0,0 +1,75 @@
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
+ from taskai.llm_models import PROVIDER_ENV_VARS, PROVIDER_MODELS
8
+
9
+ from rich.prompt import Prompt
10
+ from rich import print
11
+
12
+
13
+ def user_setup_service(
14
+ db: JsonDirectoryDatabase
15
+ ):
16
+
17
+ config = db.get_config()
18
+ # setup ai model
19
+ print("Beginning setup")
20
+ if "AI_MODEL" not in config:
21
+ model = _get_ai_model()
22
+ if model:
23
+ db.update_config(AI_MODEL=model)
24
+ else:
25
+ print("AI model already specified")
26
+ print("Setup complete! Use 'task config set|get|list' to interact with your configuration options")
27
+ db.commit()
28
+
29
+ def _select_from_list(prompt_text: str, options: list[str]) -> str:
30
+ """Show a numbered menu of options; accept either a number or free text."""
31
+ if options:
32
+ print("\n".join(f" {i}. {opt}" for i, opt in enumerate(options, 1)))
33
+ response = Prompt.ask(prompt_text)
34
+ if response.isdigit() and 1 <= int(response) <= len(options):
35
+ return options[int(response) - 1]
36
+ return response
37
+
38
+ def _get_ai_model() -> str|None:
39
+ provider = _select_from_list(
40
+ "\nSelect a provider (number or name)", list(PROVIDER_ENV_VARS)
41
+ )
42
+ if not provider:
43
+ return
44
+
45
+ model_options = PROVIDER_MODELS.get(provider, [])
46
+ model = _select_from_list(
47
+ f"\nSelect a model for '{provider}' (number or name)", model_options
48
+ )
49
+ if not model:
50
+ return
51
+
52
+ response = f"{provider}/{model}"
53
+ print(f"Storing: [green]{response}[/green]")
54
+
55
+ env_vars = PROVIDER_ENV_VARS.get(provider)
56
+ if env_vars is None:
57
+ print(
58
+ f"Unrecognized provider '{provider}' - check litellm's docs for the "
59
+ f"env var(s) it expects, and add it to PROVIDER_ENV_VARS in "
60
+ f"llm_models.py if you use it regularly."
61
+ )
62
+ elif env_vars:
63
+ print("Make sure these environment variables are set:\n" + "\n".join(f" {v}" for v in env_vars))
64
+ else:
65
+ print(f"'{provider}' runs locally - no credentials needed.")
66
+ return response
67
+
68
+
69
+ if __name__ == "__main__":
70
+ import os
71
+ db = JsonDirectoryDatabase(
72
+ ".taskai/task_db", user=os.getenv("USER")
73
+ )
74
+ db.connect()
75
+ user_setup_service(db)
@@ -1,63 +0,0 @@
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.get_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
- db.update_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
- db.update_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)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes