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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: zino-cli
3
+ Version: 0.1.0
4
+ Summary: Minimal CLI client for zino-daemon
5
+ Project-URL: Repository, https://github.com/you/yourrepo
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: zino-common
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()