cyborgy 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 @@
1
+ prune tests
cyborgy-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyborgy
3
+ Version: 0.1.0
4
+ Summary: Extend the claude and codex CLIs: skills marketplace, memory, and extension APIs via the Cyborgy MCP.
5
+ Author: Kizuna Intelligence
6
+ Project-URL: Homepage, https://github.com/kizuna-intelligence/cyborgy
7
+ Project-URL: Repository, https://github.com/kizuna-intelligence/cyborgy
8
+ Keywords: cyborgy,codex,claude,mcp,skills,agents
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: mcp>=1.2
21
+ Requires-Dist: pynacl>=1.5
22
+
23
+ # Cyborgy CLI
24
+
25
+ `cyborgy` extends coding-agent CLIs such as Claude Code and Codex with the
26
+ Cyborgy MCP, workspace roles, skills, memory, extension APIs, and temporary
27
+ tunnels.
28
+
29
+ This package provides:
30
+
31
+ - `cyborgy`: the main CLI.
32
+ - `cyborgy-mcp`: the MCP server used by supported coding agents.
33
+ - `cyborgy-dev` and `cyborgy-mcp-dev`: development-environment entry points.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install cyborgy
39
+ ```
40
+
41
+ ## Basic usage
42
+
43
+ ```bash
44
+ cyborgy login
45
+ cyborgy mcp install --target codex
46
+ cyborgy codex exec "summarize this repository"
47
+ ```
48
+
49
+ The CLI stores login state locally, installs the Cyborgy MCP configuration into
50
+ the current repository, and syncs the skills assigned to that workspace.
51
+
52
+ ## Status
53
+
54
+ This is an alpha package. The public CLI surface is expected to evolve with the
55
+ Cyborgy service.
@@ -0,0 +1,33 @@
1
+ # Cyborgy CLI
2
+
3
+ `cyborgy` extends coding-agent CLIs such as Claude Code and Codex with the
4
+ Cyborgy MCP, workspace roles, skills, memory, extension APIs, and temporary
5
+ tunnels.
6
+
7
+ This package provides:
8
+
9
+ - `cyborgy`: the main CLI.
10
+ - `cyborgy-mcp`: the MCP server used by supported coding agents.
11
+ - `cyborgy-dev` and `cyborgy-mcp-dev`: development-environment entry points.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install cyborgy
17
+ ```
18
+
19
+ ## Basic usage
20
+
21
+ ```bash
22
+ cyborgy login
23
+ cyborgy mcp install --target codex
24
+ cyborgy codex exec "summarize this repository"
25
+ ```
26
+
27
+ The CLI stores login state locally, installs the Cyborgy MCP configuration into
28
+ the current repository, and syncs the skills assigned to that workspace.
29
+
30
+ ## Status
31
+
32
+ This is an alpha package. The public CLI surface is expected to evolve with the
33
+ Cyborgy service.
@@ -0,0 +1,3 @@
1
+ """Cyborgy CLI + MCP — extend the claude and codex CLIs."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,311 @@
1
+ """Thin HTTP client over the Cyborgy backend. Shared by the CLI and the MCP."""
2
+ from __future__ import annotations
3
+
4
+ import httpx
5
+
6
+ from . import config
7
+
8
+
9
+ class ApiError(RuntimeError):
10
+ pass
11
+
12
+
13
+ def _media_body(url, question, data, filename, mime_type) -> dict:
14
+ body: dict = {"url": url or "", "question": question}
15
+ if data:
16
+ body["data"] = data
17
+ if filename:
18
+ body["filename"] = filename
19
+ if mime_type:
20
+ body["mime_type"] = mime_type
21
+ return body
22
+
23
+
24
+ class Api:
25
+ def __init__(self, token: str | None = None, base_url: str | None = None) -> None:
26
+ self.base_url = (base_url or config.api_url()).rstrip("/")
27
+ self.token = token if token is not None else config.get_token()
28
+
29
+ def _headers(self) -> dict:
30
+ return {"Authorization": f"Bearer {self.token}"} if self.token else {}
31
+
32
+ def _req(self, method: str, path: str, **kw) -> httpx.Response:
33
+ kw.setdefault("timeout", 120)
34
+ resp = httpx.request(method, f"{self.base_url}{path}", headers=self._headers(), **kw)
35
+ if resp.status_code >= 400:
36
+ raise ApiError(f"{method} {path} -> {resp.status_code}: {resp.text}")
37
+ return resp
38
+
39
+ # --- auth (device flow) ---
40
+ def device_start(self, name: str = "", host: str = "") -> dict:
41
+ return self._req("POST", "/auth/device/start", json={"name": name, "host": host}).json()
42
+
43
+ def device_token(self, device_code: str) -> httpx.Response:
44
+ # caller inspects status (428 = keep polling); don't raise here.
45
+ return httpx.post(f"{self.base_url}/auth/device/token", json={"device_code": device_code}, timeout=30)
46
+
47
+ def me(self) -> dict:
48
+ return self._req("GET", "/auth/me").json()
49
+
50
+ # --- ephemeral tunnels ---
51
+ def tunnel_config(self) -> dict:
52
+ return self._req("POST", "/me/tunnel/config").json()
53
+
54
+ # --- skills ---
55
+ def search_skills(self, q: str | None = None, tag: str | None = None) -> list[dict]:
56
+ params = {k: v for k, v in {"q": q, "tag": tag}.items() if v}
57
+ return self._req("GET", "/skills", params=params).json()
58
+
59
+ def get_skill(self, skill_id: str) -> dict:
60
+ return self._req("GET", f"/skills/{skill_id}").json()
61
+
62
+ def get_skill_files(self, skill_id: str) -> dict:
63
+ return self._req("GET", f"/skills/{skill_id}/files").json()
64
+
65
+ def create_skill(self, payload: dict) -> dict:
66
+ return self._req("POST", "/skills", json=payload).json()
67
+
68
+ def update_skill(self, skill_id: str, patch: dict) -> dict:
69
+ return self._req("PUT", f"/skills/{skill_id}", json=patch).json()
70
+
71
+ def put_skill_files(self, skill_id: str, files: list[dict], message: str = "update") -> dict:
72
+ return self._req("PUT", f"/skills/{skill_id}/files", json={"files": files, "message": message}).json()
73
+
74
+ def delete_skill(self, skill_id: str) -> None:
75
+ self._req("DELETE", f"/skills/{skill_id}")
76
+
77
+ # --- my skills ---
78
+ def my_skills(self) -> list[dict]:
79
+ return self._req("GET", "/me/skills").json()
80
+
81
+ def my_owned(self) -> list[dict]:
82
+ return self._req("GET", "/me/skills/owned").json()
83
+
84
+ def add_favorite(self, skill_id: str) -> None:
85
+ self._req("PUT", f"/me/skills/{skill_id}")
86
+
87
+ # --- api catalog ---
88
+ def list_apis(self, q: str | None = None, tag: str | None = None) -> list[dict]:
89
+ params = {k: v for k, v in {"q": q, "tag": tag}.items() if v}
90
+ return self._req("GET", "/apis", params=params).json()
91
+
92
+ def get_api(self, api_id: str) -> dict:
93
+ return self._req("GET", f"/apis/{api_id}").json()
94
+
95
+ def my_apis(self) -> list[dict]:
96
+ return self._req("GET", "/me/apis").json()
97
+
98
+ def my_owned_apis(self) -> list[dict]:
99
+ return self._req("GET", "/me/apis/owned").json()
100
+
101
+ def create_api(self, payload: dict) -> dict:
102
+ return self._req("POST", "/apis", json=payload).json()
103
+
104
+ def update_api(self, api_id: str, patch: dict) -> dict:
105
+ return self._req("PUT", f"/apis/{api_id}", json=patch).json()
106
+
107
+ def add_api_favorite(self, api_id: str) -> None:
108
+ self._req("PUT", f"/me/apis/{api_id}")
109
+
110
+ def call_api(self, path: str, body: dict) -> dict:
111
+ """Invoke an API by its backend path (e.g. /media/audio/ask)."""
112
+ return self._req("POST", path, json=body).json()
113
+
114
+ def api_job(self, path: str, job_id: str) -> dict:
115
+ """Fetch an async API job by the API's backend path."""
116
+ return self._req("GET", f"{path.rstrip('/')}/{job_id}").json()
117
+
118
+ # --- memory ---
119
+ def memory_add(self, text: str, metadata: dict | None = None) -> dict:
120
+ return self._req("POST", "/memory", json={"text": text, "metadata": metadata or {}}).json()
121
+
122
+ def memory_search(self, q: str, limit: int = 10) -> list[dict]:
123
+ return self._req("GET", "/memory/search", params={"q": q, "limit": limit}).json()
124
+
125
+ # --- roles & workspaces ---
126
+ def list_roles(self) -> list[dict]:
127
+ return self._req("GET", "/me/roles").json()
128
+
129
+ def create_role(self, payload: dict) -> dict:
130
+ return self._req("POST", "/me/roles", json=payload).json()
131
+
132
+ def get_role(self, role_id: str) -> dict:
133
+ return self._req("GET", f"/me/roles/{role_id}").json()
134
+
135
+ def update_role(self, role_id: str, patch: dict) -> dict:
136
+ role = self.get_role(role_id)
137
+ payload = {
138
+ "name": role.get("name", ""),
139
+ "description": role.get("description", ""),
140
+ "parent_role_ids": role.get("parent_role_ids") or [],
141
+ "skill_ids": role.get("skill_ids") or [],
142
+ }
143
+ payload.update(patch)
144
+ return self._req("PUT", f"/me/roles/{role_id}", json=payload).json()
145
+
146
+ def delete_role(self, role_id: str) -> None:
147
+ self._req("DELETE", f"/me/roles/{role_id}")
148
+
149
+ def get_workspace(self, machine: str, path: str) -> dict:
150
+ return self._req("GET", "/me/workspace", params={"machine": machine, "path": path}).json()
151
+
152
+ def set_workspace(self, machine: str, path: str, role_ids: list[str], supervisor_role_ids: list[str] | None = None,
153
+ code_review_role_ids: list[str] | None = None) -> dict:
154
+ current = None
155
+ if supervisor_role_ids is None:
156
+ current = self.get_workspace(machine, path)
157
+ supervisor_role_ids = current.get("supervisor_role_ids") or []
158
+ if code_review_role_ids is None:
159
+ current = current or self.get_workspace(machine, path)
160
+ code_review_role_ids = current.get("code_review_role_ids") or []
161
+ return self._req("PUT", "/me/workspace",
162
+ json={"machine_id": machine, "path": path, "role_ids": role_ids,
163
+ "supervisor_role_ids": supervisor_role_ids,
164
+ "code_review_role_ids": code_review_role_ids}).json()
165
+
166
+ def list_workspaces(self) -> list[dict]:
167
+ return self._req("GET", "/me/workspaces").json()
168
+
169
+ def list_repository_role_bindings(self) -> list[dict]:
170
+ return self._req("GET", "/me/repository-role-bindings").json()
171
+
172
+ def set_repository_role_binding(self, github_repository: str, role_ids: list[str],
173
+ supervisor_role_ids: list[str] | None = None,
174
+ code_review_role_ids: list[str] | None = None) -> dict:
175
+ current = None
176
+ if supervisor_role_ids is None:
177
+ current = next((b for b in self.list_repository_role_bindings()
178
+ if b.get("github_repository") == github_repository), {})
179
+ supervisor_role_ids = current.get("supervisor_role_ids") or []
180
+ if code_review_role_ids is None:
181
+ current = current or next((b for b in self.list_repository_role_bindings()
182
+ if b.get("github_repository") == github_repository), {})
183
+ code_review_role_ids = current.get("code_review_role_ids") or []
184
+ return self._req("PUT", "/me/repository-role-binding",
185
+ json={"github_repository": github_repository, "role_ids": role_ids,
186
+ "supervisor_role_ids": supervisor_role_ids,
187
+ "code_review_role_ids": code_review_role_ids}).json()
188
+
189
+ def resolve_workspace(self, machine: str, path: str, github_repository: str | None = None) -> dict:
190
+ params = {"machine": machine, "path": path}
191
+ if github_repository:
192
+ params["github_repository"] = github_repository
193
+ return self._req("GET", "/me/workspace/resolve", params=params).json()
194
+
195
+ # --- AI chat / coordination ---
196
+ def ai_chat_post(self, channel: str, message: str, agent_id: str = "") -> dict:
197
+ return self._req("POST", "/me/ai-chat/messages",
198
+ json={"channel": channel, "message": message, "agent_id": agent_id}).json()
199
+
200
+ def ai_chat_list(self, channel: str = "work", since: str = "", until: str = "", limit: int = 100) -> list[dict]:
201
+ params = {"channel": channel, "limit": limit}
202
+ if since:
203
+ params["since"] = since
204
+ if until:
205
+ params["until"] = until
206
+ return self._req("GET", "/me/ai-chat/messages", params=params).json()
207
+
208
+ # --- worker (remote task execution) ---
209
+ def worker_register(self, name: str, host: str, root: str = "", dirs: list[str] | None = None,
210
+ convos: list[dict] | None = None, accounts: list[dict] | None = None,
211
+ public_key: str = "", models: dict | None = None) -> dict:
212
+ return self._req("POST", "/worker/register",
213
+ json={"name": name, "host": host, "root": root, "dirs": dirs or [],
214
+ "convos": convos or [], "accounts": accounts or [], "public_key": public_key,
215
+ "models": models or {}}).json()
216
+
217
+ def worker_poll(self, worker_id: str) -> list[dict]:
218
+ return self._req("GET", "/worker/poll", params={"worker_id": worker_id}, timeout=40).json()
219
+
220
+ # --- cloud login (browser/code re-auth driven from the web) ---
221
+ def cloud_login_get(self, cl_id: str) -> dict:
222
+ return self._req("GET", f"/worker/cloud-login/{cl_id}").json()
223
+
224
+ def cloud_login_update(self, cl_id: str, *, status: str | None = None, url: str | None = None,
225
+ code: str | None = None, needs_code: bool | None = None, message: str | None = None) -> None:
226
+ body: dict = {}
227
+ for k, v in {"status": status, "url": url, "code": code, "needs_code": needs_code, "message": message}.items():
228
+ if v is not None:
229
+ body[k] = v
230
+ self._req("POST", f"/worker/cloud-login/{cl_id}", json=body)
231
+
232
+ # --- viewer (files shown in the web) ---
233
+ def push_view(self, session_id: str, name: str, mime: str, data: str) -> dict:
234
+ return self._req("POST", "/me/views",
235
+ json={"session_id": session_id, "name": name, "mime": mime, "data": data}).json()
236
+
237
+ def list_views(self, session_id: str | None = None) -> list[dict]:
238
+ params = {"session_id": session_id} if session_id else {}
239
+ return self._req("GET", "/me/views", params=params).json()
240
+
241
+ def session_output(self, session_id: str, *, text: str | None = None,
242
+ review_text: str | None = None,
243
+ status: str | None = None, exit_code: int | None = None,
244
+ claude_session_id: str | None = None,
245
+ review_session_id: str | None = None, review_status: str | None = None,
246
+ review_feedback: str | None = None, review_round: int | None = None,
247
+ completion_summary: str | None = None, completion_detail: str | None = None,
248
+ completion_status: str | None = None) -> None:
249
+ body: dict = {}
250
+ if text is not None:
251
+ body["text"] = text
252
+ if review_text is not None:
253
+ body["review_text"] = review_text
254
+ if status is not None:
255
+ body["status"] = status
256
+ if exit_code is not None:
257
+ body["exit_code"] = exit_code
258
+ if claude_session_id is not None:
259
+ body["claude_session_id"] = claude_session_id
260
+ if review_session_id is not None:
261
+ body["review_session_id"] = review_session_id
262
+ if review_status is not None:
263
+ body["review_status"] = review_status
264
+ if review_feedback is not None:
265
+ body["review_feedback"] = review_feedback
266
+ if review_round is not None:
267
+ body["review_round"] = review_round
268
+ if completion_summary is not None:
269
+ body["completion_summary"] = completion_summary
270
+ if completion_detail is not None:
271
+ body["completion_detail"] = completion_detail
272
+ if completion_status is not None:
273
+ body["completion_status"] = completion_status
274
+ self._req("POST", f"/worker/sessions/{session_id}/output", json=body)
275
+
276
+ def notify_work_complete(self, session_id: str, one_line_summary: str, detailed_summary: str,
277
+ status: str = "complete") -> None:
278
+ self.session_output(
279
+ session_id,
280
+ completion_summary=one_line_summary,
281
+ completion_detail=detailed_summary,
282
+ completion_status=status,
283
+ )
284
+
285
+ # --- media ---
286
+ def ask_audio(self, url: str | None, question: str, *, data: str | None = None,
287
+ filename: str | None = None, mime_type: str | None = None) -> dict:
288
+ return self._req("POST", "/media/audio/ask", json=_media_body(url, question, data, filename, mime_type)).json()
289
+
290
+ def ask_video(self, url: str | None, question: str, *, data: str | None = None,
291
+ filename: str | None = None, mime_type: str | None = None) -> dict:
292
+ return self._req("POST", "/media/video/ask", json=_media_body(url, question, data, filename, mime_type)).json()
293
+
294
+ # --- generative media ---
295
+ def image_gen(self, prompt: str, n: int = 1, aspect_ratio: str | None = None) -> dict:
296
+ return self._req("POST", "/media/image/generate",
297
+ json={"prompt": prompt, "n": n, "aspect_ratio": aspect_ratio or ""}).json()
298
+
299
+ def tts(self, text: str, voice: str | None = None, language: str | None = None) -> dict:
300
+ return self._req("POST", "/media/tts",
301
+ json={"text": text, "voice": voice or "", "language": language or ""}).json()
302
+
303
+ def music_gen(self, prompt: str, negative_prompt: str | None = None, seconds: int = 30) -> dict:
304
+ return self._req("POST", "/media/music/generate",
305
+ json={"prompt": prompt, "negative_prompt": negative_prompt or "", "seconds": seconds}).json()
306
+
307
+ def video_gen(self, prompt: str, seconds: int = 8) -> dict:
308
+ return self._req("POST", "/media/video/generate", json={"prompt": prompt, "seconds": seconds}).json()
309
+
310
+ def media_job(self, job_id: str) -> dict:
311
+ return self._req("GET", f"/media/jobs/{job_id}").json()
@@ -0,0 +1,48 @@
1
+ """Device-authorization login, Claude-CLI style."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ import time
6
+ import webbrowser
7
+
8
+ from . import config
9
+ from .api import Api
10
+
11
+
12
+ def login(open_browser: bool = True, name: str = "", host: str = "") -> dict:
13
+ api = Api(token=None)
14
+ start = api.device_start(name, host) # name/host let the web approve this worker per-worker
15
+ url = start["verification_uri_complete"]
16
+
17
+ print("\n To sign in, open this URL and approve the code:\n")
18
+ print(f" {start['verification_uri']}")
19
+ print(f" code: {start['user_code']}\n")
20
+ if open_browser:
21
+ try:
22
+ webbrowser.open(url)
23
+ except Exception: # noqa: BLE001 - headless is fine
24
+ pass
25
+
26
+ interval = start.get("interval", 5)
27
+ deadline = time.time() + start.get("expires_in", 900)
28
+ print(" Waiting for approval", end="", flush=True)
29
+ while time.time() < deadline:
30
+ resp = api.device_token(start["device_code"])
31
+ if resp.status_code == 200:
32
+ token = resp.json()["access_token"]
33
+ config.set_token(token)
34
+ me = Api(token=token).me()
35
+ print(f"\n ✓ Logged in as {me.get('email') or me.get('uid')}\n")
36
+ return me
37
+ if resp.status_code == 428: # authorization_pending
38
+ print(".", end="", flush=True)
39
+ time.sleep(interval)
40
+ continue
41
+ print(f"\n login failed: {resp.status_code} {resp.text}", file=sys.stderr)
42
+ break
43
+ raise SystemExit("login timed out or failed")
44
+
45
+
46
+ def ensure_login() -> None:
47
+ if config.get_token() is None:
48
+ login()