zino-cli 0.1.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.
- zino_cli-0.1.0/PKG-INFO +7 -0
- zino_cli-0.1.0/README.md +0 -0
- zino_cli-0.1.0/pyproject.toml +19 -0
- zino_cli-0.1.0/src/zino_cli/__init__.py +203 -0
zino_cli-0.1.0/PKG-INFO
ADDED
zino_cli-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zino-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Minimal CLI client for zino-daemon"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"zino-common",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
zino-cli = "zino_cli:main"
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Repository = "https://github.com/you/yourrepo"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
zino-cli — minimal test client for zino-daemon.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python3 zino-cli.py "your message here"
|
|
7
|
+
python3 zino-cli.py --config /path/to/ZINO.toml "your message here"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tomllib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from zino_common import send_msg, recv_msg, open_uds
|
|
17
|
+
|
|
18
|
+
SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def spinner(description: str):
|
|
22
|
+
i = 0
|
|
23
|
+
try:
|
|
24
|
+
while True:
|
|
25
|
+
print(f"\r {SPINNER_CHARS[i % len(SPINNER_CHARS)]} {description}", end="", flush=True)
|
|
26
|
+
await asyncio.sleep(0.1)
|
|
27
|
+
i += 1
|
|
28
|
+
except asyncio.CancelledError:
|
|
29
|
+
print(f"\r ✓ {description}", flush=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_socket(config_path: str) -> str:
|
|
33
|
+
p = Path(config_path)
|
|
34
|
+
if not p.exists():
|
|
35
|
+
return "/run/zino/daemon.sock"
|
|
36
|
+
with open(p, "rb") as f:
|
|
37
|
+
config = tomllib.load(f)
|
|
38
|
+
return config.get("daemon", {}).get("socket", "/run/zino/daemon.sock")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _tool_progress_label(kind: str, name: str, data: dict) -> str | None:
|
|
42
|
+
"""
|
|
43
|
+
Derive a human-readable spinner label from a tool_progress packet.
|
|
44
|
+
Returns None when the packet should not update the spinner
|
|
45
|
+
(e.g. streaming sub-agent text deltas, which are printed directly).
|
|
46
|
+
"""
|
|
47
|
+
if kind == "tool_call":
|
|
48
|
+
status = data.get("status", "")
|
|
49
|
+
if status == "running":
|
|
50
|
+
executor = data.get("executor", name)
|
|
51
|
+
return f"{name} [{executor}]"
|
|
52
|
+
if status == "complete":
|
|
53
|
+
exit_code = data.get("exit_code", 0)
|
|
54
|
+
suffix = "✗ non-zero exit" if exit_code != 0 else "done"
|
|
55
|
+
has_stderr = data.get("has_stderr", False)
|
|
56
|
+
if exit_code == 0 and has_stderr:
|
|
57
|
+
suffix = "done (stderr)"
|
|
58
|
+
return f"{name} {suffix}"
|
|
59
|
+
|
|
60
|
+
elif kind == "task":
|
|
61
|
+
status = data.get("status", "")
|
|
62
|
+
if status == "thinking":
|
|
63
|
+
return f"{name} iteration {data.get('iteration', '?')} — thinking"
|
|
64
|
+
if status == "running_tool":
|
|
65
|
+
tool = data.get("tool", "")
|
|
66
|
+
executor = data.get("executor", "")
|
|
67
|
+
return f"{name} running {tool} [{executor}]"
|
|
68
|
+
if status == "tool_complete":
|
|
69
|
+
tool = data.get("tool", "")
|
|
70
|
+
exit_code = data.get("exit_code", 0)
|
|
71
|
+
flag = "" if exit_code == 0 else " ✗"
|
|
72
|
+
return f"{name} {tool} complete{flag}"
|
|
73
|
+
if data.get("done"):
|
|
74
|
+
return None # spinner will be cancelled by tool_done
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def send_request(socket_path: str, message: str, channel_id: str | None):
|
|
80
|
+
reader, writer = await open_uds(socket_path)
|
|
81
|
+
|
|
82
|
+
payload = {"message": message}
|
|
83
|
+
if channel_id:
|
|
84
|
+
payload["channel_id"] = channel_id
|
|
85
|
+
|
|
86
|
+
await send_msg(writer, payload)
|
|
87
|
+
|
|
88
|
+
spinner_task = None
|
|
89
|
+
current_label = ""
|
|
90
|
+
in_subagent = False # True while streaming sub-agent text deltas
|
|
91
|
+
|
|
92
|
+
def _cancel_spinner():
|
|
93
|
+
nonlocal spinner_task, in_subagent
|
|
94
|
+
if spinner_task:
|
|
95
|
+
spinner_task.cancel()
|
|
96
|
+
spinner_task = None
|
|
97
|
+
in_subagent = False
|
|
98
|
+
|
|
99
|
+
async def _await_spinner():
|
|
100
|
+
nonlocal spinner_task
|
|
101
|
+
if spinner_task:
|
|
102
|
+
try:
|
|
103
|
+
await spinner_task
|
|
104
|
+
except asyncio.CancelledError:
|
|
105
|
+
pass
|
|
106
|
+
spinner_task = None
|
|
107
|
+
|
|
108
|
+
async def _set_spinner(label: str):
|
|
109
|
+
nonlocal spinner_task, current_label
|
|
110
|
+
if spinner_task and label == current_label:
|
|
111
|
+
return # no change needed
|
|
112
|
+
_cancel_spinner()
|
|
113
|
+
current_label = label
|
|
114
|
+
spinner_task = asyncio.create_task(spinner(label))
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
while True:
|
|
118
|
+
packet = await recv_msg(reader)
|
|
119
|
+
ptype = packet.get("type")
|
|
120
|
+
|
|
121
|
+
if ptype == "chunk":
|
|
122
|
+
# Primary model streaming — spinner should not be running here,
|
|
123
|
+
# but guard in case of interleaving
|
|
124
|
+
if spinner_task:
|
|
125
|
+
_cancel_spinner()
|
|
126
|
+
await _await_spinner()
|
|
127
|
+
print()
|
|
128
|
+
print(packet.get("delta", ""), end="", flush=True)
|
|
129
|
+
|
|
130
|
+
elif ptype == "tool_start":
|
|
131
|
+
# Ensure any trailing primary-model text ends cleanly
|
|
132
|
+
print()
|
|
133
|
+
desc = packet.get("description", "working...")
|
|
134
|
+
await _set_spinner(desc)
|
|
135
|
+
|
|
136
|
+
elif ptype == "tool_progress":
|
|
137
|
+
kind = packet.get("kind", "")
|
|
138
|
+
name = packet.get("name", "")
|
|
139
|
+
data = packet.get("data", {})
|
|
140
|
+
|
|
141
|
+
if kind == "task" and "delta" in data:
|
|
142
|
+
# Sub-agent streaming text — pause spinner, print indented,
|
|
143
|
+
# then resume
|
|
144
|
+
if spinner_task:
|
|
145
|
+
_cancel_spinner()
|
|
146
|
+
await _await_spinner()
|
|
147
|
+
if not in_subagent:
|
|
148
|
+
# First delta for this sub-agent block: newline + prefix
|
|
149
|
+
print(f"\n ┆ ", end="", flush=True)
|
|
150
|
+
in_subagent = True
|
|
151
|
+
print(data["delta"], end="", flush=True)
|
|
152
|
+
else:
|
|
153
|
+
# Status update — resume spinner with new label
|
|
154
|
+
if in_subagent:
|
|
155
|
+
print() # close out sub-agent text line
|
|
156
|
+
in_subagent = False
|
|
157
|
+
label = _tool_progress_label(kind, name, data)
|
|
158
|
+
if label:
|
|
159
|
+
await _set_spinner(label)
|
|
160
|
+
|
|
161
|
+
elif ptype == "tool_done":
|
|
162
|
+
if in_subagent:
|
|
163
|
+
print()
|
|
164
|
+
in_subagent = False
|
|
165
|
+
_cancel_spinner()
|
|
166
|
+
await _await_spinner()
|
|
167
|
+
|
|
168
|
+
elif ptype == "done":
|
|
169
|
+
if in_subagent:
|
|
170
|
+
print()
|
|
171
|
+
print() # final newline after primary stream
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
elif ptype == "error":
|
|
175
|
+
print(f"\nError: {packet.get('message')}", file=sys.stderr)
|
|
176
|
+
break
|
|
177
|
+
|
|
178
|
+
else:
|
|
179
|
+
print(f"\nUnknown packet: {packet}", file=sys.stderr)
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
finally:
|
|
183
|
+
_cancel_spinner()
|
|
184
|
+
await _await_spinner()
|
|
185
|
+
writer.close()
|
|
186
|
+
await writer.wait_closed()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main():
|
|
190
|
+
import argparse
|
|
191
|
+
parser = argparse.ArgumentParser(description="zino-cli: test client for zino-daemon")
|
|
192
|
+
parser.add_argument("message", nargs="*", help="Message to send")
|
|
193
|
+
parser.add_argument("--channel", "-ch", default=None)
|
|
194
|
+
parser.add_argument("--config", "-c", default=os.environ.get("ZINO_CONFIG", "ZINO.toml"))
|
|
195
|
+
args = parser.parse_args()
|
|
196
|
+
|
|
197
|
+
message = " ".join(args.message) if args.message else "What is today's date?"
|
|
198
|
+
socket_path = load_socket(args.config)
|
|
199
|
+
|
|
200
|
+
asyncio.run(send_request(socket_path, message, args.channel))
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|