lemonpie-cli 0.2.0a1__tar.gz → 0.2.0a3__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 (29) hide show
  1. lemonpie_cli-0.2.0a1/.github/workflows/publish.yml → lemonpie_cli-0.2.0a3/.github/workflows/ci.yml +3 -2
  2. lemonpie_cli-0.2.0a3/.github/workflows/release.yml +64 -0
  3. lemonpie_cli-0.2.0a3/.release-please/config.json +14 -0
  4. lemonpie_cli-0.2.0a3/.release-please/manifest.json +3 -0
  5. lemonpie_cli-0.2.0a3/CHANGELOG.md +35 -0
  6. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/PKG-INFO +1 -1
  7. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/_version.py +2 -2
  8. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/cli/config_cmd.py +2 -2
  9. lemonpie_cli-0.2.0a1/src/lemonpie/cli_utils.py → lemonpie_cli-0.2.0a3/src/lemonpie/cli/parser.py +1 -21
  10. lemonpie_cli-0.2.0a1/src/lemonpie/spinner.py → lemonpie_cli-0.2.0a3/src/lemonpie/cli/ui.py +31 -3
  11. lemonpie_cli-0.2.0a3/src/lemonpie/engine/__init__.py +0 -0
  12. lemonpie_cli-0.2.0a1/src/lemonpie/model_utils.py → lemonpie_cli-0.2.0a3/src/lemonpie/engine/models.py +29 -30
  13. lemonpie_cli-0.2.0a3/src/lemonpie/engine/ollama.py +49 -0
  14. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/main.py +40 -73
  15. lemonpie_cli-0.2.0a3/src/lemonpie/sessions/__init__.py +0 -0
  16. lemonpie_cli-0.2.0a1/src/lemonpie/session_utils.py → lemonpie_cli-0.2.0a3/src/lemonpie/sessions/storage.py +1 -2
  17. lemonpie_cli-0.2.0a1/.github/workflows/release.yml +0 -43
  18. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/.gitignore +0 -0
  19. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/LICENSE +0 -0
  20. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/README.md +0 -0
  21. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/config.example.json +0 -0
  22. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/docs/design/state-machine.md +0 -0
  23. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/pyproject.toml +0 -0
  24. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/__init__.py +0 -0
  25. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/cli/__init__.py +0 -0
  26. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/config.py +0 -0
  27. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/paths.py +0 -0
  28. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/src/lemonpie/time_utils.py +0 -0
  29. {lemonpie_cli-0.2.0a1 → lemonpie_cli-0.2.0a3}/uv.lock +0 -0
@@ -4,8 +4,6 @@ on:
4
4
  push:
5
5
  branches:
6
6
  - main
7
- tags:
8
- - "v*"
9
7
  pull_request:
10
8
  branches:
11
9
  - main
@@ -29,3 +27,6 @@ jobs:
29
27
 
30
28
  - name: Verify build artifact
31
29
  run: uv build
