cycls 0.0.1.1__tar.gz → 0.0.2.81__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,276 @@
1
+ Metadata-Version: 2.4
2
+ Name: cycls
3
+ Version: 0.0.2.81
4
+ Summary: Distribute Intelligence
5
+ Author: Mohammed J. AlRujayi
6
+ Author-email: mj@cycls.com
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Provides-Extra: modal
16
+ Requires-Dist: cloudpickle (>=3.1.1,<4.0.0)
17
+ Requires-Dist: docker (>=7.1.0,<8.0.0)
18
+ Requires-Dist: fastapi (>=0.111.0,<0.112.0)
19
+ Requires-Dist: grpcio (>=1.76.0,<2.0.0)
20
+ Requires-Dist: httpx (>=0.27.0,<0.28.0)
21
+ Requires-Dist: modal (>=1.1.0,<2.0.0) ; extra == "modal"
22
+ Requires-Dist: protobuf (>=6.0,<7.0)
23
+ Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
24
+ Description-Content-Type: text/markdown
25
+
26
+ <h3 align="center">
27
+ Distribute Intelligence
28
+ </h3>
29
+
30
+ <h4 align="center">
31
+ <a href="https://cycls.com">Website</a> |
32
+ <a href="https://docs.cycls.com">Docs</a>
33
+ </h4>
34
+
35
+ <h4 align="center">
36
+ <a href="https://pypi.python.org/pypi/cycls"><img src="https://img.shields.io/pypi/v/cycls.svg?label=cycls+pypi&color=blueviolet" alt="cycls Python package on PyPi" /></a>
37
+ <a href="https://github.com/Cycls/cycls/actions/workflows/tests.yml"><img src="https://github.com/Cycls/cycls/actions/workflows/tests.yml/badge.svg" alt="Tests" /></a>
38
+ <a href="https://blog.cycls.com"><img src="https://img.shields.io/badge/newsletter-blueviolet.svg?logo=substack&label=cycls" alt="Cycls newsletter" /></a>
39
+ <a href="https://x.com/cyclsai">
40
+ <img src="https://img.shields.io/twitter/follow/CyclsAI" alt="Cycls Twitter" />
41
+ </a>
42
+ </h4>
43
+
44
+ ---
45
+
46
+ # Cycls
47
+
48
+ The open-source SDK for distributing AI agents.
49
+
50
+ The function is the unit of abstraction in cycls. Your agent logic lives in a plain Python function — the decorator layers on everything else: containerization, authentication, deployment, analytics. You write the function, the `@` handles the infrastructure.
51
+
52
+ ## Distribute Intelligence
53
+
54
+ Write a function. Deploy it as an API, a web interface, or both. Add authentication, analytics, and monetization with flags.
55
+
56
+ ```python
57
+ import cycls
58
+
59
+ cycls.api_key = "YOUR_CYCLS_API_KEY"
60
+
61
+ @cycls.agent(pip=["openai"])
62
+ async def agent(context):
63
+ from openai import AsyncOpenAI
64
+ client = AsyncOpenAI()
65
+
66
+ stream = await client.responses.create(
67
+ model="o3-mini",
68
+ input=context.messages,
69
+ stream=True,
70
+ reasoning={"effort": "medium", "summary": "auto"},
71
+ )
72
+
73
+ async for event in stream:
74
+ if event.type == "response.reasoning_summary_text.delta":
75
+ yield {"type": "thinking", "thinking": event.delta} # Renders as thinking bubble
76
+ elif event.type == "response.output_text.delta":
77
+ yield event.delta
78
+
79
+ agent.deploy() # Live at https://agent.cycls.ai
80
+ ```
81
+
82
+ ## Installation
83
+
84
+ ```bash
85
+ pip install cycls
86
+ ```
87
+
88
+ Requires Docker.
89
+
90
+ ## What You Get
91
+
92
+ - **Streaming API** - OpenAI-compatible `/chat/completions` endpoint
93
+ - **Web Interface** - Chat UI served automatically
94
+ - **Authentication** - `auth=True` enables JWT-based access control
95
+ - **Analytics** - `analytics=True` tracks usage
96
+ - **Monetization** - `plan="cycls_pass"` integrates with [Cycls Pass](https://cycls.ai) subscriptions
97
+ - **Native UI Components** - Render thinking bubbles, tables, code blocks in responses
98
+
99
+ ## Running
100
+
101
+ ```python
102
+ agent.local() # Development with hot-reload (localhost:8080)
103
+ agent.local(watch=False) # Development without hot-reload
104
+ agent.deploy() # Production: https://agent.cycls.ai
105
+ ```
106
+
107
+ Get an API key at [cycls.com](https://cycls.com).
108
+
109
+ ## Authentication & Analytics
110
+
111
+ ```python
112
+ @cycls.agent(pip=["openai"], auth=True, analytics=True)
113
+ async def agent(context):
114
+ # context.user available when auth=True
115
+ user = context.user # User(id, email, name, plans)
116
+ yield f"Hello {user.name}!"
117
+ ```
118
+
119
+ | Flag | Description |
120
+ |------|-------------|
121
+ | `auth=True` | Universal user pool via Cycls Pass (Clerk-based). You can also use your own Clerk auth. |
122
+ | `analytics=True` | Rich usage metrics available on the Cycls dashboard. |
123
+ | `plan="cycls_pass"` | Monetization via Cycls Pass subscriptions. Enables both auth and analytics. |
124
+
125
+ ## Native UI Components
126
+
127
+ Yield structured objects for rich streaming responses:
128
+
129
+ ```python
130
+ @cycls.agent()
131
+ async def demo(context):
132
+ yield {"type": "thinking", "thinking": "Analyzing the request..."}
133
+ yield "Here's what I found:\n\n"
134
+
135
+ yield {"type": "table", "headers": ["Name", "Status"]}
136
+ yield {"type": "table", "row": ["Server 1", "Online"]}
137
+ yield {"type": "table", "row": ["Server 2", "Offline"]}
138
+
139
+ yield {"type": "code", "code": "result = analyze(data)", "language": "python"}
140
+ yield {"type": "callout", "callout": "Analysis complete!", "style": "success"}
141
+ ```
142
+
143
+ | Component | Streaming |
144
+ |-----------|-----------|
145
+ | `{"type": "thinking", "thinking": "..."}` | Yes |
146
+ | `{"type": "code", "code": "...", "language": "..."}` | Yes |
147
+ | `{"type": "table", "headers": [...]}` | Yes |
148
+ | `{"type": "table", "row": [...]}` | Yes |
149
+ | `{"type": "status", "status": "..."}` | Yes |
150
+ | `{"type": "callout", "callout": "...", "style": "..."}` | Yes |
151
+ | `{"type": "image", "src": "..."}` | Yes |
152
+
153
+ ### Thinking Bubbles
154
+
155
+ The `{"type": "thinking", "thinking": "..."}` component renders as a collapsible thinking bubble in the UI. Each yield appends to the same bubble until a different component type is yielded:
156
+
157
+ ```python
158
+ # Multiple yields build one thinking bubble
159
+ yield {"type": "thinking", "thinking": "Let me "}
160
+ yield {"type": "thinking", "thinking": "analyze this..."}
161
+ yield {"type": "thinking", "thinking": " Done thinking."}
162
+
163
+ # Then output the response
164
+ yield "Here's what I found..."
165
+ ```
166
+
167
+ This works seamlessly with OpenAI's reasoning models - just map reasoning summaries to the thinking component.
168
+
169
+ ## Context Object
170
+
171
+ ```python
172
+ @cycls.agent()
173
+ async def chat(context):
174
+ context.messages # [{"role": "user", "content": "..."}]
175
+ context.messages.raw # Full data including UI component parts
176
+ context.user # User(id, email, name, plans) when auth=True
177
+ ```
178
+
179
+ ## API Endpoints
180
+
181
+ | Endpoint | Format |
182
+ |----------|--------|
183
+ | `POST chat/cycls` | Cycls streaming protocol |
184
+ | `POST chat/completions` | OpenAI-compatible |
185
+
186
+ ## Streaming Protocol
187
+
188
+ Cycls streams structured components over SSE:
189
+
190
+ ```
191
+ data: {"type": "thinking", "thinking": "Let me "}
192
+ data: {"type": "thinking", "thinking": "analyze..."}
193
+ data: {"type": "text", "text": "Here's the answer"}
194
+ data: {"type": "callout", "callout": "Done!", "style": "success"}
195
+ data: [DONE]
196
+ ```
197
+
198
+ See [docs/streaming-protocol.md](docs/streaming-protocol.md) for frontend integration.
199
+
200
+ ## Declarative Infrastructure
201
+
202
+ Define your entire runtime in the decorator:
203
+
204
+ ```python
205
+ @cycls.agent(
206
+ pip=["openai", "pandas", "numpy"],
207
+ apt=["ffmpeg", "libmagic1"],
208
+ copy=["./utils.py", "./models/", "/absolute/path/to/config.json"],
209
+ copy_public=["./assets/logo.png", "./static/"],
210
+ )
211
+ async def my_agent(context):
212
+ ...
213
+ ```
214
+
215
+ ### `pip` - Python Packages
216
+
217
+ Install any packages from PyPI. These are installed during the container build.
218
+
219
+ ```python
220
+ pip=["openai", "pandas", "numpy", "transformers"]
221
+ ```
222
+
223
+ ### `apt` - System Packages
224
+
225
+ Install system-level dependencies via apt-get. Need ffmpeg for audio processing? ImageMagick for images? Just declare it.
226
+
227
+ ```python
228
+ apt=["ffmpeg", "imagemagick", "libpq-dev"]
229
+ ```
230
+
231
+ ### `copy` - Bundle Files and Directories
232
+
233
+ Include local files and directories in your container. Works with both relative and absolute paths. Copies files and entire directory trees.
234
+
235
+ ```python
236
+ copy=[
237
+ "./utils.py", # Single file, relative path
238
+ "./models/", # Entire directory
239
+ "/home/user/configs/app.json", # Absolute path
240
+ ]
241
+ ```
242
+
243
+ Then import them in your function:
244
+
245
+ ```python
246
+ @cycls.agent(copy=["./utils.py"])
247
+ async def chat(context):
248
+ from utils import helper_function # Your bundled module
249
+ ...
250
+ ```
251
+
252
+ ### `copy_public` - Static Files
253
+
254
+ Files and directories served at the `/public` endpoint. Perfect for images, downloads, or any static assets your agent needs to reference.
255
+
256
+ ```python
257
+ copy_public=["./assets/logo.png", "./downloads/"]
258
+ ```
259
+
260
+ Access them at `https://your-agent.cycls.ai/public/logo.png`.
261
+
262
+ ---
263
+
264
+ ### What You Get
265
+
266
+ - **One file** - Code, dependencies, configuration, and infrastructure together
267
+ - **Instant deploys** - Unchanged code deploys in seconds from cache
268
+ - **No drift** - What you see is what runs. Always.
269
+ - **Just works** - Closures, lambdas, dynamic imports - your function runs exactly as written
270
+
271
+ No YAML. No Dockerfiles. No infrastructure repo. The code is the deployment.
272
+
273
+ ## License
274
+
275
+ MIT
276
+
@@ -0,0 +1,250 @@
1
+ <h3 align="center">
2
+ Distribute Intelligence
3
+ </h3>
4
+
5
+ <h4 align="center">
6
+ <a href="https://cycls.com">Website</a> |
7
+ <a href="https://docs.cycls.com">Docs</a>
8
+ </h4>
9
+
10
+ <h4 align="center">
11
+ <a href="https://pypi.python.org/pypi/cycls"><img src="https://img.shields.io/pypi/v/cycls.svg?label=cycls+pypi&color=blueviolet" alt="cycls Python package on PyPi" /></a>
12
+ <a href="https://github.com/Cycls/cycls/actions/workflows/tests.yml"><img src="https://github.com/Cycls/cycls/actions/workflows/tests.yml/badge.svg" alt="Tests" /></a>
13
+ <a href="https://blog.cycls.com"><img src="https://img.shields.io/badge/newsletter-blueviolet.svg?logo=substack&label=cycls" alt="Cycls newsletter" /></a>
14
+ <a href="https://x.com/cyclsai">
15
+ <img src="https://img.shields.io/twitter/follow/CyclsAI" alt="Cycls Twitter" />
16
+ </a>
17
+ </h4>
18
+
19
+ ---
20
+
21
+ # Cycls
22
+
23
+ The open-source SDK for distributing AI agents.
24
+
25
+ The function is the unit of abstraction in cycls. Your agent logic lives in a plain Python function — the decorator layers on everything else: containerization, authentication, deployment, analytics. You write the function, the `@` handles the infrastructure.
26
+
27
+ ## Distribute Intelligence
28
+
29
+ Write a function. Deploy it as an API, a web interface, or both. Add authentication, analytics, and monetization with flags.
30
+
31
+ ```python
32
+ import cycls
33
+
34
+ cycls.api_key = "YOUR_CYCLS_API_KEY"
35
+
36
+ @cycls.agent(pip=["openai"])
37
+ async def agent(context):
38
+ from openai import AsyncOpenAI
39
+ client = AsyncOpenAI()
40
+
41
+ stream = await client.responses.create(
42
+ model="o3-mini",
43
+ input=context.messages,
44
+ stream=True,
45
+ reasoning={"effort": "medium", "summary": "auto"},
46
+ )
47
+
48
+ async for event in stream:
49
+ if event.type == "response.reasoning_summary_text.delta":
50
+ yield {"type": "thinking", "thinking": event.delta} # Renders as thinking bubble
51
+ elif event.type == "response.output_text.delta":
52
+ yield event.delta
53
+
54
+ agent.deploy() # Live at https://agent.cycls.ai
55
+ ```
56
+
57
+ ## Installation
58
+
59
+ ```bash
60
+ pip install cycls
61
+ ```
62
+
63
+ Requires Docker.
64
+
65
+ ## What You Get
66
+
67
+ - **Streaming API** - OpenAI-compatible `/chat/completions` endpoint
68
+ - **Web Interface** - Chat UI served automatically
69
+ - **Authentication** - `auth=True` enables JWT-based access control
70
+ - **Analytics** - `analytics=True` tracks usage
71
+ - **Monetization** - `plan="cycls_pass"` integrates with [Cycls Pass](https://cycls.ai) subscriptions
72
+ - **Native UI Components** - Render thinking bubbles, tables, code blocks in responses
73
+
74
+ ## Running
75
+
76
+ ```python
77
+ agent.local() # Development with hot-reload (localhost:8080)
78
+ agent.local(watch=False) # Development without hot-reload
79
+ agent.deploy() # Production: https://agent.cycls.ai
80
+ ```
81
+
82
+ Get an API key at [cycls.com](https://cycls.com).
83
+
84
+ ## Authentication & Analytics
85
+
86
+ ```python
87
+ @cycls.agent(pip=["openai"], auth=True, analytics=True)
88
+ async def agent(context):
89
+ # context.user available when auth=True
90
+ user = context.user # User(id, email, name, plans)
91
+ yield f"Hello {user.name}!"
92
+ ```
93
+
94
+ | Flag | Description |
95
+ |------|-------------|
96
+ | `auth=True` | Universal user pool via Cycls Pass (Clerk-based). You can also use your own Clerk auth. |
97
+ | `analytics=True` | Rich usage metrics available on the Cycls dashboard. |
98
+ | `plan="cycls_pass"` | Monetization via Cycls Pass subscriptions. Enables both auth and analytics. |
99
+
100
+ ## Native UI Components
101
+
102
+ Yield structured objects for rich streaming responses:
103
+
104
+ ```python
105
+ @cycls.agent()
106
+ async def demo(context):
107
+ yield {"type": "thinking", "thinking": "Analyzing the request..."}
108
+ yield "Here's what I found:\n\n"
109
+
110
+ yield {"type": "table", "headers": ["Name", "Status"]}
111
+ yield {"type": "table", "row": ["Server 1", "Online"]}
112
+ yield {"type": "table", "row": ["Server 2", "Offline"]}
113
+
114
+ yield {"type": "code", "code": "result = analyze(data)", "language": "python"}
115
+ yield {"type": "callout", "callout": "Analysis complete!", "style": "success"}
116
+ ```
117
+
118
+ | Component | Streaming |
119
+ |-----------|-----------|
120
+ | `{"type": "thinking", "thinking": "..."}` | Yes |
121
+ | `{"type": "code", "code": "...", "language": "..."}` | Yes |
122
+ | `{"type": "table", "headers": [...]}` | Yes |
123
+ | `{"type": "table", "row": [...]}` | Yes |
124
+ | `{"type": "status", "status": "..."}` | Yes |
125
+ | `{"type": "callout", "callout": "...", "style": "..."}` | Yes |
126
+ | `{"type": "image", "src": "..."}` | Yes |
127
+
128
+ ### Thinking Bubbles
129
+
130
+ The `{"type": "thinking", "thinking": "..."}` component renders as a collapsible thinking bubble in the UI. Each yield appends to the same bubble until a different component type is yielded:
131
+
132
+ ```python
133
+ # Multiple yields build one thinking bubble
134
+ yield {"type": "thinking", "thinking": "Let me "}
135
+ yield {"type": "thinking", "thinking": "analyze this..."}
136
+ yield {"type": "thinking", "thinking": " Done thinking."}
137
+
138
+ # Then output the response
139
+ yield "Here's what I found..."
140
+ ```
141
+
142
+ This works seamlessly with OpenAI's reasoning models - just map reasoning summaries to the thinking component.
143
+
144
+ ## Context Object
145
+
146
+ ```python
147
+ @cycls.agent()
148
+ async def chat(context):
149
+ context.messages # [{"role": "user", "content": "..."}]
150
+ context.messages.raw # Full data including UI component parts
151
+ context.user # User(id, email, name, plans) when auth=True
152
+ ```
153
+
154
+ ## API Endpoints
155
+
156
+ | Endpoint | Format |
157
+ |----------|--------|
158
+ | `POST chat/cycls` | Cycls streaming protocol |
159
+ | `POST chat/completions` | OpenAI-compatible |
160
+
161
+ ## Streaming Protocol
162
+
163
+ Cycls streams structured components over SSE:
164
+
165
+ ```
166
+ data: {"type": "thinking", "thinking": "Let me "}
167
+ data: {"type": "thinking", "thinking": "analyze..."}
168
+ data: {"type": "text", "text": "Here's the answer"}
169
+ data: {"type": "callout", "callout": "Done!", "style": "success"}
170
+ data: [DONE]
171
+ ```
172
+
173
+ See [docs/streaming-protocol.md](docs/streaming-protocol.md) for frontend integration.
174
+
175
+ ## Declarative Infrastructure
176
+
177
+ Define your entire runtime in the decorator:
178
+
179
+ ```python
180
+ @cycls.agent(
181
+ pip=["openai", "pandas", "numpy"],
182
+ apt=["ffmpeg", "libmagic1"],
183
+ copy=["./utils.py", "./models/", "/absolute/path/to/config.json"],
184
+ copy_public=["./assets/logo.png", "./static/"],
185
+ )
186
+ async def my_agent(context):
187
+ ...
188
+ ```
189
+
190
+ ### `pip` - Python Packages
191
+
192
+ Install any packages from PyPI. These are installed during the container build.
193
+
194
+ ```python
195
+ pip=["openai", "pandas", "numpy", "transformers"]
196
+ ```
197
+
198
+ ### `apt` - System Packages
199
+
200
+ Install system-level dependencies via apt-get. Need ffmpeg for audio processing? ImageMagick for images? Just declare it.
201
+
202
+ ```python
203
+ apt=["ffmpeg", "imagemagick", "libpq-dev"]
204
+ ```
205
+
206
+ ### `copy` - Bundle Files and Directories
207
+
208
+ Include local files and directories in your container. Works with both relative and absolute paths. Copies files and entire directory trees.
209
+
210
+ ```python
211
+ copy=[
212
+ "./utils.py", # Single file, relative path
213
+ "./models/", # Entire directory
214
+ "/home/user/configs/app.json", # Absolute path
215
+ ]
216
+ ```
217
+
218
+ Then import them in your function:
219
+
220
+ ```python
221
+ @cycls.agent(copy=["./utils.py"])
222
+ async def chat(context):
223
+ from utils import helper_function # Your bundled module
224
+ ...
225
+ ```
226
+
227
+ ### `copy_public` - Static Files
228
+
229
+ Files and directories served at the `/public` endpoint. Perfect for images, downloads, or any static assets your agent needs to reference.
230
+
231
+ ```python
232
+ copy_public=["./assets/logo.png", "./downloads/"]
233
+ ```
234
+
235
+ Access them at `https://your-agent.cycls.ai/public/logo.png`.
236
+
237
+ ---
238
+
239
+ ### What You Get
240
+
241
+ - **One file** - Code, dependencies, configuration, and infrastructure together
242
+ - **Instant deploys** - Unchanged code deploys in seconds from cache
243
+ - **No drift** - What you see is what runs. Always.
244
+ - **Just works** - Closures, lambdas, dynamic imports - your function runs exactly as written
245
+
246
+ No YAML. No Dockerfiles. No infrastructure repo. The code is the deployment.
247
+
248
+ ## License
249
+
250
+ MIT
@@ -0,0 +1,20 @@
1
+ import sys
2
+ from types import ModuleType
3
+ from .sdk import function, agent
4
+ from .runtime import Runtime
5
+
6
+ class _Module(ModuleType):
7
+ def __getattr__(self, name):
8
+ from . import sdk
9
+ if name in ("api_key", "base_url"):
10
+ return getattr(sdk, name)
11
+ raise AttributeError(f"module 'cycls' has no attribute '{name}'")
12
+
13
+ def __setattr__(self, name, value):
14
+ from . import sdk
15
+ if name in ("api_key", "base_url"):
16
+ setattr(sdk, name, value)
17
+ return
18
+ super().__setattr__(name, value)
19
+
20
+ sys.modules[__name__].__class__ = _Module
@@ -0,0 +1,4 @@
1
+ JWKS_PROD = "https://clerk.cycls.ai/.well-known/jwks.json"
2
+ JWKS_TEST = "https://select-sloth-58.clerk.accounts.dev/.well-known/jwks.json"
3
+ PK_LIVE = "pk_live_Y2xlcmsuY3ljbHMuYWkk"
4
+ PK_TEST = "pk_test_c2VsZWN0LXNsb3RoLTU4LmNsZXJrLmFjY291bnRzLmRldiQ"
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env python3
2
+ """cycls chat - Claude Code style CLI for cycls agents"""
3
+
4
+ import json, os, re, sys
5
+ import httpx
6
+
7
+ RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
8
+ BLUE, GREEN, YELLOW, RED = "\033[34m", "\033[32m", "\033[33m", "\033[31m"
9
+ CALLOUTS = {"success": ("✓", GREEN), "warning": ("⚠", YELLOW), "info": ("ℹ", BLUE), "error": ("✗", RED)}
10
+
11
+ separator = lambda: f"{DIM}{'─' * min(os.get_terminal_size().columns if sys.stdout.isatty() else 80, 80)}{RESET}"
12
+ markdown = lambda text: re.sub(r"\*\*(.+?)\*\*", f"{BOLD}\\1{RESET}", text)
13
+ header = lambda title, meta, color=GREEN, dim=False: print(f"{color}●{RESET} {BOLD}{title}{RESET}\n ⎿ {meta}{DIM if dim else ''}", flush=True)
14
+
15
+
16
+ def table(headers, rows):
17
+ if not headers: return
18
+ widths = [max(len(str(h)), *(len(str(r[i])) for r in rows if i < len(r))) for i, h in enumerate(headers)]
19
+ line = lambda left, mid, right: left + mid.join("─" * (w + 2) for w in widths) + right
20
+ row = lambda cells, bold=False: "│" + "│".join(f" {BOLD if bold else ''}{str(cells[i] if i < len(cells) else '').ljust(widths[i])}{RESET if bold else ''} " for i in range(len(widths))) + "│"
21
+ print(f"{line('┌', '┬', '┐')}\n{row(headers, True)}\n{line('├', '┼', '┤')}")
22
+ for r in rows: print(row(r))
23
+ print(line("└", "┴", "┘"))
24
+
25
+
26
+ def chat(url):
27
+ messages, endpoint = [], f"{url.rstrip('/')}/chat/cycls"
28
+ print(f"\n{BOLD}cycls{RESET} {DIM}|{RESET} {url}\n")
29
+
30
+ while True:
31
+ try:
32
+ print(separator())
33
+ user_input = input(f"{BOLD}{BLUE}❯{RESET} ").strip()
34
+ print(separator())
35
+
36
+ if not user_input: continue
37
+ if user_input in ("/q", "exit", "quit"): break
38
+ if user_input == "/c": messages, _ = [], print(f"{GREEN}⏺ Cleared{RESET}"); continue
39
+
40
+ messages.append({"role": "user", "content": user_input})
41
+ block, tbl = None, ([], [])
42
+
43
+ def close():
44
+ nonlocal block, tbl
45
+ if block == "thinking": print(RESET)
46
+ if block == "text": print()
47
+ if block == "table" and tbl[0]: table(*tbl); tbl = ([], [])
48
+ if block: print()
49
+ block = None
50
+
51
+ with httpx.stream("POST", endpoint, json={"messages": messages}, timeout=None) as response:
52
+ for line in response.iter_lines():
53
+ if not line.startswith("data: ") or line == "data: [DONE]": continue
54
+ data = json.loads(line[6:])
55
+ type = data.get("type")
56
+
57
+ if type is None:
58
+ print(markdown(data if isinstance(data, str) else data.get("text", "")), end="", flush=True); continue
59
+
60
+ if type != block: close()
61
+
62
+ if type in ("thinking", "text"):
63
+ if block != type: header(type.capitalize(), "Live", dim=(type == "thinking")); block = type
64
+ print((markdown if type == "text" else str)(data.get(type, "")), end="", flush=True)
65
+ elif type == "code":
66
+ code = data.get("code", ""); header(f"Code({data.get('language', '')})", f"{code.count(chr(10))+1} lines"); print(code, flush=True); block = type
67
+ elif type == "status":
68
+ print(f"{DIM}[{data.get('status', '')}]{RESET} ", end="", flush=True)
69
+ elif type == "table":
70
+ if "headers" in data:
71
+ if tbl[0]: table(*tbl)
72
+ header("Table", f"{len(data['headers'])} cols"); tbl, block = (data["headers"], []), type
73
+ elif "row" in data: tbl[1].append(data["row"])
74
+ elif type == "callout":
75
+ style = data.get("style", "info"); icon, color = CALLOUTS.get(style, ("•", RESET))
76
+ header(style.capitalize(), f"{icon} {data.get('callout', '')}", color=color); block = type
77
+ elif type == "image":
78
+ header("Image", data.get("src", "")); block = type
79
+
80
+ close()
81
+
82
+ except KeyboardInterrupt: print()
83
+ except EOFError: break
84
+ except (httpx.ReadError, httpx.ConnectError) as e: print(f"{RED}⏺ Connection error: {e}{RESET}"); messages and messages.pop()
85
+
86
+
87
+ def main():
88
+ if len(sys.argv) < 2:
89
+ print("Usage: cycls chat <url|port>")
90
+ sys.exit(1)
91
+ arg = sys.argv[1]
92
+ if arg.isdigit():
93
+ port = int(arg)
94
+ if not (1 <= port <= 65535):
95
+ print(f"Error: Invalid port {port}. Must be between 1 and 65535.")
96
+ sys.exit(1)
97
+ url = f"http://localhost:{port}"
98
+ else:
99
+ url = arg
100
+ chat(url)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()