yeschef-cli 0.1.0__py3-none-any.whl
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.
- yeschef/__init__.py +3 -0
- yeschef/__main__.py +6 -0
- yeschef/agent/__init__.py +6 -0
- yeschef/agent/backends/__init__.py +53 -0
- yeschef/agent/backends/anthropic_compat.py +119 -0
- yeschef/agent/backends/base.py +50 -0
- yeschef/agent/backends/cli.py +99 -0
- yeschef/agent/backends/openai_compat.py +118 -0
- yeschef/agent/config.py +98 -0
- yeschef/agent/detect.py +98 -0
- yeschef/agent/harness.py +1009 -0
- yeschef/cli.py +1073 -0
- yeschef/hub/__init__.py +16 -0
- yeschef/hub/api.py +595 -0
- yeschef/hub/app.py +43 -0
- yeschef/hub/dashboard.html +206 -0
- yeschef/hub/events.py +78 -0
- yeschef/hub/mcp_server.py +941 -0
- yeschef/hub/schema.sql +106 -0
- yeschef/hub/store.py +1621 -0
- yeschef/models.py +431 -0
- yeschef/procs.py +109 -0
- yeschef/replay.py +224 -0
- yeschef/resources/__init__.py +0 -0
- yeschef/resources/agents/__init__.py +0 -0
- yeschef/resources/agents/yeschef-expediter.md +74 -0
- yeschef/resources/skill/SKILL.md +212 -0
- yeschef/resources/skill/__init__.py +0 -0
- yeschef/sdk/__init__.py +9 -0
- yeschef/sdk/client.py +359 -0
- yeschef/settings.py +100 -0
- yeschef/tools/__init__.py +5 -0
- yeschef/tools/executor.py +296 -0
- yeschef_cli-0.1.0.dist-info/METADATA +254 -0
- yeschef_cli-0.1.0.dist-info/RECORD +38 -0
- yeschef_cli-0.1.0.dist-info/WHEEL +4 -0
- yeschef_cli-0.1.0.dist-info/entry_points.txt +3 -0
- yeschef_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
yeschef/cli.py
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
"""``yeschef`` — open the kitchen, clock a cook in, and run the line.
|
|
2
|
+
|
|
3
|
+
Onboarding is meant to be two commands total:
|
|
4
|
+
|
|
5
|
+
# on the hub machine (where Claude Code runs)
|
|
6
|
+
yeschef up
|
|
7
|
+
|
|
8
|
+
# on each worker (auto-detects the local model)
|
|
9
|
+
yeschef join --hub http://<hub> --token <printed by up>
|
|
10
|
+
|
|
11
|
+
Everything else (submit, ask, watch, agents, tasks) talks to the hub over the same MCP
|
|
12
|
+
endpoint Claude Code uses, so the user's CLI needs no token of its own.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import getpass
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import shutil
|
|
23
|
+
import socket
|
|
24
|
+
import subprocess
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
import typer
|
|
30
|
+
from rich.console import Console
|
|
31
|
+
from rich.panel import Panel
|
|
32
|
+
from rich.table import Table
|
|
33
|
+
|
|
34
|
+
from . import procs, settings
|
|
35
|
+
from .agent import AgentConfig, run_agent
|
|
36
|
+
from .agent.detect import autodetect, preflight, probe
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(
|
|
39
|
+
help="Fire work to a kitchen of local AI cooks from Claude Code (and back).",
|
|
40
|
+
no_args_is_help=True,
|
|
41
|
+
add_completion=False,
|
|
42
|
+
)
|
|
43
|
+
hub_app = typer.Typer(help="Run and inspect the hub (advanced; `up` is the easy path).")
|
|
44
|
+
agent_app = typer.Typer(help="Run an agent from a TOML config (advanced; `join` is easier).")
|
|
45
|
+
app.add_typer(hub_app, name="hub")
|
|
46
|
+
app.add_typer(agent_app, name="agent")
|
|
47
|
+
console = Console()
|
|
48
|
+
|
|
49
|
+
OPERATOR_IDENTITY = f"operator:{getpass.getuser()}"
|
|
50
|
+
|
|
51
|
+
PROVIDER_PRESETS = {
|
|
52
|
+
"openrouter": {"base_url": "https://openrouter.ai/api/v1", "key_env": "OPENROUTER_API_KEY"},
|
|
53
|
+
"openai": {"base_url": "https://api.openai.com/v1", "key_env": "OPENAI_API_KEY"},
|
|
54
|
+
"groq": {"base_url": "https://api.groq.com/openai/v1", "key_env": "GROQ_API_KEY"},
|
|
55
|
+
"together": {"base_url": "https://api.together.xyz/v1", "key_env": "TOGETHER_API_KEY"},
|
|
56
|
+
"deepseek": {"base_url": "https://api.deepseek.com/v1", "key_env": "DEEPSEEK_API_KEY"},
|
|
57
|
+
"fireworks": {
|
|
58
|
+
"base_url": "https://api.fireworks.ai/inference/v1",
|
|
59
|
+
"key_env": "FIREWORKS_API_KEY",
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
"""Cloud providers that speak the OpenAI-compatible API. Any other one works via
|
|
63
|
+
--base-url + --api-key-env; these are just the presets."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# --------------------------------------------------------------- shared plumbing
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _hub_url(explicit: str | None = None) -> str:
|
|
70
|
+
return (explicit or settings.load().hub_url).rstrip("/")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _mcp_url(explicit: str | None = None) -> str:
|
|
74
|
+
return f"{_hub_url(explicit)}/mcp"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _call(tool: str, arguments: dict, *, identity: str | None = None, hub: str | None = None):
|
|
78
|
+
"""Call one MCP tool and return its data, surfacing hub errors as a clean exit."""
|
|
79
|
+
from fastmcp import Client
|
|
80
|
+
|
|
81
|
+
async with Client(_mcp_url(hub)) as client:
|
|
82
|
+
if identity:
|
|
83
|
+
await client.call_tool("set_identity", {"label": identity})
|
|
84
|
+
result = await client.call_tool(tool, arguments)
|
|
85
|
+
data = result.data
|
|
86
|
+
if isinstance(data, dict) and "error" in data and isinstance(data["error"], dict):
|
|
87
|
+
console.print(
|
|
88
|
+
f"[red]{data['error'].get('code', 'error')}[/red]: {data['error']['message']}"
|
|
89
|
+
)
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _mcp(tool: str, arguments: dict, *, identity: str | None = None, hub: str | None = None):
|
|
95
|
+
try:
|
|
96
|
+
return asyncio.run(_call(tool, arguments, identity=identity, hub=hub))
|
|
97
|
+
except typer.Exit:
|
|
98
|
+
raise
|
|
99
|
+
except Exception as exc: # noqa: BLE001 - a CLI should not stacktrace on a dead hub
|
|
100
|
+
console.print(
|
|
101
|
+
f"[red]could not reach the hub[/red] at {_hub_url(hub)} — {type(exc).__name__}: {exc}"
|
|
102
|
+
)
|
|
103
|
+
console.print("Is it running? Start one with [cyan]yeschef up[/cyan].")
|
|
104
|
+
raise typer.Exit(1) from exc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# =============================================================== onboarding: up
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _wire_claude_code(mcp_url: str) -> None:
|
|
111
|
+
if shutil.which("claude") is None:
|
|
112
|
+
console.print(
|
|
113
|
+
"[yellow]note:[/yellow] the `claude` CLI is not on PATH, so I did not wire it up. "
|
|
114
|
+
f"Add it yourself with:\n [cyan]claude mcp add --transport http yeschef "
|
|
115
|
+
f"{mcp_url}[/cyan]"
|
|
116
|
+
)
|
|
117
|
+
return
|
|
118
|
+
result = subprocess.run(
|
|
119
|
+
[
|
|
120
|
+
"claude",
|
|
121
|
+
"mcp",
|
|
122
|
+
"add",
|
|
123
|
+
"--scope",
|
|
124
|
+
"user",
|
|
125
|
+
"--transport",
|
|
126
|
+
"http",
|
|
127
|
+
"yeschef",
|
|
128
|
+
mcp_url,
|
|
129
|
+
],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
text=True,
|
|
132
|
+
)
|
|
133
|
+
if result.returncode == 0:
|
|
134
|
+
console.print(
|
|
135
|
+
"[green]✓[/green] wired into Claude Code (user scope) as [bold]yeschef[/bold]"
|
|
136
|
+
)
|
|
137
|
+
elif "already exists" in (result.stderr + result.stdout).lower():
|
|
138
|
+
console.print("[green]✓[/green] already wired into Claude Code as [bold]yeschef[/bold]")
|
|
139
|
+
else:
|
|
140
|
+
console.print(
|
|
141
|
+
"[yellow]note:[/yellow] could not wire Claude Code automatically. Run:\n"
|
|
142
|
+
f" [cyan]claude mcp add --transport http yeschef {mcp_url}[/cyan]"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
CODEX_MCP_BLOCK = """
|
|
147
|
+
[mcp_servers.yeschef]
|
|
148
|
+
command = "yeschef"
|
|
149
|
+
args = ["mcp-proxy", "--hub", "{hub_url}"]
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def codex_config_with_yeschef(existing: str, hub_url: str) -> str | None:
|
|
154
|
+
"""Return the Codex config.toml text with yeschef wired, or None if already there.
|
|
155
|
+
|
|
156
|
+
Codex speaks stdio MCP; the entry launches `yeschef mcp-proxy`, which bridges
|
|
157
|
+
stdio to the hub's HTTP endpoint. Append-only on purpose: rewriting a user's TOML
|
|
158
|
+
through a parser risks mangling comments and formatting they care about.
|
|
159
|
+
"""
|
|
160
|
+
if "[mcp_servers.yeschef]" in existing:
|
|
161
|
+
return None
|
|
162
|
+
block = CODEX_MCP_BLOCK.format(hub_url=hub_url)
|
|
163
|
+
if existing and not existing.endswith("\n"):
|
|
164
|
+
existing += "\n"
|
|
165
|
+
return existing + block
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _wire_codex(hub_url: str) -> None:
|
|
169
|
+
codex_home = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
|
|
170
|
+
config_path = codex_home / "config.toml"
|
|
171
|
+
if shutil.which("codex") is None and not codex_home.exists():
|
|
172
|
+
return # no Codex on this machine; stay quiet
|
|
173
|
+
updated = codex_config_with_yeschef(
|
|
174
|
+
config_path.read_text() if config_path.exists() else "", hub_url
|
|
175
|
+
)
|
|
176
|
+
if updated is None:
|
|
177
|
+
console.print("[green]✓[/green] Codex already wired to [bold]yeschef[/bold]")
|
|
178
|
+
return
|
|
179
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
config_path.write_text(updated)
|
|
181
|
+
console.print(
|
|
182
|
+
f"[green]✓[/green] wired into Codex ({config_path}) via [bold]yeschef mcp-proxy[/bold]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _serve_hub(host: str, port: int, db: str, cfg: settings.Settings) -> None:
|
|
187
|
+
import uvicorn
|
|
188
|
+
|
|
189
|
+
os.environ["YESCHEF_DB"] = db
|
|
190
|
+
if cfg.admin_token:
|
|
191
|
+
os.environ.setdefault("YESCHEF_ADMIN_TOKEN", cfg.admin_token)
|
|
192
|
+
if cfg.register_token:
|
|
193
|
+
os.environ.setdefault("YESCHEF_REGISTER_TOKEN", cfg.register_token)
|
|
194
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
|
195
|
+
uvicorn.run(
|
|
196
|
+
"yeschef.hub.app:app_factory",
|
|
197
|
+
host=host,
|
|
198
|
+
port=port,
|
|
199
|
+
factory=True,
|
|
200
|
+
log_level="info",
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@app.command("up")
|
|
205
|
+
def up(
|
|
206
|
+
port: int = typer.Option(8787, help="Port to serve on."),
|
|
207
|
+
host: str = typer.Option("0.0.0.0", help="Bind address. LAN/Tailscale only — never funnel."),
|
|
208
|
+
advertise: str = typer.Option(
|
|
209
|
+
None, help="URL cooks should use to reach this hub (default: best-guess LAN address)."
|
|
210
|
+
),
|
|
211
|
+
detach: bool = typer.Option(False, "--detach", "-d", help="Run in the background and return."),
|
|
212
|
+
open_mode: bool = typer.Option(
|
|
213
|
+
False, "--open", help="Skip token generation — anyone on the network can drive the hub."
|
|
214
|
+
),
|
|
215
|
+
no_wire: bool = typer.Option(False, "--no-wire", help="Do not touch the Claude Code config."),
|
|
216
|
+
db: str = typer.Option(None, help="SQLite path (default: <yeschef home>/hub.db)."),
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Open the kitchen: start the hub, wire it into Claude Code, print the cook clock-in line."""
|
|
219
|
+
db = db or str(settings.home() / "hub.db")
|
|
220
|
+
cfg = settings.load()
|
|
221
|
+
if not open_mode:
|
|
222
|
+
cfg.admin_token = cfg.admin_token or secrets.token_urlsafe(24)
|
|
223
|
+
cfg.register_token = cfg.register_token or secrets.token_urlsafe(24)
|
|
224
|
+
else:
|
|
225
|
+
cfg.admin_token = None
|
|
226
|
+
cfg.register_token = None
|
|
227
|
+
cfg.hub_url = f"http://localhost:{port}"
|
|
228
|
+
cfg.advertise_url = advertise or settings.best_lan_url(port)
|
|
229
|
+
settings.save(cfg)
|
|
230
|
+
|
|
231
|
+
mcp_url = f"{cfg.hub_url}/mcp"
|
|
232
|
+
if not no_wire:
|
|
233
|
+
_wire_claude_code(mcp_url)
|
|
234
|
+
_wire_codex(cfg.hub_url)
|
|
235
|
+
|
|
236
|
+
join = f"yeschef join --hub {cfg.advertise_url}"
|
|
237
|
+
if cfg.register_token:
|
|
238
|
+
join += f" --token {cfg.register_token}"
|
|
239
|
+
console.print(
|
|
240
|
+
Panel(
|
|
241
|
+
f"[bold]Clock a cook in on each machine[/bold] (auto-detects the local model):\n\n"
|
|
242
|
+
f" [cyan]{join}[/cyan]\n\n"
|
|
243
|
+
f"On a Tailnet, swap the host for the machine's MagicDNS name.\n"
|
|
244
|
+
f"Run the line from here with [cyan]yeschef ask[/cyan] / "
|
|
245
|
+
f"[cyan]submit[/cyan] / [cyan]cooks[/cyan].\n"
|
|
246
|
+
f"Watch the pass in a browser: [cyan]{cfg.hub_url}/[/cyan] "
|
|
247
|
+
f"([cyan]yeschef dashboard[/cyan]).",
|
|
248
|
+
title="yeschef — kitchen open",
|
|
249
|
+
border_style="green",
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
if open_mode:
|
|
253
|
+
console.print(
|
|
254
|
+
"[yellow]open mode:[/yellow] no tokens — any process that can reach this port can "
|
|
255
|
+
"drive the hub. Fine on a trusted LAN; never expose it publicly.\n"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if detach:
|
|
259
|
+
command = procs.cli_executable() + [
|
|
260
|
+
"hub",
|
|
261
|
+
"serve",
|
|
262
|
+
"--host",
|
|
263
|
+
host,
|
|
264
|
+
"--port",
|
|
265
|
+
str(port),
|
|
266
|
+
"--db",
|
|
267
|
+
db,
|
|
268
|
+
]
|
|
269
|
+
proc = procs.spawn("hub", "hub", command)
|
|
270
|
+
_wait_healthy(cfg.hub_url)
|
|
271
|
+
console.print(
|
|
272
|
+
f"[green]✓[/green] hub running in the background (pid {proc.pid}), logging to "
|
|
273
|
+
f"{proc.log}\n stop it with [cyan]yeschef down[/cyan]."
|
|
274
|
+
)
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
console.print(f"[dim]serving on http://{host}:{port} — Ctrl-C to stop[/dim]\n")
|
|
278
|
+
try:
|
|
279
|
+
_serve_hub(host, port, db, cfg)
|
|
280
|
+
except KeyboardInterrupt:
|
|
281
|
+
console.print("\nstopped")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@app.command("down")
|
|
285
|
+
def down() -> None:
|
|
286
|
+
"""Close the kitchen: stop the hub and any cooks started with --detach on this machine."""
|
|
287
|
+
stopped = procs.stop_all()
|
|
288
|
+
if not stopped:
|
|
289
|
+
console.print("nothing running in the background here.")
|
|
290
|
+
return
|
|
291
|
+
for label in stopped:
|
|
292
|
+
console.print(f"[green]stopped[/green] {label}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command("ps")
|
|
296
|
+
def ps() -> None:
|
|
297
|
+
"""Show background hub/agent processes started on this machine."""
|
|
298
|
+
running = procs.list_all()
|
|
299
|
+
if not running:
|
|
300
|
+
console.print("no background processes here. (foreground ones aren't tracked.)")
|
|
301
|
+
return
|
|
302
|
+
table = Table("label", "kind", "pid", "state", "log")
|
|
303
|
+
for proc in running:
|
|
304
|
+
alive = procs.is_alive(proc.pid)
|
|
305
|
+
table.add_row(
|
|
306
|
+
proc.label,
|
|
307
|
+
proc.kind,
|
|
308
|
+
str(proc.pid),
|
|
309
|
+
"[green]alive[/green]" if alive else "[red]dead[/red]",
|
|
310
|
+
proc.log,
|
|
311
|
+
)
|
|
312
|
+
console.print(table)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# =============================================================== onboarding: join
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@app.command("join")
|
|
319
|
+
def join(
|
|
320
|
+
hub: str = typer.Option(None, help="Hub URL (default: the one saved by a prior join/up)."),
|
|
321
|
+
token: str = typer.Option(None, help="Register token from `up` (omit in open mode)."),
|
|
322
|
+
name: str = typer.Option(None, help="Agent name (default: this machine's hostname)."),
|
|
323
|
+
model: str = typer.Option(None, help="Model to serve (default: auto-detected)."),
|
|
324
|
+
base_url: str = typer.Option(None, help="Model API base (default: auto-detected)."),
|
|
325
|
+
provider: str = typer.Option(
|
|
326
|
+
None,
|
|
327
|
+
help="Cloud provider preset that fills base_url and the key env var: "
|
|
328
|
+
"openrouter | openai | groq | together | deepseek | fireworks.",
|
|
329
|
+
),
|
|
330
|
+
api_key_env: str = typer.Option(
|
|
331
|
+
None, help="Env var holding the API key (for a cloud provider or a secured endpoint)."
|
|
332
|
+
),
|
|
333
|
+
backend: str = typer.Option(
|
|
334
|
+
"openai_compat", help="Backend adapter: openai_compat | anthropic_compat | tahoma."
|
|
335
|
+
),
|
|
336
|
+
tier: str = typer.Option(None, help="Capability tier tag, e.g. fast or reasoning."),
|
|
337
|
+
tag: list[str] = typer.Option(None, "--tag", help="Extra capability tag (repeatable)."),
|
|
338
|
+
detach: bool = typer.Option(False, "--detach", "-d", help="Run in the background and return."),
|
|
339
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Clock a cook in — from a local model server or a cloud provider.
|
|
342
|
+
|
|
343
|
+
Local is the default (auto-detected). For a cloud provider, pass --provider (which
|
|
344
|
+
sets base_url and the key env var for you) and --model, e.g.:
|
|
345
|
+
|
|
346
|
+
yeschef join --provider openrouter --model meta-llama/llama-3.3-70b-instruct
|
|
347
|
+
"""
|
|
348
|
+
cfg = settings.load()
|
|
349
|
+
hub = hub or cfg.hub_url
|
|
350
|
+
token = token or cfg.register_token
|
|
351
|
+
name = name or socket.gethostname().split(".")[0]
|
|
352
|
+
|
|
353
|
+
runtime: str | None = None
|
|
354
|
+
if provider:
|
|
355
|
+
preset = PROVIDER_PRESETS.get(provider.lower())
|
|
356
|
+
if preset is None:
|
|
357
|
+
console.print(
|
|
358
|
+
f"[red]unknown provider '{provider}'[/red]. "
|
|
359
|
+
f"Known: {', '.join(sorted(PROVIDER_PRESETS))}."
|
|
360
|
+
)
|
|
361
|
+
raise typer.Exit(1)
|
|
362
|
+
base_url = base_url or preset["base_url"]
|
|
363
|
+
api_key_env = api_key_env or preset["key_env"]
|
|
364
|
+
runtime = provider.lower()
|
|
365
|
+
if not os.environ.get(api_key_env):
|
|
366
|
+
console.print(
|
|
367
|
+
f"[red]{api_key_env} is not set.[/red] Export your {provider} API key first:\n"
|
|
368
|
+
f" [cyan]export {api_key_env}=...[/cyan]"
|
|
369
|
+
)
|
|
370
|
+
raise typer.Exit(1)
|
|
371
|
+
console.print(f"[green]✓[/green] using [bold]{provider}[/bold] at {base_url}")
|
|
372
|
+
elif not base_url:
|
|
373
|
+
console.print("probing for a local model server…")
|
|
374
|
+
detected = autodetect()
|
|
375
|
+
if detected is None:
|
|
376
|
+
console.print(
|
|
377
|
+
"[red]no local model server found[/red] on the usual ports (Ollama 11434, "
|
|
378
|
+
"vLLM 8000, LM Studio 1234).\nStart one, pass [cyan]--base-url[/cyan] + "
|
|
379
|
+
"[cyan]--model[/cyan], or use a cloud provider with [cyan]--provider[/cyan]."
|
|
380
|
+
)
|
|
381
|
+
raise typer.Exit(1)
|
|
382
|
+
base_url = detected.base_url
|
|
383
|
+
runtime = detected.runtime
|
|
384
|
+
model = detected.pick_model(model)
|
|
385
|
+
console.print(
|
|
386
|
+
f"[green]✓[/green] found [bold]{detected.runtime}[/bold] at {base_url}"
|
|
387
|
+
+ (f" → model [bold]{model}[/bold]" if model else "")
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if not model:
|
|
391
|
+
console.print("[red]no model selected[/red]; pass --model.")
|
|
392
|
+
raise typer.Exit(1)
|
|
393
|
+
|
|
394
|
+
backend_cfg = {"type": backend, "base_url": base_url, "model": model}
|
|
395
|
+
if runtime and runtime != "openai_compat":
|
|
396
|
+
backend_cfg["runtime"] = runtime
|
|
397
|
+
if api_key_env:
|
|
398
|
+
key = os.environ.get(api_key_env)
|
|
399
|
+
if not key:
|
|
400
|
+
console.print(f"[red]{api_key_env} is not set[/red] on this machine.")
|
|
401
|
+
raise typer.Exit(1)
|
|
402
|
+
backend_cfg["api_key"] = key
|
|
403
|
+
if provider and provider.lower() == "openrouter":
|
|
404
|
+
# Optional attribution headers OpenRouter uses for its rankings; harmless.
|
|
405
|
+
backend_cfg["extra_headers"] = {
|
|
406
|
+
"HTTP-Referer": "https://github.com/labscommunity/yeschef",
|
|
407
|
+
"X-Title": "yeschef",
|
|
408
|
+
}
|
|
409
|
+
console.print("verifying the model answers…")
|
|
410
|
+
ok, message = asyncio.run(preflight(backend_cfg))
|
|
411
|
+
if not ok:
|
|
412
|
+
console.print(f"[red]the model endpoint did not answer[/red]: {message}")
|
|
413
|
+
console.print(f"Check that {base_url} is serving [bold]{model}[/bold].")
|
|
414
|
+
raise typer.Exit(1)
|
|
415
|
+
console.print(f"[green]✓[/green] model reachable (replied: {message[:40]!r})")
|
|
416
|
+
|
|
417
|
+
tags = list(tag or [])
|
|
418
|
+
if tier:
|
|
419
|
+
tags.append(f"tier:{tier}")
|
|
420
|
+
tags.append(f"node:{name}")
|
|
421
|
+
|
|
422
|
+
# Remember hub + token so a plain `yeschef join` works next time, and so
|
|
423
|
+
# `ask`/`agents`/etc. on this box point at the right hub.
|
|
424
|
+
cfg.hub_url = hub
|
|
425
|
+
if token:
|
|
426
|
+
cfg.register_token = token
|
|
427
|
+
settings.save(cfg)
|
|
428
|
+
|
|
429
|
+
agent_config = AgentConfig(
|
|
430
|
+
name=name,
|
|
431
|
+
hub=hub,
|
|
432
|
+
tags=tags,
|
|
433
|
+
register_token=token,
|
|
434
|
+
backend=backend_cfg,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if detach:
|
|
438
|
+
command = procs.cli_executable() + [
|
|
439
|
+
"agent",
|
|
440
|
+
"run-detached-worker",
|
|
441
|
+
"--name",
|
|
442
|
+
name,
|
|
443
|
+
"--hub",
|
|
444
|
+
hub,
|
|
445
|
+
"--base-url",
|
|
446
|
+
base_url,
|
|
447
|
+
"--model",
|
|
448
|
+
model,
|
|
449
|
+
"--backend",
|
|
450
|
+
backend,
|
|
451
|
+
]
|
|
452
|
+
if runtime:
|
|
453
|
+
command += ["--runtime", runtime]
|
|
454
|
+
if api_key_env:
|
|
455
|
+
command += ["--api-key-env", api_key_env]
|
|
456
|
+
if provider:
|
|
457
|
+
command += ["--provider", provider]
|
|
458
|
+
for extra in tags:
|
|
459
|
+
command += ["--tag", extra]
|
|
460
|
+
if token:
|
|
461
|
+
command += ["--token", token]
|
|
462
|
+
proc = procs.spawn(f"agent-{name}", "agent", command)
|
|
463
|
+
console.print(
|
|
464
|
+
f"[green]✓[/green] [bold]{name}[/bold] running in the background (pid {proc.pid}), "
|
|
465
|
+
f"logging to {proc.log}\n stop it with [cyan]yeschef down[/cyan]."
|
|
466
|
+
)
|
|
467
|
+
return
|
|
468
|
+
|
|
469
|
+
_run_worker(agent_config, verbose)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _run_worker(agent_config: AgentConfig, verbose: bool) -> None:
|
|
473
|
+
logging.basicConfig(
|
|
474
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
475
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
476
|
+
)
|
|
477
|
+
console.print(
|
|
478
|
+
f"[bold]{agent_config.name}[/bold] → {agent_config.hub} "
|
|
479
|
+
f"({agent_config.backend_label()}, tags: {', '.join(agent_config.tags)})\n"
|
|
480
|
+
f"[dim]listening for tasks and messages — Ctrl-C to stop[/dim]"
|
|
481
|
+
)
|
|
482
|
+
try:
|
|
483
|
+
asyncio.run(run_agent(agent_config))
|
|
484
|
+
except KeyboardInterrupt:
|
|
485
|
+
console.print("\nstopped")
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# =============================================================== doctor
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
@app.command("doctor")
|
|
492
|
+
def doctor() -> None:
|
|
493
|
+
"""Diagnose a setup: config, hub reachability, and local model servers."""
|
|
494
|
+
cfg = settings.load()
|
|
495
|
+
console.print("[bold]yeschef doctor[/bold]\n")
|
|
496
|
+
|
|
497
|
+
ok = "[green]✓[/green]"
|
|
498
|
+
bad = "[red]✗[/red]"
|
|
499
|
+
|
|
500
|
+
if settings.config_path().exists():
|
|
501
|
+
console.print(f"{ok} config at {settings.config_path()}")
|
|
502
|
+
else:
|
|
503
|
+
console.print(
|
|
504
|
+
f"{bad} no config yet — run [cyan]yeschef up[/cyan] (hub) or [cyan]join[/cyan] (worker)"
|
|
505
|
+
)
|
|
506
|
+
console.print(f" hub_url: {cfg.hub_url}")
|
|
507
|
+
console.print(f" tokens: {'set' if cfg.admin_token or cfg.register_token else 'OPEN MODE'}")
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
health = httpx.get(f"{cfg.hub_url}/healthz", timeout=5.0).json()
|
|
511
|
+
console.print(
|
|
512
|
+
f"{ok} hub reachable — {health['agents']} agents "
|
|
513
|
+
f"({health['online']} online), {health['queued_tasks']} queued tasks"
|
|
514
|
+
)
|
|
515
|
+
except Exception as exc: # noqa: BLE001
|
|
516
|
+
console.print(f"{bad} hub not reachable at {cfg.hub_url} ({type(exc).__name__})")
|
|
517
|
+
|
|
518
|
+
detected = probe()
|
|
519
|
+
if detected:
|
|
520
|
+
for backend in detected:
|
|
521
|
+
models = ", ".join(backend.models[:4]) or "(no models loaded)"
|
|
522
|
+
console.print(
|
|
523
|
+
f"{ok} local model server: {backend.runtime} at {backend.base_url} — {models}"
|
|
524
|
+
)
|
|
525
|
+
else:
|
|
526
|
+
console.print(
|
|
527
|
+
"[dim]•[/dim] no local model server detected on this machine (fine for a hub-only box)"
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if shutil.which("claude"):
|
|
531
|
+
listed = subprocess.run(["claude", "mcp", "list"], capture_output=True, text=True)
|
|
532
|
+
if "yeschef" in (listed.stdout + listed.stderr):
|
|
533
|
+
console.print(f"{ok} Claude Code is wired to yeschef")
|
|
534
|
+
else:
|
|
535
|
+
console.print(
|
|
536
|
+
"[dim]•[/dim] Claude Code present but not wired — run "
|
|
537
|
+
"[cyan]yeschef up[/cyan] or add it manually"
|
|
538
|
+
)
|
|
539
|
+
else:
|
|
540
|
+
console.print(
|
|
541
|
+
"[dim]•[/dim] `claude` CLI not on PATH (only needed on the box you drive from)"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
codex_config = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser() / "config.toml"
|
|
545
|
+
if codex_config.exists():
|
|
546
|
+
wired = "[mcp_servers.yeschef]" in codex_config.read_text()
|
|
547
|
+
mark = ok if wired else "[dim]•[/dim]"
|
|
548
|
+
note = (
|
|
549
|
+
"wired via mcp-proxy"
|
|
550
|
+
if wired
|
|
551
|
+
else "present but not wired — run [cyan]yeschef up[/cyan]"
|
|
552
|
+
)
|
|
553
|
+
console.print(f"{mark} Codex {note}")
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# =============================================================== interaction
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@app.command("cooks")
|
|
560
|
+
@app.command("agents", hidden=True) # muscle-memory alias
|
|
561
|
+
def list_cooks(hub: str = typer.Option(None)) -> None:
|
|
562
|
+
"""List the cooks on the line."""
|
|
563
|
+
data = _mcp("list_agents", {}, hub=hub)
|
|
564
|
+
table = Table("cook", "kind", "station", "stove", "tags", "status")
|
|
565
|
+
for agent in data["agents"]:
|
|
566
|
+
colour = {"online": "green", "busy": "yellow"}.get(agent["status"], "dim")
|
|
567
|
+
table.add_row(
|
|
568
|
+
agent["name"],
|
|
569
|
+
agent["kind"],
|
|
570
|
+
agent["node"] or "-",
|
|
571
|
+
agent["backend"] or "-",
|
|
572
|
+
",".join(agent["tags"]) or "-",
|
|
573
|
+
f"[{colour}]{agent['status']}[/{colour}]",
|
|
574
|
+
)
|
|
575
|
+
console.print(table)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@app.command("submit")
|
|
579
|
+
def submit(
|
|
580
|
+
spec: str = typer.Argument(..., help="What the agent should do."),
|
|
581
|
+
to: str = typer.Option(None, "--to", help="Agent name to assign it to."),
|
|
582
|
+
tier: str = typer.Option(None, "--tier", help="Assign to any agent with tier:<x>."),
|
|
583
|
+
title: str = typer.Option(None, help="Short title (default: first line of the spec)."),
|
|
584
|
+
hub: str = typer.Option(None),
|
|
585
|
+
) -> None:
|
|
586
|
+
"""Fire a ticket to a cook and print its id (check it with `task`)."""
|
|
587
|
+
if not to and not tier:
|
|
588
|
+
console.print("[red]pick a target[/red]: --to <agent> or --tier <tier>")
|
|
589
|
+
raise typer.Exit(1)
|
|
590
|
+
args = {"title": title or spec.splitlines()[0][:60], "spec": spec}
|
|
591
|
+
if to:
|
|
592
|
+
args["assignee"] = to
|
|
593
|
+
if tier:
|
|
594
|
+
args["selector"] = f"tier:{tier}"
|
|
595
|
+
data = _mcp("submit_task", args, identity=OPERATOR_IDENTITY, hub=hub)
|
|
596
|
+
console.print(
|
|
597
|
+
f"[green]dispatched[/green] [bold]{data['task_id']}[/bold] → "
|
|
598
|
+
f"{data['task']['assignee'] or data['task']['selector']}"
|
|
599
|
+
)
|
|
600
|
+
console.print(f" check it: [cyan]yeschef task {data['task_id']}[/cyan]")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@app.command("result")
|
|
604
|
+
def result(task_id: str, hub: str = typer.Option(None)) -> None:
|
|
605
|
+
"""Print a ticket's plate (or why it isn't up yet)."""
|
|
606
|
+
data = _mcp("task_result", {"task_id": task_id}, hub=hub)
|
|
607
|
+
if not data["ready"]:
|
|
608
|
+
console.print(f"[yellow]{data['state']}[/yellow] — not finished yet")
|
|
609
|
+
raise typer.Exit(0)
|
|
610
|
+
if data.get("error"):
|
|
611
|
+
console.print(f"[red]failed[/red]: {data['error']}")
|
|
612
|
+
raise typer.Exit(1)
|
|
613
|
+
payload = data["result"] or {}
|
|
614
|
+
console.print(payload.get("text") if isinstance(payload, dict) else payload)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@app.command("send")
|
|
618
|
+
def send(agent: str, message: str, hub: str = typer.Option(None)) -> None:
|
|
619
|
+
"""Send a message to a cook without waiting for the reply."""
|
|
620
|
+
data = _mcp(
|
|
621
|
+
"send_message", {"to": agent, "message": message}, identity=OPERATOR_IDENTITY, hub=hub
|
|
622
|
+
)
|
|
623
|
+
console.print(f"[green]sent[/green] to {agent} (room {data['room_id']})")
|
|
624
|
+
console.print(f" read the reply: [cyan]yeschef watch {data['room_id']}[/cyan]")
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@app.command("ask")
|
|
628
|
+
def ask(
|
|
629
|
+
agent: str,
|
|
630
|
+
message: str,
|
|
631
|
+
wait: float = typer.Option(45.0, help="Seconds to wait for the reply."),
|
|
632
|
+
hub: str = typer.Option(None),
|
|
633
|
+
) -> None:
|
|
634
|
+
"""Send a message to a cook and wait for its reply."""
|
|
635
|
+
|
|
636
|
+
async def _ask():
|
|
637
|
+
from fastmcp import Client
|
|
638
|
+
|
|
639
|
+
async with Client(_mcp_url(hub)) as client:
|
|
640
|
+
await client.call_tool("set_identity", {"label": OPERATOR_IDENTITY})
|
|
641
|
+
sent = await client.call_tool("send_message", {"to": agent, "message": message})
|
|
642
|
+
room_id = sent.data["room_id"]
|
|
643
|
+
deadline = time.monotonic() + wait
|
|
644
|
+
while time.monotonic() < deadline:
|
|
645
|
+
got = await client.call_tool(
|
|
646
|
+
"fetch_messages", {"room": room_id, "after": 0, "wait_s": min(30.0, wait)}
|
|
647
|
+
)
|
|
648
|
+
replies = [m for m in got.data["messages"] if m["sender"] == agent]
|
|
649
|
+
if replies:
|
|
650
|
+
return replies[-1]["body"]
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
console.print(f"[dim]asking {agent}…[/dim]")
|
|
654
|
+
reply = _mcp_run(_ask, hub)
|
|
655
|
+
if reply is None:
|
|
656
|
+
console.print(
|
|
657
|
+
f"[yellow]no reply within {wait:.0f}s[/yellow] — the agent may be offline or slow."
|
|
658
|
+
)
|
|
659
|
+
raise typer.Exit(1)
|
|
660
|
+
console.print(Panel(reply, title=agent, border_style="cyan"))
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _mcp_run(coro_fn, hub):
|
|
664
|
+
try:
|
|
665
|
+
return asyncio.run(coro_fn())
|
|
666
|
+
except Exception as exc: # noqa: BLE001
|
|
667
|
+
console.print(f"[red]could not reach the hub[/red] at {_hub_url(hub)} — {exc}")
|
|
668
|
+
raise typer.Exit(1) from exc
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
@app.command("dialogue")
|
|
672
|
+
def dialogue(
|
|
673
|
+
agents: list[str] = typer.Argument(..., help="Two or more agent names."),
|
|
674
|
+
goal: str = typer.Option(..., "--goal", "-g", help="What they should work toward."),
|
|
675
|
+
max_messages: int = typer.Option(20, help="Cap before the room auto-closes."),
|
|
676
|
+
stop_phrase: str = typer.Option(None, help="Archive the room when an agent says this."),
|
|
677
|
+
watch: bool = typer.Option(True, help="Follow the conversation live after starting it."),
|
|
678
|
+
record: str = typer.Option(
|
|
679
|
+
None, "--record", help="Save the finished transcript to this JSON file (for `replay`)."
|
|
680
|
+
),
|
|
681
|
+
hub: str = typer.Option(None),
|
|
682
|
+
) -> None:
|
|
683
|
+
"""Have two or more cooks talk a question over on their own toward a goal."""
|
|
684
|
+
args = {"participants": agents, "goal": goal, "max_messages": max_messages}
|
|
685
|
+
if stop_phrase:
|
|
686
|
+
args["stop_phrase"] = stop_phrase
|
|
687
|
+
data = _mcp("start_dialogue", args, identity=OPERATOR_IDENTITY, hub=hub)
|
|
688
|
+
room_id = data["room_id"]
|
|
689
|
+
console.print(f"[green]started[/green] dialogue in room [bold]{room_id}[/bold]")
|
|
690
|
+
if watch or record:
|
|
691
|
+
_follow_room(room_id, hub, record=record)
|
|
692
|
+
else:
|
|
693
|
+
console.print(f" watch it: [cyan]yeschef watch {room_id}[/cyan]")
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@app.command("watch")
|
|
697
|
+
def watch(
|
|
698
|
+
room_id: str,
|
|
699
|
+
record: str = typer.Option(
|
|
700
|
+
None, "--record", help="Save the transcript to this JSON file when the room closes."
|
|
701
|
+
),
|
|
702
|
+
hub: str = typer.Option(None),
|
|
703
|
+
) -> None:
|
|
704
|
+
"""Follow a room's conversation live."""
|
|
705
|
+
_follow_room(room_id, hub, record=record)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@app.command("tail", hidden=True)
|
|
709
|
+
def tail(room_id: str, hub: str = typer.Option(None)) -> None:
|
|
710
|
+
"""Alias for `watch`."""
|
|
711
|
+
_follow_room(room_id, hub)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _follow_room(room_id: str, hub: str | None, record: str | None = None) -> None:
|
|
715
|
+
seen = 0
|
|
716
|
+
console.print(f"[dim]following {room_id} — Ctrl-C to stop[/dim]")
|
|
717
|
+
closed = False
|
|
718
|
+
try:
|
|
719
|
+
while True:
|
|
720
|
+
data = _mcp("room_transcript", {"room": room_id, "from_seq": seen}, hub=hub)
|
|
721
|
+
for message in data["messages"]:
|
|
722
|
+
seen = message["seq"]
|
|
723
|
+
console.print(f"[bold cyan]{message['sender']}[/bold cyan]: {message['body']}")
|
|
724
|
+
if data["room"]["archived"]:
|
|
725
|
+
console.print(f"[dim]— room closed ({data['room']['archived_reason']})[/dim]")
|
|
726
|
+
closed = True
|
|
727
|
+
return
|
|
728
|
+
time.sleep(2.0)
|
|
729
|
+
except KeyboardInterrupt:
|
|
730
|
+
console.print("\nstopped following")
|
|
731
|
+
finally:
|
|
732
|
+
if record:
|
|
733
|
+
_save_transcript(room_id, record, hub, complete=closed)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _save_transcript(room_id: str, path: str, hub: str | None, complete: bool) -> None:
|
|
737
|
+
from .replay import save_transcript, transcript_payload
|
|
738
|
+
|
|
739
|
+
transcript = _mcp("room_transcript", {"room": room_id, "from_seq": 0, "limit": 1000}, hub=hub)
|
|
740
|
+
fleet = _mcp("list_agents", {}, hub=hub)
|
|
741
|
+
payload = transcript_payload(transcript["room"], transcript["messages"], fleet["agents"])
|
|
742
|
+
target = save_transcript(path, payload)
|
|
743
|
+
note = "" if complete else " (room still open — partial transcript)"
|
|
744
|
+
console.print(f"[green]recorded[/green] {len(payload['messages'])} turns → {target}{note}")
|
|
745
|
+
console.print(f" replay it: [cyan]yeschef replay {target}[/cyan]")
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@app.command("replay")
|
|
749
|
+
def replay_cmd(
|
|
750
|
+
transcript: str = typer.Argument(..., help="A JSON transcript saved with --record."),
|
|
751
|
+
speed: float = typer.Option(1.0, help="Multiplier on the real inter-message gaps."),
|
|
752
|
+
words_per_sec: float = typer.Option(13.0, help="Model output cadence."),
|
|
753
|
+
human_words_per_sec: float = typer.Option(6.0, help="Cadence for operator turns."),
|
|
754
|
+
max_gap: float = typer.Option(1.2, help="Cap between messages, seconds."),
|
|
755
|
+
no_delay: bool = typer.Option(False, "--no-delay", help="Render instantly (sanity checks)."),
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Re-stream a recorded conversation with realistic pacing.
|
|
758
|
+
|
|
759
|
+
Live model output is nondeterministic; a recorded transcript replays identically
|
|
760
|
+
every time, which is what screen-recorder pipelines (VHS, asciinema) need to render
|
|
761
|
+
a reproducible demo GIF from a real conversation.
|
|
762
|
+
"""
|
|
763
|
+
import json as _json
|
|
764
|
+
|
|
765
|
+
from .replay import ReplayOptions, replay
|
|
766
|
+
|
|
767
|
+
payload = _json.loads(Path(transcript).expanduser().read_text())
|
|
768
|
+
replay(
|
|
769
|
+
payload,
|
|
770
|
+
console,
|
|
771
|
+
ReplayOptions(
|
|
772
|
+
speed=speed,
|
|
773
|
+
words_per_sec=words_per_sec,
|
|
774
|
+
human_words_per_sec=human_words_per_sec,
|
|
775
|
+
max_gap_s=max_gap,
|
|
776
|
+
no_delay=no_delay,
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@app.command("mcp-proxy")
|
|
782
|
+
def mcp_proxy(hub: str = typer.Option(None, help="Hub URL (default: the configured one).")) -> None:
|
|
783
|
+
"""Bridge stdio MCP to the hub, for clients that don't speak HTTP MCP (Codex, etc.).
|
|
784
|
+
|
|
785
|
+
Everything the hub's MCP surface offers — dispatch, status, files, dialogues —
|
|
786
|
+
flows through unchanged; this process is just the transport adapter.
|
|
787
|
+
"""
|
|
788
|
+
# Banner and logs must not touch stdout — it IS the MCP channel for a stdio client.
|
|
789
|
+
import logging as _logging
|
|
790
|
+
|
|
791
|
+
_logging.disable(_logging.CRITICAL)
|
|
792
|
+
try:
|
|
793
|
+
from fastmcp.server import create_proxy
|
|
794
|
+
except ImportError: # older fastmcp
|
|
795
|
+
from fastmcp import FastMCP
|
|
796
|
+
|
|
797
|
+
proxy = FastMCP.as_proxy(_mcp_url(hub), name="yeschef")
|
|
798
|
+
else:
|
|
799
|
+
proxy = create_proxy(_mcp_url(hub), name="yeschef")
|
|
800
|
+
proxy.run(show_banner=False)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@app.command("dashboard")
|
|
804
|
+
def dashboard(
|
|
805
|
+
hub: str = typer.Option(None, help="Hub URL (default: the configured one)."),
|
|
806
|
+
no_open: bool = typer.Option(
|
|
807
|
+
False, "--no-open", help="Just print the URL, don't open a browser."
|
|
808
|
+
),
|
|
809
|
+
) -> None:
|
|
810
|
+
"""Open the live web dashboard for the kitchen in a browser."""
|
|
811
|
+
url = f"{_hub_url(hub)}/"
|
|
812
|
+
try:
|
|
813
|
+
httpx.get(f"{_hub_url(hub)}/healthz", timeout=5.0).raise_for_status()
|
|
814
|
+
except Exception as exc: # noqa: BLE001
|
|
815
|
+
console.print(f"[red]hub unreachable[/red] at {url} — {exc}")
|
|
816
|
+
console.print("Open the kitchen with [cyan]yeschef up[/cyan].")
|
|
817
|
+
raise typer.Exit(1) from exc
|
|
818
|
+
console.print(f"dashboard: [cyan]{url}[/cyan]")
|
|
819
|
+
if not no_open:
|
|
820
|
+
import webbrowser
|
|
821
|
+
|
|
822
|
+
webbrowser.open(url)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@app.command("stats")
|
|
826
|
+
def stats(hub: str = typer.Option(None)) -> None:
|
|
827
|
+
"""Show what the kitchen has cooked for you, lifetime."""
|
|
828
|
+
try:
|
|
829
|
+
health = httpx.get(f"{_hub_url(hub)}/healthz", timeout=5.0).json()
|
|
830
|
+
except Exception as exc: # noqa: BLE001
|
|
831
|
+
console.print(f"[red]hub unreachable[/red]: {exc}")
|
|
832
|
+
raise typer.Exit(1) from exc
|
|
833
|
+
lifetime = health.get("lifetime", {})
|
|
834
|
+
hours = lifetime.get("work_seconds", 0) / 3600.0
|
|
835
|
+
fail_hours = lifetime.get("work_seconds_failed", 0) / 3600.0
|
|
836
|
+
submitted = lifetime.get("tasks_submitted", 0)
|
|
837
|
+
completed = lifetime.get("tasks_completed", 0)
|
|
838
|
+
tail = lifetime.get("tasks_failed_cancelled", 0)
|
|
839
|
+
table = Table(show_header=False, box=None)
|
|
840
|
+
table.add_row("tasks completed", f"[bold]{completed}[/bold]")
|
|
841
|
+
if submitted:
|
|
842
|
+
rate = 100 * completed / submitted
|
|
843
|
+
table.add_row("of submitted", f"{completed}/{submitted} ({rate:.0f}%)")
|
|
844
|
+
if tail:
|
|
845
|
+
table.add_row(
|
|
846
|
+
"[dim]failed / cancelled[/dim]",
|
|
847
|
+
f"[dim]{tail} · {fail_hours:.1f}h of local compute[/dim]",
|
|
848
|
+
)
|
|
849
|
+
table.add_row("tokens generated locally", f"[bold]{lifetime.get('task_tokens', 0):,}[/bold]")
|
|
850
|
+
if lifetime.get("room_tokens"):
|
|
851
|
+
table.add_row("[dim]+ agent debate tokens[/dim]", f"[dim]{lifetime['room_tokens']:,}[/dim]")
|
|
852
|
+
table.add_row("local compute", f"[bold]{hours:.1f}h[/bold] (completed tasks)")
|
|
853
|
+
table.add_row("fleet", f"{health.get('online', 0)}/{health.get('agents', 0)} agents online")
|
|
854
|
+
console.print(table)
|
|
855
|
+
console.print(
|
|
856
|
+
"[dim]Note: locally-generated tokens are not saved Claude tokens one-for-one — "
|
|
857
|
+
"local models are weaker and their output usually needs review. This is compute "
|
|
858
|
+
"run on your hardware, not a dollar figure.[/dim]"
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@app.command("tasks")
|
|
863
|
+
def list_tasks(state: str = typer.Option(None), hub: str = typer.Option(None)) -> None:
|
|
864
|
+
"""List tickets."""
|
|
865
|
+
args = {"state": state} if state else {}
|
|
866
|
+
data = _mcp("list_tasks", args, hub=hub)
|
|
867
|
+
table = Table("id", "title", "state", "assignee", "progress")
|
|
868
|
+
for task in data["tasks"]:
|
|
869
|
+
pct = task["progress"]["pct"]
|
|
870
|
+
table.add_row(
|
|
871
|
+
task["id"],
|
|
872
|
+
task["title"][:48],
|
|
873
|
+
task["state"],
|
|
874
|
+
task["assignee"] or "-",
|
|
875
|
+
f"{pct:.0f}%" if pct is not None else "-",
|
|
876
|
+
)
|
|
877
|
+
console.print(table)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@app.command("task")
|
|
881
|
+
def show_task(task_id: str, hub: str = typer.Option(None)) -> None:
|
|
882
|
+
"""Show one ticket with its progress and event history."""
|
|
883
|
+
data = _mcp("task_status", {"task_id": task_id, "verbose": True}, hub=hub)
|
|
884
|
+
task = data["task"]
|
|
885
|
+
console.print(
|
|
886
|
+
f"[bold]{task['title']}[/bold] [{task['state']}] → {task['assignee'] or task['selector']}"
|
|
887
|
+
)
|
|
888
|
+
if task["progress"]["message"]:
|
|
889
|
+
console.print(f" progress: {task['progress']['message']}")
|
|
890
|
+
if task["result"]:
|
|
891
|
+
text = task["result"].get("text") if isinstance(task["result"], dict) else task["result"]
|
|
892
|
+
console.print(f" result: {str(text)[:200]}")
|
|
893
|
+
if task["error"]:
|
|
894
|
+
console.print(f" [red]error[/red]: {task['error']}")
|
|
895
|
+
table = Table("event", "detail")
|
|
896
|
+
for event in data["events"]:
|
|
897
|
+
detail = event["payload"].get("message") or event["payload"].get("error") or ""
|
|
898
|
+
table.add_row(event["kind"], str(detail)[:60])
|
|
899
|
+
console.print(table)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
@app.command("rooms")
|
|
903
|
+
def list_rooms(
|
|
904
|
+
active_only: bool = typer.Option(False, "--active", help="Hide closed conversations."),
|
|
905
|
+
hub: str = typer.Option(None),
|
|
906
|
+
) -> None:
|
|
907
|
+
"""List conversations, including recently closed ones."""
|
|
908
|
+
data = _mcp("list_rooms", {"mine_only": False, "include_archived": not active_only}, hub=hub)
|
|
909
|
+
table = Table("id", "topic", "members", "turns", "state")
|
|
910
|
+
for room in data["rooms"]:
|
|
911
|
+
state = f"archived ({room['archived_reason']})" if room["archived"] else "open"
|
|
912
|
+
table.add_row(
|
|
913
|
+
room["id"],
|
|
914
|
+
room["topic"][:44],
|
|
915
|
+
",".join(room["members"]),
|
|
916
|
+
room["policy"]["turn_policy"],
|
|
917
|
+
state,
|
|
918
|
+
)
|
|
919
|
+
console.print(table)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
@app.command("cancel")
|
|
923
|
+
def cancel_task(task_id: str, hub: str = typer.Option(None)) -> None:
|
|
924
|
+
"""Cancel a ticket (86 it)."""
|
|
925
|
+
data = _mcp("cancel_task", {"task_id": task_id}, identity=OPERATOR_IDENTITY, hub=hub)
|
|
926
|
+
console.print(f"{task_id} → [yellow]{data['task']['state']}[/yellow]")
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@app.command("install-skill")
|
|
930
|
+
def install_skill(
|
|
931
|
+
project: str = typer.Option(
|
|
932
|
+
".", help="Project directory to install the skill into (its .claude/skills/)."
|
|
933
|
+
),
|
|
934
|
+
user: bool = typer.Option(
|
|
935
|
+
False, "--user", help="Install for all projects (~/.claude/skills/)."
|
|
936
|
+
),
|
|
937
|
+
) -> None:
|
|
938
|
+
"""Install the Claude Code skill and the yeschef-expediter subagent."""
|
|
939
|
+
from importlib import resources
|
|
940
|
+
from pathlib import Path
|
|
941
|
+
|
|
942
|
+
claude_root = Path("~/.claude").expanduser() if user else Path(project) / ".claude"
|
|
943
|
+
|
|
944
|
+
skill_target = claude_root / "skills" / "yeschef"
|
|
945
|
+
skill_target.mkdir(parents=True, exist_ok=True)
|
|
946
|
+
for entry in resources.files("yeschef.resources.skill").iterdir():
|
|
947
|
+
if entry.is_file() and not entry.name.startswith("__"):
|
|
948
|
+
(skill_target / entry.name).write_text(entry.read_text())
|
|
949
|
+
console.print(f"[green]✓[/green] skill → {skill_target}")
|
|
950
|
+
|
|
951
|
+
agents_target = claude_root / "agents"
|
|
952
|
+
agents_target.mkdir(parents=True, exist_ok=True)
|
|
953
|
+
for entry in resources.files("yeschef.resources.agents").iterdir():
|
|
954
|
+
if entry.is_file() and not entry.name.startswith("__"):
|
|
955
|
+
(agents_target / entry.name).write_text(entry.read_text())
|
|
956
|
+
console.print(
|
|
957
|
+
f"[green]✓[/green] yeschef-expediter subagent → {agents_target} "
|
|
958
|
+
"(dispatched tasks can appear in the subagent panel)"
|
|
959
|
+
)
|
|
960
|
+
console.print(" Claude Code picks both up in that scope on its next run.")
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# =============================================================== advanced (hub/agent)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
@hub_app.command("serve")
|
|
967
|
+
def hub_serve(
|
|
968
|
+
host: str = typer.Option("0.0.0.0"),
|
|
969
|
+
port: int = typer.Option(8787),
|
|
970
|
+
db: str = typer.Option(None, help="SQLite path (default: <yeschef home>/hub.db)."),
|
|
971
|
+
) -> None:
|
|
972
|
+
"""Run the hub in the foreground (advanced; `up` wraps this with setup)."""
|
|
973
|
+
cfg = settings.load()
|
|
974
|
+
db = db or str(settings.home() / "hub.db")
|
|
975
|
+
console.print(f"[bold]yeschef hub[/bold] → http://{host}:{port} (MCP at /mcp)")
|
|
976
|
+
if not cfg.admin_token and not cfg.register_token:
|
|
977
|
+
console.print("[yellow]open mode — no tokens set.[/yellow]")
|
|
978
|
+
_serve_hub(host, port, db, cfg)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
@hub_app.command("health")
|
|
982
|
+
def hub_health(hub: str = typer.Option(None)) -> None:
|
|
983
|
+
"""Check that the hub is up."""
|
|
984
|
+
try:
|
|
985
|
+
console.print_json(data=httpx.get(f"{_hub_url(hub)}/healthz", timeout=5.0).json())
|
|
986
|
+
except Exception as exc: # noqa: BLE001
|
|
987
|
+
console.print(f"[red]unreachable[/red]: {exc}")
|
|
988
|
+
raise typer.Exit(1) from exc
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
@agent_app.command("run")
|
|
992
|
+
def agent_run(
|
|
993
|
+
config: str = typer.Option(..., "--config", "-c", help="Path to the agent TOML config."),
|
|
994
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
995
|
+
) -> None:
|
|
996
|
+
"""Run an agent from a TOML config (advanced; `join` is the easy path)."""
|
|
997
|
+
cfg = AgentConfig.load(config)
|
|
998
|
+
if cfg.backend.get("type", "openai_compat") != "cli":
|
|
999
|
+
ok, message = asyncio.run(preflight(cfg.backend))
|
|
1000
|
+
if not ok:
|
|
1001
|
+
console.print(
|
|
1002
|
+
f"[red]WARNING:[/red] backend preflight failed — {message}\n"
|
|
1003
|
+
f" {cfg.backend.get('base_url')} does not serve "
|
|
1004
|
+
f"[bold]{cfg.backend.get('model')}[/bold] right now. Registering anyway; "
|
|
1005
|
+
"every task will fail until the model answers. Fix the backend or the "
|
|
1006
|
+
"config, then restart this worker."
|
|
1007
|
+
)
|
|
1008
|
+
_run_worker(cfg, verbose)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
@agent_app.command("run-detached-worker", hidden=True)
|
|
1012
|
+
def _run_detached_worker(
|
|
1013
|
+
name: str = typer.Option(...),
|
|
1014
|
+
hub: str = typer.Option(...),
|
|
1015
|
+
base_url: str = typer.Option(...),
|
|
1016
|
+
model: str = typer.Option(...),
|
|
1017
|
+
backend: str = typer.Option("openai_compat"),
|
|
1018
|
+
runtime: str = typer.Option(None),
|
|
1019
|
+
token: str = typer.Option(None),
|
|
1020
|
+
api_key_env: str = typer.Option(None),
|
|
1021
|
+
provider: str = typer.Option(None),
|
|
1022
|
+
tag: list[str] = typer.Option(None, "--tag"),
|
|
1023
|
+
) -> None:
|
|
1024
|
+
"""Internal entry point used by `join --detach`."""
|
|
1025
|
+
backend_cfg = {"type": backend, "base_url": base_url, "model": model}
|
|
1026
|
+
if runtime:
|
|
1027
|
+
backend_cfg["runtime"] = runtime
|
|
1028
|
+
if api_key_env:
|
|
1029
|
+
backend_cfg["api_key"] = os.environ.get(api_key_env)
|
|
1030
|
+
if provider and provider.lower() == "openrouter":
|
|
1031
|
+
backend_cfg["extra_headers"] = {
|
|
1032
|
+
"HTTP-Referer": "https://github.com/labscommunity/yeschef",
|
|
1033
|
+
"X-Title": "yeschef",
|
|
1034
|
+
}
|
|
1035
|
+
_run_worker(
|
|
1036
|
+
AgentConfig(
|
|
1037
|
+
name=name,
|
|
1038
|
+
hub=hub,
|
|
1039
|
+
tags=list(tag or []),
|
|
1040
|
+
register_token=token,
|
|
1041
|
+
backend=backend_cfg,
|
|
1042
|
+
),
|
|
1043
|
+
verbose=False,
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _wait_healthy(hub_url: str, timeout: float = 15.0) -> bool:
|
|
1048
|
+
deadline = time.monotonic() + timeout
|
|
1049
|
+
while time.monotonic() < deadline:
|
|
1050
|
+
try:
|
|
1051
|
+
if httpx.get(f"{hub_url}/healthz", timeout=2.0).status_code == 200:
|
|
1052
|
+
return True
|
|
1053
|
+
except httpx.HTTPError:
|
|
1054
|
+
pass
|
|
1055
|
+
time.sleep(0.3)
|
|
1056
|
+
return False
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def main() -> None:
|
|
1060
|
+
app()
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
def legacy_main() -> None:
|
|
1064
|
+
"""Entry point for the deprecated `cascadia-tasks` command name."""
|
|
1065
|
+
console.print(
|
|
1066
|
+
"[yellow]note:[/yellow] `cascadia-tasks` is now [bold]yeschef[/bold] — "
|
|
1067
|
+
"same tool, new name. This alias keeps working for now.\n"
|
|
1068
|
+
)
|
|
1069
|
+
app()
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
if __name__ == "__main__":
|
|
1073
|
+
main()
|