muse-cli 0.2.2__tar.gz → 0.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: muse-cli
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Command-line client for your personal muse.ai AI agent: chat, automate, and manage side chats, feed, goals, ideas, and sessions from the terminal. No browser needed.
5
5
  Project-URL: Homepage, https://github.com/nikships/muse-cli
6
6
  Project-URL: Documentation, https://github.com/nikships/muse-cli#readme
@@ -75,7 +75,7 @@ to `~/.agents/skills/muse-cli`:
75
75
  curl -fsSL https://raw.githubusercontent.com/nikships/muse-cli/main/install.sh | bash
76
76
  ```
77
77
 
78
- Upgrade with `uv tool upgrade muse-cli`, remove with `uv tool uninstall muse-cli`.
78
+ Upgrade with `muse-cli update`. In a terminal it also checks PyPI once a day and, when a newer release exists, prints that command on stderr. It does not upgrade itself. Set `MUSE_NO_UPDATE_CHECK=1` to silence the notice. The check stays quiet when output is piped or `CI` is set. Remove with `uv tool uninstall muse-cli`.
79
79
 
80
80
  Logging in is a separate one-time step. Chrome shares its cookies after you
81
81
  turn on remote debugging. Follow [Log in once](#log-in-once) before running
@@ -38,7 +38,7 @@ to `~/.agents/skills/muse-cli`:
38
38
  curl -fsSL https://raw.githubusercontent.com/nikships/muse-cli/main/install.sh | bash
39
39
  ```
40
40
 
41
- Upgrade with `uv tool upgrade muse-cli`, remove with `uv tool uninstall muse-cli`.
41
+ Upgrade with `muse-cli update`. In a terminal it also checks PyPI once a day and, when a newer release exists, prints that command on stderr. It does not upgrade itself. Set `MUSE_NO_UPDATE_CHECK=1` to silence the notice. The check stays quiet when output is piped or `CI` is set. Remove with `uv tool uninstall muse-cli`.
42
42
 
43
43
  Logging in is a separate one-time step. Chrome shares its cookies after you