30
+
31
+ # - name: Run tests (Uncomment in Phase 3)
32
+ # run: uv run pytest
@@ -0,0 +1,64 @@
1
+ name: Release & Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ pull-requests: write
11
+ id-token: write
12
+
13
+ jobs:
14
+ release-please:
15
+ name: Manage Release PR & Tags
16
+ runs-on: ubuntu-latest
17
+ outputs:
18
+ release_created: ${{ steps.release.outputs.releases_created }}
19
+ tag_name: ${{ steps.release.outputs.tag_name }}
20
+ steps:
21
+ - uses: googleapis/release-please-action@v4
22
+ id: release
23
+ with:
24
+ config-file: .release-please/config.json
25
+ manifest-file: .release-please/manifest.json
26
+
27
+ pypi-publish:
28
+ name: Build and publish to PyPI
29
+ needs: release-please
30
+ if: ${{ needs.release-please.outputs.release_created == 'true' }}
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write # Required for Trusted Publisher OIDC authentication
35
+
36
+ steps:
37
+ - name: Check out repository
38
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
39
+ with:
40
+ fetch-depth: 0 # Required for hatch-vcs to inspect git history & tags
41
+
42
+ - name: Verify tag is reachable from main
43
+ run: |
44
+ git fetch origin main
45
+ if git merge-base --is-ancestor "${{ github.sha }}" origin/main; then
46
+ echo "Commit ${{ github.sha }} is on main. Proceeding."
47
+ else
48
+ echo "::error::Commit ${{ github.sha }} is not reachable from origin/main. Refusing to publish."
49
+ exit 1
50
+ fi
51
+
52
+ - name: Install uv
53
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
54
+ with:
55
+ version: "0.12.17"
56
+
57
+ - name: Set up Python
58
+ run: uv python install
59
+
60
+ - name: Build package artifacts
61
+ run: uv build
62
+
63
+ - name: Publish to PyPI
64
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,14 @@
1
+ {
2
+ "release-type": "python",
3
+ "packages": {
4
+ ".": {
5
+ "package-name": "lemonpie-cli",
6
+ "changelog-path": "CHANGELOG.md",
7
+ "bump-minor-pre-major": true,
8
+ "bump-patch-for-minor-pre-major": true,
9
+ "prerelease": true,
10
+ "versioning": "prerelease",
11
+ "include-component-in-tag": false
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.0-alpha.3"
3
+ }
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0-alpha.3](https://github.com/famousStratum/LemonPie/compare/v0.2.0-alpha.2...v0.2.0-alpha.3) (2026-09-21)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * resolve tuple/dict mismatch in model alias lookup and dead code in session loading ([#23](https://github.com/famousStratum/LemonPie/issues/23)) ([65152bf](https://github.com/famousStratum/LemonPie/commit/65152bf9d6df0d91addad5aa72f73145383b99d9))
9
+
10
+ ## [0.2.0-alpha.2](https://github.com/famousStratum/LemonPie/compare/lemonpie-cli-v0.2.0-alpha.1...lemonpie-cli-v0.2.0-alpha.2) (2026-09-21)
11
+
12
+
13
+ ### Features
14
+
15
+ * **cli:** enhance error handling, model defaults, timeouts, and connection status ([9147dc0](https://github.com/famousStratum/LemonPie/commit/9147dc0293065f0b2227ac8f36cdacf0502ca7c4))
16
+ * **cli:** improve connection error handling, timeouts, and documentation ([83e2659](https://github.com/famousStratum/LemonPie/commit/83e2659182717d82feb28be4b85d14e85679d751))
17
+ * **paths:** Depend on platformdirs for config/session paths; bump requires-python to >=3.11 ([6fbf8e9](https://github.com/famousStratum/LemonPie/commit/6fbf8e92d27cbec20c57f404aecf99940329b238))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **build:** explicitly specify hatchling wheel package directory ([705980a](https://github.com/famousStratum/LemonPie/commit/705980a9eda1672829119db0fcd2aab4a9c04101))
23
+ * **build:** specify hatchling package target ([b759f2d](https://github.com/famousStratum/LemonPie/commit/b759f2d086bccb7b39d456844d1bb7403410e8d4))
24
+ * **cli:** import missing resolve_session in main entrypoint ([f5c4c5a](https://github.com/famousStratum/LemonPie/commit/f5c4c5ae1cc7ade21ba41eb92a9ecc640a7fe969))
25
+ * **cli:** reorder main entrypoint control flow and fix argument handling ([3a182d2](https://github.com/famousStratum/LemonPie/commit/3a182d2485af36f8a98a28cb41459f968fc9f4d4))
26
+ * **cli:** reorder main entrypoint control flow and resolve model assignment ([ad944f9](https://github.com/famousStratum/LemonPie/commit/ad944f93e2661eb3f2cc1abfe6cdeedc04f6ee86))
27
+ * **cli:** resolve NameError in main entrypoint ([532d661](https://github.com/famousStratum/LemonPie/commit/532d6617ff589de5f5def690e6390b1341517f84))
28
+
29
+
30
+ ### Documentation
31
+
32
+ * Add roadmap section to README and initial state-machine design doc ([fa9b000](https://github.com/famousStratum/LemonPie/commit/fa9b0009aaca85cbcd3e5a6f1e1c6c4d3bc40196))
33
+ * update README for src/ layout and platformdirs ([a15cde2](https://github.com/famousStratum/LemonPie/commit/a15cde25a08c49d11366bc64c31aa80da9590838))
34
+ * update README.md ([cf7a046](https://github.com/famousStratum/LemonPie/commit/cf7a0468474cc6bb47dbf33148a6b18d11b44fdf))
35
+ * update roadmap in README.md ([d0a3316](https://github.com/famousStratum/LemonPie/commit/d0a33167361efd400682f54780aaf3fff738e41d))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lemonpie-cli
3
- Version: 0.2.0a1
3
+ Version: 0.2.0a3
4
4
  Summary: LemonPie — A lightweight Ollama client for terminal users
5
5
  Project-URL: homepage, https://versicode.com/lemonpie
6
6
  Project-URL: repository, https://github.com/famousStratum/LemonPie
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0a1'
22
- __version_tuple__ = version_tuple = (0, 2, 0, 'a1')
21
+ __version__ = version = '0.2.0a3'
22
+ __version_tuple__ = version_tuple = (0, 2, 0, 'a3')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -15,8 +15,8 @@ import argparse, json, os, sys
15
15
  from urllib.parse import urlparse
16
16
 
17
17
  from lemonpie.paths import CONFIG_FILE
18
- from lemonpie.cli_utils import confirm
19
- from lemonpie.model_utils import (
18
+ from lemonpie.cli.ui import confirm
19
+ from lemonpie.engine.models import (
20
20
  find_by_alias,
21
21
  find_by_name,
22
22
  )
@@ -1,12 +1,11 @@
1
1
  import argparse
2
2
 
3
- from lemonpie.session_utils import list_sessions, read_current
4
-
5
3
  try:
6
4
  from lemonpie._version import __version__
7
5
  except ImportError:
8
6
  __version__ = "0.0.0.dev0"
9
7
 
8
+
10
9
  def build_parser():
11
10
  parser = argparse.ArgumentParser(prog="lm", description="Run LemonPie with a given model or prompt")
12
11
  parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
@@ -21,22 +20,3 @@ def build_parser():
21
20
  parser.add_argument("--delete-all", action="store_true", help="Delete all stored sessions")
22
21
  parser.add_argument("prompt", nargs="*", help="Prompt text")
23
22
  return parser
24
-
25
- def confirm(prompt):
26
- ans = input(f"{prompt} (y/N): ").strip().lower()
27
- return ans in ("y", "yes")
28
-
29
- def print_session(session):
30
- print(f"Session {session['id']} — {session.get('title') or '<no title>'}")
31
- for turn in session["history"]:
32
- role = turn["role"]
33
- content = turn["content"]
34
- print(f"{role} [{turn.get('ts','')}] : {content}")
35
-
36
- def print_sessions_list():
37
- sessions = list_sessions()
38
- if not sessions:
39
- print("No sessions found.")
40
- return
41
- for sid, title, created, updated, is_current in sessions:
42
- print(f"{is_current} {sid} {title} created: {created}, updated: {updated}")
@@ -1,17 +1,45 @@
1
- import sys, time, random, threading
1
+ import random
2
+ import sys
3
+ import threading
4
+ import time
5
+
6
+ from lemonpie.sessions.storage import list_sessions
2
7
 
3
8
  CONNECTING_MSGS = [
4
9
  "Connecting",
5
10
  "Squeezing the lemons",
6
- "Knocking on Ollama’s door"
11
+ "Knocking on Ollama’s door",
7
12
  ]
8
13
 
9
14
  WAITING_MSGS = [
10
15
  "Waiting for model response",
11
16
  "The pie is cooking",
12
- "Brewing tokens"
17
+ "Brewing tokens",
13
18
  ]
14
19
 
20
+
21
+ def confirm(prompt):
22
+ ans = input(f"{prompt} (y/N): ").strip().lower()
23
+ return ans in ("y", "yes")
24
+
25
+
26
+ def print_session(session):
27
+ print(f"Session {session['id']} — {session.get('title') or '<no title>'}")
28
+ for turn in session["history"]:
29
+ role = turn["role"]
30
+ content = turn["content"]
31
+ print(f"{role} [{turn.get('ts', '')}] : {content}")
32
+
33
+
34
+ def print_sessions_list():
35
+ sessions = list_sessions()
36
+ if not sessions:
37
+ print("No sessions found.")
38
+ return
39
+ for sid, title, created, updated, is_current in sessions:
40
+ print(f"{is_current} {sid} {title} created: {created}, updated: {updated}")
41
+
42
+
15
43
  class StatusSpinner:
16
44
  def __init__(self, messages, interval=0.5):
17
45
  self.messages = messages
File without changes
@@ -1,19 +1,44 @@
1
1
  import sys
2
2
 
3
+ from lemonpie.sessions.storage import save_session
3
4
 
4
- from lemonpie.session_utils import save_session
5
+
6
+ def find_by_alias(cfg, alias):
7
+ for idx, m in enumerate(cfg.get("models", [])):
8
+ if m.get("alias") == alias:
9
+ return idx, m
10
+ return None, None
11
+
12
+
13
+ def find_by_name(cfg, name):
14
+ for idx, m in enumerate(cfg.get("models", [])):
15
+ if m.get("name") == name:
16
+ return idx, m
17
+ return None, None
18
+
19
+
20
+ def resolve_model(cfg, args_model, default_model_name):
21
+ if args_model:
22
+ _, alias_entry = find_by_alias(cfg, args_model)
23
+ if alias_entry:
24
+ return alias_entry["name"]
25
+ _, name_entry = find_by_name(cfg, args_model)
26
+ if name_entry:
27
+ return name_entry["name"]
28
+ return args_model
29
+ return default_model_name
5
30
 
6
31
 
7
32
  def handle_model_switch(cfg, session, requested_model):
8
33
  """
9
- Handles validating a requested model/alias and updates the active session
34
+ Handles validating a requested model/alias and updates the active session
10
35
  if confirmed by the user.
11
36
  """
12
37
  if not session or not requested_model:
13
38
  return session
14
39
 
15
- alias_entry = find_by_alias(cfg, requested_model)
16
- name_entry = find_by_name(cfg, requested_model)
40
+ _, alias_entry = find_by_alias(cfg, requested_model)
41
+ _, name_entry = find_by_name(cfg, requested_model)
17
42
 
18
43
  if alias_entry:
19
44
  new_model = alias_entry["name"]
@@ -34,29 +59,3 @@ def handle_model_switch(cfg, session, requested_model):
34
59
  print("Aborted model change.")
35
60
 
36
61
  return session
37
-
38
-
39
- def find_by_alias(cfg, alias):
40
- for idx, m in enumerate(cfg.get("models", [])):
41
- if m.get("alias") == alias:
42
- return idx, m
43
- return None, None
44
-
45
-
46
- def find_by_name(cfg, name):
47
- for idx, m in enumerate(cfg.get("models", [])):
48
- if m.get("name") == name:
49
- return idx, m
50
- return None, None
51
-
52
-
53
- def resolve_model(cfg, args_model, default_model_name):
54
- if args_model:
55
- alias_entry = find_by_alias(cfg, args_model)
56
- if alias_entry:
57
- return alias_entry["name"]
58
- name_entry = find_by_name(cfg, args_model)
59
- if name_entry:
60
- return name_entry["name"]
61
- return args_model
62
- return default_model_name
@@ -0,0 +1,49 @@
1
+ """Ollama API interaction abstractions: client construction and streaming chat."""
2
+
3
+ from ollama import Client
4
+
5
+
6
+ def build_client(server_host, timeout):
7
+ return Client(host=server_host, timeout=timeout)
8
+
9
+
10
+ def stream_chat(client, model, messages):
11
+ """
12
+ Stream a chat completion from Ollama, yielding text content chunks.
13
+
14
+ The underlying stream is closed automatically when this generator is
15
+ closed (e.g. via a `for` loop finishing, an exception propagating, or an
16
+ explicit `.close()` call on the generator by the caller).
17
+ """
18
+ stream = client.chat(model=model, messages=messages, stream=True)
19
+ try:
20
+ for chunk in stream:
21
+ # Standard Ollama client attribute access
22
+ content = getattr(getattr(chunk, "message", None), "content", None)
23
+ if content is None and isinstance(chunk, dict):
24
+ content = chunk.get("message", {}).get("content", "")
25
+ if content:
26
+ yield content
27
+ finally:
28
+ if hasattr(stream, "close"):
29
+ stream.close()
30
+
31
+
32
+ def resolve_ollama_error(exc, server_host):
33
+ """
34
+ Map an exception raised during an Ollama request to a (message, hint)
35
+ pair suitable for printing to the user. `hint` may be None.
36
+ """
37
+ msg = str(exc)
38
+ if "[Errno 111]" in msg or "Connection refused" in msg:
39
+ return (
40
+ "Ollama server did not respond: Connection refused.",
41
+ f"Hint: Ollama may not be running, or it may not be listening on: {server_host}\n"
42
+ "Check that the server is started with `ollama serve` and bound to the correct interface.",
43
+ )
44
+ if "[Errno 110]" in msg or "timed out" in msg.lower():
45
+ return (
46
+ "Failed to contact Ollama server: Connection timed out.",
47
+ f"Hint: The server at {server_host} is unreachable. Verify IP/port and network.",
48
+ )
49
+ return (f"Failed to contact Ollama server: {msg}", None)
@@ -17,11 +17,7 @@ Quick usage
17
17
  See README.md for details and maintainer notes.
18
18
  """
19
19
 
20
- import argparse, errno, httpx, json, os, sys
21
- from datetime import datetime, timezone
22
- from ollama import Client
23
-
24
- from lemonpie.paths import CURRENT_FILE, SESSIONS_DIR
20
+ import sys
25
21
 
26
22
  from lemonpie.config import (
27
23
  load_config,
@@ -29,55 +25,47 @@ from lemonpie.config import (
29
25
  build_timeout,
30
26
  )
31
27
 
32
- from lemonpie.cli_utils import (
33
- build_parser,
34
- list_sessions,
28
+ from lemonpie.cli.parser import build_parser
29
+ from lemonpie.cli.ui import (
35
30
  print_session,
36
31
  print_sessions_list,
32
+ StatusSpinner,
33
+ CONNECTING_MSGS,
34
+ WAITING_MSGS,
37
35
  )
38
36
 
39
- from lemonpie.model_utils import (
40
- find_by_alias,
37
+ from lemonpie.engine.models import (
41
38
  find_by_name,
42
39
  handle_model_switch,
43
40
  resolve_model,
44
41
  )
45
42
 
46
- from lemonpie.session_utils import (
43
+ from lemonpie.engine.ollama import (
44
+ build_client,
45
+ stream_chat,
46
+ resolve_ollama_error,
47
+ )
48
+
49
+ from lemonpie.sessions.storage import (
47
50
  create_session,
48
- load_session_by_id,
49
51
  delete_session_by_id,
50
52
  delete_all_sessions,
51
53
  resolve_session,
52
54
  close_session,
53
55
  load_session,
54
56
  save_session,
55
- delete_session,
56
57
  read_current,
57
- write_current,
58
- clear_current,
59
- default_title,
60
- set_title,
61
58
  update_title,
62
59
  )
63
60
 
64
- from lemonpie.spinner import (
65
- StatusSpinner,
66
- CONNECTING_MSGS,
67
- WAITING_MSGS,
68
- )
69
-
70
61
  from lemonpie.time_utils import now_ts
71
62
 
72
- cfg = load_config()
73
- server_host = resolve_server_host(cfg)
74
- timeout = build_timeout()
75
- client = Client(host=server_host, timeout=timeout)
76
-
77
- models_list = cfg["models"]
78
- default_model_name = cfg.get("default") or None
79
-
80
63
  def main():
64
+ cfg = load_config()
65
+ server_host = resolve_server_host(cfg)
66
+ timeout = build_timeout()
67
+ client = build_client(server_host, timeout)
68
+ default_model_name = cfg.get("default") or None
81
69
  parser = build_parser()
82
70
  args = parser.parse_args()
83
71
 
@@ -85,18 +73,9 @@ def main():
85
73
  parser.print_help()
86
74
  sys.exit(1)
87
75
 
88
- model_flag = any(a in ("-m", "--model") for a in sys.argv[1:])
89
- resolve_model(cfg, args.model, default_model_name)
90
-
91
- model = default_model_name
92
- if model is None:
93
- print("No default model configured.")
94
- print("Use lmConfig to add one or pass a known alias with -m.")
95
- sys.exit(1)
96
-
97
76
  current_id = read_current()
98
77
 
99
- # Direct CLI actions
78
+ # Direct CLI actions
100
79
 
101
80
  if args.list:
102
81
  print_sessions_list(); sys.exit(0)
@@ -122,10 +101,18 @@ def main():
122
101
  confirm = input("Are you sure you want to delete ALL sessions? (y/N): ")
123
102
  if confirm.lower() in ("y", "yes"):
124
103
  count = delete_all_sessions()
125
- print(f"Deleted {count} session(s).")
104
+ print(f"Deleted {count} session(s)."); sys.exit(0)
126
105
  else:
127
106
  print("Aborted."); sys.exit(0)
128
107
 
108
+ model_flag = args.model is not None
109
+ model = resolve_model(cfg, args.model, default_model_name)
110
+
111
+ if model is None:
112
+ print("No default model configured.")
113
+ print("Use lmConfig to add one or pass a known alias with -m.")
114
+ sys.exit(1)
115
+
129
116
  if model_flag and not current_id and not args.new and not args.session and not args.prompt:
130
117
  print(f'No current session.\n'
131
118
  f'To create a new session with "{args.model}" use: lm -n -m {args.model}\n'
@@ -141,6 +128,8 @@ def main():
141
128
 
142
129
  if args.title:
143
130
  session = update_title(session, model, args.title, args.prompt)
131
+ if not args.prompt:
132
+ sys.exit(0)
144
133
 
145
134
  if args.prompt:
146
135
  user_prompt = " ".join(args.prompt)
@@ -163,50 +152,28 @@ def main():
163
152
  assistant_reply = ""
164
153
  reply_ts = None
165
154
  first_chunk_seen = False
155
+ chunks = stream_chat(client, model, session["history"])
166
156
 
167
157
  try:
168
- stream = client.chat(model=model, messages=session["history"], stream=True)
169
- spinner.stop()
170
-
171
- spinner = StatusSpinner(WAITING_MSGS)
172
- spinner.start()
173
-
174
- for chunk in stream:
158
+ for content in chunks:
175
159
  if not first_chunk_seen:
176
160
  spinner.stop()
177
161
  first_chunk_seen = True
178
-
179
- # Standard Ollama client attribute access
180
- content = getattr(getattr(chunk, "message", None), "content", None)
181
- if content is None and isinstance(chunk, dict):
182
- content = chunk.get("message", {}).get("content", "")
183
-
184
- if content:
185
- print(content, end="", flush=True)
186
- assistant_reply += content
162
+ print(content, end="", flush=True)
163
+ assistant_reply += content
187
164
  print()
188
165
  reply_ts = now_ts()
189
166
 
190
167
  except KeyboardInterrupt:
191
- try:
192
- if 'stream' in locals() and hasattr(stream, "close"):
193
- stream.close()
194
- except Exception:
195
- pass
168
+ chunks.close()
196
169
  print("\nInterrupted by user.")
197
170
  sys.exit(1)
198
171
 
199
172
  except Exception as e:
200
- msg = str(e)
201
- if "[Errno 111]" in msg or "Connection refused" in msg:
202
- print("\nOllama server did not respond: Connection refused.")
203
- print("Hint: Ollama may not be running, or it may not be listening on:", server_host)
204
- print("Check that the server is started with `ollama serve` and bound to the correct interface.")
205
- elif "[Errno 110]" in msg or "timed out" in msg.lower():
206
- print("\nFailed to contact Ollama server: Connection timed out.")
207
- print("Hint: The server at", server_host, "is unreachable. Verify IP/port and network.")
208
- else:
209
- print("\nFailed to contact Ollama server:", msg)
173
+ title, hint = resolve_ollama_error(e, server_host)
174
+ print("\n" + title)
175
+ if hint:
176
+ print(hint)
210
177
  sys.exit(1)
211
178
 
212
179
  finally:
File without changes
@@ -41,7 +41,7 @@ def resolve_session(args, model, current_id):
41
41
  def load_session(session_id):
42
42
  try:
43
43
  with open(session_path(session_id), "r", encoding="utf-8") as f:
44
- return json.load(f)
44
+ s = json.load(f)
45
45
  # Backfill created_at/updated_at from history if missing
46
46
  if "created_at" not in s or not s.get("created_at"):
47
47
  if s.get("history"):
@@ -179,6 +179,5 @@ def list_sessions():
179
179
  sessions.append((data["id"], data.get("title") or "<no title>", created, updated, is_current))
180
180
  except Exception:
181
181
  # skip invalid JSON files
182
- print("DEBUG: loaded", fname, "->", data.get("id"))
183
182
  continue
184
183
  return sessions
@@ -1,43 +0,0 @@
1
- name: Release Package to PyPI
2
-
3
- on:
4
- push:
5
- tags:
6
- - "v*"
7
-
8
- jobs:
9
- pypi-publish:
10
- name: Build and publish to PyPI
11
- runs-on: ubuntu-latest
12
- environment: pypi
13
- permissions:
14
- id-token: write # Required for Trusted Publisher OIDC authentication
15
-
16
- steps:
17
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
- with:
19
- fetch-depth: 0
20
-
21
- - name: Verify tag is reachable from main
22
- run: |
23
- git fetch origin main
24
- if git merge-base --is-ancestor "${{ github.sha }}" origin/main; then
25
- echo "Tag ${{ github.ref_name }} (commit ${{ github.sha }}) is on main. Proceeding."
26
- else
27
- echo "::error::Tag ${{ github.ref_name }} (commit ${{ github.sha }}) is not reachable from origin/main. Refusing to publish — merge to main before tagging."
28
- exit 1
29
- fi
30
-
31
- - name: Install uv
32
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
33
- with:
34
- version: "0.12.17"
35
-
36
- - name: Set up Python
37
- run: uv python install
38
-
39
- - name: Build package artifacts
40
- run: uv build
41
-
42
- - name: Publish to PyPI
43
- uses: pypa/gh-action-pypi-publish@release/v1
File without changes
File without changes
File without changes