muse-cli 0.2.2__tar.gz → 0.3.1__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.
- {muse_cli-0.2.2 → muse_cli-0.3.1}/PKG-INFO +2 -2
- {muse_cli-0.2.2 → muse_cli-0.3.1}/README.md +1 -1
- {muse_cli-0.2.2 → muse_cli-0.3.1}/skills/muse-cli/SKILL.md +4 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/__init__.py +1 -1
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/cli.py +70 -21
- muse_cli-0.3.1/src/muse_cli/update.py +150 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/.gitignore +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/LICENSE +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/docs/PROTOCOL.md +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/pyproject.toml +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/__main__.py +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/desc0.bin +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/desc1.bin +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/gateway.py +0 -0
- {muse_cli-0.2.2 → muse_cli-0.3.1}/src/muse_cli/routes.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: muse-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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 `
|
|
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 `
|
|
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.
|
|
@@ -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")
|
|
@@ -81,7 +82,32 @@ def _parse_browser_doc(stdout):
|
|
|
81
82
|
return None
|
|
82
83
|
|
|
83
84
|
|
|
84
|
-
def
|
|
85
|
+
def _interpret_browser(returncode, stdout, stderr, require_json):
|
|
86
|
+
"""Turn one agent-browser invocation into data, or raise RuntimeError.
|
|
87
|
+
|
|
88
|
+
`tab <id>` confirms success with a human line (`✓ Title` plus the URL)
|
|
89
|
+
and exit 0. That is not a failure. A real failure is `success: false`
|
|
90
|
+
or a non-zero exit.
|
|
91
|
+
"""
|
|
92
|
+
doc = _parse_browser_doc(stdout) or _parse_browser_doc(stderr)
|
|
93
|
+
if isinstance(doc, dict) and doc.get("success") is False:
|
|
94
|
+
raise RuntimeError(str(doc.get("error") or "unknown error")[:400])
|
|
95
|
+
if isinstance(doc, dict):
|
|
96
|
+
data = doc.get("data")
|
|
97
|
+
if isinstance(data, dict):
|
|
98
|
+
return data
|
|
99
|
+
if isinstance(data, list):
|
|
100
|
+
return {"items": data}
|
|
101
|
+
if "cookies" in doc or "tabs" in doc:
|
|
102
|
+
return doc
|
|
103
|
+
return data if isinstance(data, dict) else (doc if doc.get("success") else {})
|
|
104
|
+
text = (stderr or stdout or "").strip()
|
|
105
|
+
if returncode == 0 and not require_json:
|
|
106
|
+
return {}
|
|
107
|
+
raise RuntimeError(text[:400] or f"exit {returncode}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _browser_run(argv, require_json=True):
|
|
85
111
|
"""Run agent-browser and return the JSON envelope's data.
|
|
86
112
|
|
|
87
113
|
On failure the tool prints {"success": false, "error": ...}. Some versions
|
|
@@ -89,13 +115,7 @@ def _browser_run(argv):
|
|
|
89
115
|
"""
|
|
90
116
|
import subprocess
|
|
91
117
|
r = subprocess.run(argv, capture_output=True, text=True)
|
|
92
|
-
|
|
93
|
-
if isinstance(doc, dict) and doc.get("success") is False:
|
|
94
|
-
raise RuntimeError(str(doc.get("error") or "unknown error")[:400])
|
|
95
|
-
if r.returncode != 0 or doc is None:
|
|
96
|
-
msg = (r.stderr or r.stdout or "").strip()
|
|
97
|
-
raise RuntimeError(msg[:400] or f"exit {r.returncode}")
|
|
98
|
-
return doc.get("data", {}) if isinstance(doc, dict) else doc
|
|
118
|
+
return _interpret_browser(r.returncode, r.stdout, r.stderr, require_json)
|
|
99
119
|
|
|
100
120
|
|
|
101
121
|
def _export_error_kind(err):
|
|
@@ -107,6 +127,9 @@ def _export_error_kind(err):
|
|
|
107
127
|
return "debug"
|
|
108
128
|
if err == "no muse.ai tab open in Chrome":
|
|
109
129
|
return "tab"
|
|
130
|
+
# A page title from agent-browser means Chrome already answered.
|
|
131
|
+
if "https://muse.ai" in e or e.startswith("✓") or e.startswith("connected to chrome"):
|
|
132
|
+
return "connected"
|
|
110
133
|
return "other"
|
|
111
134
|
|
|
112
135
|
|
|
@@ -172,6 +195,13 @@ def _print_export_failure(err):
|
|
|
172
195
|
print("Open https://muse.ai/, log in, leave that tab open, and run", file=sys.stderr)
|
|
173
196
|
print("`muse-cli auth export` again.", file=sys.stderr)
|
|
174
197
|
return
|
|
198
|
+
if kind == "connected":
|
|
199
|
+
print("Chrome is connected and the muse.ai tab is open.", file=sys.stderr)
|
|
200
|
+
print("Remote debugging is already on. The cookie read did not come back.", file=sys.stderr)
|
|
201
|
+
print("Run `muse-cli auth export` again. If Chrome asks, click Allow.", file=sys.stderr)
|
|
202
|
+
print(file=sys.stderr)
|
|
203
|
+
_print_hand_copy()
|
|
204
|
+
return
|
|
175
205
|
print(err, file=sys.stderr)
|
|
176
206
|
print(file=sys.stderr)
|
|
177
207
|
print("Check both of these, then run `muse-cli auth export` again:", file=sys.stderr)
|
|
@@ -200,10 +230,22 @@ def _browser_cookies():
|
|
|
200
230
|
and (t.get("id") or t.get("tabId"))]
|
|
201
231
|
if not muse_tabs:
|
|
202
232
|
return None, "no muse.ai tab open in Chrome"
|
|
203
|
-
|
|
233
|
+
tab_id = (muse_tabs[0].get("id") or muse_tabs[0].get("tabId")
|
|
234
|
+
or muse_tabs[0].get("targetId"))
|
|
235
|
+
# Switching tabs prints a human confirmation unless --json is set.
|
|
236
|
+
# Exit 0 is success either way; the cookies read is the next call.
|
|
237
|
+
_browser_run(base + ["tab", tab_id, "--json"], require_json=False)
|
|
204
238
|
data = _browser_run(base + ["cookies", "get", "--json"])
|
|
205
|
-
|
|
206
|
-
|
|
239
|
+
raw = []
|
|
240
|
+
if isinstance(data, list):
|
|
241
|
+
raw = data
|
|
242
|
+
elif isinstance(data, dict):
|
|
243
|
+
for key in ("cookies", "items"):
|
|
244
|
+
if isinstance(data.get(key), list):
|
|
245
|
+
raw = data[key]
|
|
246
|
+
break
|
|
247
|
+
jar = [c for c in raw if isinstance(c, dict) and "muse.ai" in (c.get("domain") or c.get("url") or "")]
|
|
248
|
+
return jar, None
|
|
207
249
|
except RuntimeError as e:
|
|
208
250
|
last_err = str(e)
|
|
209
251
|
# Missing debug port and a stuck daemon will not change on retry.
|
|
@@ -655,23 +697,30 @@ def main():
|
|
|
655
697
|
p.add_argument("title")
|
|
656
698
|
p.set_defaults(fn=cmd_session_op(kind))
|
|
657
699
|
sub.add_parser("wake", help="request a VM wake").set_defaults(fn=cmd_wake)
|
|
700
|
+
sub.add_parser("update", help="upgrade this install to the latest release").set_defaults(fn=cmd_update)
|
|
658
701
|
p = sub.add_parser("raw", help="call any gateway method (escape hatch)")
|
|
659
702
|
p.add_argument("method"); p.add_argument("--body", default=None)
|
|
660
703
|
p.add_argument("--param", action="append", default=[], help="path param k=v (repeatable)")
|
|
661
704
|
p.add_argument("--timeout", type=int, default=30); p.set_defaults(fn=cmd_raw)
|
|
662
705
|
|
|
663
706
|
args = ap.parse_args()
|
|
707
|
+
# A piped command prints JSON. The notice stays off unless both streams
|
|
708
|
+
# are a terminal, and `update` is already doing the upgrade.
|
|
709
|
+
notice = None if args.cmd == "update" else start_update_check()
|
|
664
710
|
try:
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
711
|
+
try:
|
|
712
|
+
args.fn(args)
|
|
713
|
+
except AuthError as e:
|
|
714
|
+
print(f"auth error: {e}", file=sys.stderr)
|
|
715
|
+
sys.exit(2)
|
|
716
|
+
except GatewayError as e:
|
|
717
|
+
print(f"gateway error: {e}", file=sys.stderr)
|
|
718
|
+
sys.exit(3)
|
|
719
|
+
except TimeoutError as e:
|
|
720
|
+
print(f"timeout: {e}", file=sys.stderr)
|
|
721
|
+
sys.exit(4)
|
|
722
|
+
finally:
|
|
723
|
+
finish_update_check(notice)
|
|
675
724
|
|
|
676
725
|
|
|
677
726
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|