44
44
  turn on remote debugging. Follow [Log in once](#log-in-once) before running
@@ -174,3 +174,7 @@ only for methods with no wrapper.
174
174
  don't guess at crypto or framing.
175
175
  - Respect rate limits. Writes (send, react, execute, session ops) act as the
176
176
  user in their agent: announce them before running, never loop them.
177
+ - A stderr line `A new release of muse-cli is available` is an upgrade
178
+ notice, not a failed command. Upgrade with `muse-cli update`. The check
179
+ runs at most once a day, only in a terminal, and never changes the install
180
+ by itself. `MUSE_NO_UPDATE_CHECK=1` silences it.
@@ -1,3 +1,3 @@
1
1
  """muse-cli: talk to your personal muse.ai AI agent from the terminal."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -17,6 +17,7 @@ import time
17
17
 
18
18
  from . import __version__
19
19
  from .gateway import Gateway, AuthError, GatewayError, load_cookies
20
+ from .update import cmd_update, finish_update_check, start_update_check
20
21
 
21
22
  CONFIG_DIR = os.path.expanduser("~/.config/muse-cli")
22
23
  CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
@@ -655,23 +656,30 @@ def main():
655
656
  p.add_argument("title")
656
657
  p.set_defaults(fn=cmd_session_op(kind))
657
658
  sub.add_parser("wake", help="request a VM wake").set_defaults(fn=cmd_wake)
659
+ sub.add_parser("update", help="upgrade this install to the latest release").set_defaults(fn=cmd_update)
658
660
  p = sub.add_parser("raw", help="call any gateway method (escape hatch)")
659
661
  p.add_argument("method"); p.add_argument("--body", default=None)
660
662
  p.add_argument("--param", action="append", default=[], help="path param k=v (repeatable)")
661
663
  p.add_argument("--timeout", type=int, default=30); p.set_defaults(fn=cmd_raw)
662
664
 
663
665
  args = ap.parse_args()
666
+ # A piped command prints JSON. The notice stays off unless both streams
667
+ # are a terminal, and `update` is already doing the upgrade.
668
+ notice = None if args.cmd == "update" else start_update_check()
664
669
  try:
665
- args.fn(args)
666
- except AuthError as e:
667
- print(f"auth error: {e}", file=sys.stderr)
668
- sys.exit(2)
669
- except GatewayError as e:
670
- print(f"gateway error: {e}", file=sys.stderr)
671
- sys.exit(3)
672
- except TimeoutError as e:
673
- print(f"timeout: {e}", file=sys.stderr)
674
- sys.exit(4)
670
+ try:
671
+ args.fn(args)
672
+ except AuthError as e:
673
+ print(f"auth error: {e}", file=sys.stderr)
674
+ sys.exit(2)
675
+ except GatewayError as e:
676
+ print(f"gateway error: {e}", file=sys.stderr)
677
+ sys.exit(3)
678
+ except TimeoutError as e:
679
+ print(f"timeout: {e}", file=sys.stderr)
680
+ sys.exit(4)
681
+ finally:
682
+ finish_update_check(notice)
675
683
 
676
684
 
677
685
  if __name__ == "__main__":
@@ -0,0 +1,150 @@
1
+ """Tell an interactive user when a newer muse-cli is on PyPI.
2
+
3
+ Same shape as `gh` and `uv`: look at most once a day, never on a pipe or in
4
+ CI, never fail the command, and print the upgrade command instead of
5
+ replacing the install. uv tools do not upgrade themselves.
6
+ """
7
+ import json
8
+ import os
9
+ import shutil
10
+ import sys
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+
15
+ from . import __version__
16
+
17
+ CHECK_INTERVAL = 24 * 60 * 60
18
+ FAIL_BACKOFF = 60 * 60
19
+ STATE_FILE = os.path.expanduser("~/.config/muse-cli/update.json")
20
+ PYPI_URL = "https://pypi.org/pypi/muse-cli/json"
21
+
22
+
23
+ def version_key(value):
24
+ """Stable x.y.z as a tuple. Pre-releases and junk compare as absent."""
25
+ text = str(value).strip().lstrip("v").split("+", 1)[0]
26
+ if not text or any(c.isalpha() for c in text):
27
+ return None
28
+ try:
29
+ return tuple(int(part) for part in text.split("."))
30
+ except ValueError:
31
+ return None
32
+
33
+
34
+ def is_newer(latest, current):
35
+ new, old = version_key(latest), version_key(current)
36
+ return bool(new and old and new > old)
37
+
38
+
39
+ def upgrade_argv():
40
+ """The upgrade command for this install. uv tool, pipx, or pip."""
41
+ prefix = os.path.realpath(sys.prefix)
42
+ sep = os.sep
43
+ if f"{sep}uv{sep}tools{sep}" in prefix:
44
+ return ["uv", "tool", "upgrade", "muse-cli"]
45
+ if f"{sep}pipx{sep}" in prefix:
46
+ return ["pipx", "upgrade", "muse-cli"]
47
+ return [sys.executable, "-m", "pip", "install", "-U", "muse-cli"]
48
+
49
+
50
+ def upgrade_command():
51
+ return " ".join(upgrade_argv())
52
+
53
+
54
+ def _should_check():
55
+ if os.environ.get("MUSE_NO_UPDATE_CHECK"):
56
+ return False
57
+ if os.environ.get("CI"):
58
+ return False
59
+ try:
60
+ return sys.stdout.isatty() and sys.stderr.isatty()
61
+ except (AttributeError, ValueError):
62
+ return False
63
+
64
+
65
+ def _load_state():
66
+ try:
67
+ with open(STATE_FILE) as fh:
68
+ data = json.load(fh)
69
+ except (OSError, json.JSONDecodeError):
70
+ return {}
71
+ return data if isinstance(data, dict) else {}
72
+
73
+
74
+ def _save_state(data):
75
+ try:
76
+ os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
77
+ tmp = STATE_FILE + ".tmp"
78
+ with open(tmp, "w") as fh:
79
+ json.dump(data, fh)
80
+ os.replace(tmp, STATE_FILE)
81
+ except OSError:
82
+ pass
83
+
84
+
85
+ def _fetch_latest():
86
+ req = urllib.request.Request(
87
+ PYPI_URL,
88
+ headers={"Accept": "application/json", "User-Agent": f"muse-cli/{__version__}"},
89
+ )
90
+ with urllib.request.urlopen(req, timeout=2) as resp:
91
+ payload = json.loads(resp.read().decode())
92
+ return str(payload["info"]["version"])
93
+
94
+
95
+ def start_update_check():
96
+ """Begin a check, or return a cached result. None when the notice is off."""
97
+ if not _should_check():
98
+ return None
99
+ state = _load_state()
100
+ latest = state.get("latest") or ""
101
+ checked = float(state.get("checked_at") or 0)
102
+ if latest and time.time() - checked < CHECK_INTERVAL:
103
+ return {"latest": latest}
104
+ holder = {"latest": None, "thread": None}
105
+
106
+ def work():
107
+ try:
108
+ found = _fetch_latest()
109
+ holder["latest"] = found
110
+ _save_state({"checked_at": time.time(), "latest": found})
111
+ except Exception:
112
+ # Offline or PyPI blip: try again in an hour, keep any known version.
113
+ holder["latest"] = latest or None
114
+ _save_state({
115
+ "checked_at": time.time() - CHECK_INTERVAL + FAIL_BACKOFF,
116
+ "latest": latest,
117
+ })
118
+
119
+ thread = threading.Thread(target=work, daemon=True)
120
+ holder["thread"] = thread
121
+ thread.start()
122
+ return holder
123
+
124
+
125
+ def finish_update_check(holder):
126
+ """Print the notice on stderr once the check has had a moment to finish."""
127
+ if not holder:
128
+ return
129
+ thread = holder.get("thread")
130
+ if thread is not None:
131
+ thread.join(timeout=1.5)
132
+ latest = holder.get("latest")
133
+ if not is_newer(latest, __version__):
134
+ return
135
+ print(
136
+ f"\nA new release of muse-cli is available: {__version__} → {latest}\n"
137
+ f"To upgrade, run: {upgrade_command()}\n",
138
+ file=sys.stderr,
139
+ )
140
+
141
+
142
+ def cmd_update(_args):
143
+ argv = upgrade_argv()
144
+ if argv[0] != sys.executable and shutil.which(argv[0]) is None:
145
+ print(f"{argv[0]} is not on PATH.", file=sys.stderr)
146
+ print(f"To upgrade, run: {upgrade_command()}", file=sys.stderr)
147
+ sys.exit(1)
148
+ print(f"running: {upgrade_command()}", file=sys.stderr)
149
+ import subprocess
150
+ raise SystemExit(subprocess.call(argv))
File without changes
File without changes
File without changes
File without changes