clu-ssh 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.
clu/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """clu:SSH 会话保持/管理 + Slurm 集群与服务器的 AI agent 能力层。"""
2
+
3
+ __version__ = "0.1.0"
clu/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m clu 入口。"""
2
+
3
+ from clu.cli import app
4
+
5
+ app()
clu/cli.py ADDED
@@ -0,0 +1,557 @@
1
+ """clu 命令行入口:daemon 的薄客户端;连接配置管理为本地文件操作。"""
2
+
3
+ import asyncio
4
+ from pathlib import Path
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from clu import __version__
11
+ from clu.client import CluClient, CluError
12
+ from clu.config.ssh_config import import_into_store
13
+ from clu.config.store import ConfigError, ConfigStore
14
+ from clu.constants import connections_path
15
+ from clu.daemon.lifecycle import daemon_status, ensure_daemon, stop_daemon
16
+
17
+ app = typer.Typer(
18
+ name="clu",
19
+ help="SSH 会话保持/管理 + Slurm 集群与服务器 agent 能力层。",
20
+ no_args_is_help=True,
21
+ invoke_without_command=True,
22
+ )
23
+ connection_app = typer.Typer(help="连接配置管理。", no_args_is_help=True)
24
+ server_app = typer.Typer(help="daemon 生命周期管理。", no_args_is_help=True)
25
+ session_app = typer.Typer(help="tmux 托管会话。", no_args_is_help=True)
26
+ forward_app = typer.Typer(help="本地端口转发(-L 最小集)。", no_args_is_help=True)
27
+ app.add_typer(connection_app, name="connection")
28
+ app.add_typer(server_app, name="server")
29
+ app.add_typer(session_app, name="session")
30
+ app.add_typer(forward_app, name="forward")
31
+
32
+ console = Console()
33
+
34
+
35
+ def _fail(message: str, code: int = 1) -> None:
36
+ console.print(f"[red]{message}[/red]")
37
+ raise typer.Exit(code=code)
38
+
39
+
40
+ @app.callback()
41
+ def main(
42
+ version: bool = typer.Option(False, "--version", help="显示版本并退出。"),
43
+ ) -> None:
44
+ """clu:连接池与集群操作的统一入口。"""
45
+ if version:
46
+ typer.echo(__version__)
47
+ raise typer.Exit()
48
+
49
+
50
+ # ---- server ----
51
+
52
+
53
+ @server_app.command("ensure")
54
+ def server_ensure() -> None:
55
+ """确保 daemon 运行中;不在则自动拉起。"""
56
+ try:
57
+ result = ensure_daemon()
58
+ except CluError as exc:
59
+ _fail(f"daemon 拉起失败:{exc.message}(日志:~/.local/state 或运行时目录下 daemon.log)")
60
+ return
61
+ if result["started"]:
62
+ console.print(f"[green]daemon 已拉起[/green](pid={result.get('pid')})")
63
+ else:
64
+ console.print("daemon 已在运行")
65
+
66
+
67
+ @server_app.command("status")
68
+ def server_status() -> None:
69
+ """查看 daemon 与连接池状态。"""
70
+ try:
71
+ result = asyncio.run(daemon_status())
72
+ except CluError as exc:
73
+ _fail(f"daemon 未运行或不可达:{exc.message}(先执行 clu server ensure)")
74
+ return
75
+ console.print(
76
+ f"daemon [green]在线[/green] v{result.get('version')} pid={result.get('pid')}"
77
+ f" uptime={result.get('uptime_s')}s"
78
+ )
79
+ connections = result.get("connections") or []
80
+ if connections:
81
+ table = Table(title="连接池")
82
+ for col in ("label", "kind", "host", "state", "last_error"):
83
+ table.add_column(col)
84
+ for row in connections:
85
+ state = str(row.get("state"))
86
+ style = "green" if state == "online" else "yellow"
87
+ table.add_row(
88
+ str(row.get("label")),
89
+ str(row.get("kind")),
90
+ f"{row.get('host')}:{row.get('port')}",
91
+ f"[{style}]{state}[/{style}]",
92
+ str(row.get("last_error") or ""),
93
+ )
94
+ console.print(table)
95
+
96
+
97
+ @server_app.command("stop")
98
+ def server_stop() -> None:
99
+ """停止 daemon。"""
100
+ try:
101
+ asyncio.run(stop_daemon())
102
+ console.print("daemon 已停止")
103
+ except CluError as exc:
104
+ _fail(f"停止失败:{exc.message}")
105
+
106
+
107
+ # ---- connection ----
108
+
109
+
110
+ @connection_app.command("import")
111
+ def connection_import(
112
+ config: Path = typer.Option(
113
+ Path.home() / ".ssh" / "config", "--config", help="ssh config 源文件路径。"
114
+ ),
115
+ overwrite: bool = typer.Option(False, "--overwrite", help="同名连接覆盖写入。"),
116
+ ) -> None:
117
+ """从 ssh config 一次性导入公钥连接(副本,解析 ProxyJump)。"""
118
+ try:
119
+ store = ConfigStore(connections_path())
120
+ report = import_into_store(store, config, overwrite=overwrite)
121
+ except (ConfigError, OSError) as exc:
122
+ _fail(f"导入失败:{exc}")
123
+ return
124
+ if report.added:
125
+ console.print(f"[green]导入 {len(report.added)} 条[/green]:{', '.join(report.added)}")
126
+ if report.skipped:
127
+ console.print(f"[yellow]跳过(已存在)[/yellow]:{', '.join(report.skipped)}")
128
+ for warning in report.warnings:
129
+ console.print(f"[yellow]注意[/yellow] {warning}")
130
+ if not (report.added or report.skipped or report.warnings):
131
+ console.print("没有可导入的条目")
132
+
133
+
134
+ @connection_app.command("list")
135
+ def connection_list() -> None:
136
+ """列出已管理的连接及其池状态。"""
137
+ try:
138
+ data = ConfigStore(connections_path()).load()
139
+ except ConfigError as exc:
140
+ _fail(f"配置读取失败:{exc}")
141
+ return
142
+ if not data.connections:
143
+ console.print("暂无连接(先执行 clu connection import 或手写 connections.toml)")
144
+ return
145
+ health: dict[str, str] = {}
146
+ try:
147
+ result = asyncio.run(daemon_status())
148
+ for row in result.get("connections") or []:
149
+ health[str(row.get("label"))] = str(row.get("state"))
150
+ except CluError:
151
+ health = {}
152
+ table = Table(title=f"连接(daemon {'在线' if health else '未运行'})")
153
+ for col in ("label", "kind", "host", "user", "proxy", "profile", "state"):
154
+ table.add_column(col)
155
+ for label, cfg in sorted(data.connections.items()):
156
+ state = health.get(label, "-")
157
+ style = "green" if state == "online" else "yellow"
158
+ table.add_row(
159
+ label,
160
+ cfg.kind,
161
+ f"{cfg.host}:{cfg.port}",
162
+ cfg.user,
163
+ cfg.proxy or "-",
164
+ cfg.profile or "-",
165
+ f"[{style}]{state}[/{style}]",
166
+ )
167
+ console.print(table)
168
+
169
+
170
+ @connection_app.command("add")
171
+ def connection_add(
172
+ label: str = typer.Argument(..., help="连接 label(字母数字开头,可用 ._-)。"),
173
+ host: str = typer.Option(..., "--host", help="主机名或 IP。"),
174
+ user: str = typer.Option(..., "--user", help="登录用户。"),
175
+ port: int = typer.Option(22, "--port", help="端口。"),
176
+ kind: str = typer.Option("instance", "--kind", help="cluster | instance | local。"),
177
+ identity: str = typer.Option(
178
+ None, "--identity", "-i", help="私钥路径(缺省走 ssh-agent/默认 key)。"
179
+ ),
180
+ proxy: str = typer.Option(None, "--proxy", help="单级跳板:另一连接的 label。"),
181
+ profile: str = typer.Option(None, "--profile", help="定制 profile 名。"),
182
+ overwrite: bool = typer.Option(False, "--overwrite", help="同名覆盖。"),
183
+ ) -> None:
184
+ """手动添加连接(Slurm 集群记得 --kind cluster)。"""
185
+ from clu.config.store import ConnectionConfig
186
+
187
+ if kind not in ("cluster", "instance", "local"):
188
+ _fail(f"kind 必须是 cluster/instance/local,收到:{kind}")
189
+ return
190
+ try:
191
+ store = ConfigStore(connections_path())
192
+ written = store.add_connection(
193
+ ConnectionConfig(
194
+ label=label,
195
+ kind=kind,
196
+ host=host,
197
+ port=port,
198
+ user=user,
199
+ identity=identity,
200
+ proxy=proxy,
201
+ profile=profile,
202
+ ),
203
+ overwrite=overwrite,
204
+ )
205
+ except (ConfigError, ValueError) as exc:
206
+ _fail(f"添加失败:{exc}")
207
+ return
208
+ if written:
209
+ console.print(f"[green]已添加[/green] {label}({kind} {host}:{port})")
210
+ else:
211
+ console.print(f"[yellow]已存在[/yellow] {label}(--overwrite 覆盖)")
212
+
213
+
214
+ @connection_app.command("remove")
215
+ def connection_remove(label: str) -> None:
216
+ """删除连接条目。"""
217
+ try:
218
+ removed = ConfigStore(connections_path()).remove_connection(label)
219
+ except ConfigError as exc:
220
+ _fail(f"删除失败:{exc}")
221
+ return
222
+ if removed:
223
+ console.print(f"已删除 {label}")
224
+ else:
225
+ _fail(f"不存在:{label}")
226
+
227
+
228
+ @connection_app.command("check")
229
+ def connection_check(label: str) -> None:
230
+ """探测一条连接:经 daemon 连接池建立 SSH 并执行 true。"""
231
+
232
+ async def _check() -> dict[str, object]:
233
+ async with CluClient() as client:
234
+ return await client.request("connection.check", {"label": label})
235
+
236
+ try:
237
+ ensure_daemon()
238
+ result = asyncio.run(_check())
239
+ except CluError as exc:
240
+ _fail(
241
+ f"[red]探测失败[/red] {label}: {exc.message}"
242
+ + (f"({exc.retry_after:.0f}s 后可重试)" if exc.retry_after else "")
243
+ )
244
+ return
245
+ console.print(
246
+ f"[green]在线[/green] {label}: exit={result.get('exit_code')}"
247
+ f" 用时 {result.get('duration_ms')}ms"
248
+ )
249
+
250
+
251
+ @app.command("exec")
252
+ def exec_command(
253
+ label: str = typer.Argument(..., help="连接 label。"),
254
+ command: list[str] = typer.Argument(..., help="远程命令(按空格拼接执行)。"),
255
+ timeout: float = typer.Option(60.0, "--timeout", help="超时秒数。"),
256
+ ) -> None:
257
+ """在连接上执行一条远程命令(受安全门约束)。"""
258
+
259
+ async def _do() -> dict[str, object]:
260
+ async with CluClient(timeout=max(timeout + 30, 60)) as client:
261
+ return await client.request(
262
+ "exec",
263
+ {"label": label, "command": " ".join(command), "timeout": timeout},
264
+ )
265
+
266
+ try:
267
+ ensure_daemon()
268
+ result = asyncio.run(_do())
269
+ except CluError as exc:
270
+ _fail(f"执行失败:{exc.message}")
271
+ return
272
+ stdout = str(result.get("stdout") or "")
273
+ stderr = str(result.get("stderr") or "")
274
+ if stdout:
275
+ console.print(stdout.rstrip())
276
+ if stderr:
277
+ console.print(f"[red]{stderr.rstrip()}[/red]")
278
+ console.print(f"[dim]exit={result.get('exit_code')} {result.get('duration_ms')}ms[/dim]")
279
+ raise typer.Exit(code=int(str(result.get("exit_code") or 0)))
280
+
281
+
282
+ def _do_request(method: str, params: dict[str, object]) -> dict[str, object]:
283
+ async def _call() -> dict[str, object]:
284
+ async with CluClient(timeout=180.0) as client:
285
+ return dict(await client.request(method, params))
286
+
287
+ result = asyncio.run(_call())
288
+ return result
289
+
290
+
291
+ @session_app.command("list")
292
+ def session_list(label: str = typer.Argument(None, help="连接 label;缺省列出全部。")) -> None:
293
+ """列出 tmux 托管会话(alloc 资源会话)。"""
294
+ try:
295
+ ensure_daemon()
296
+ result = _do_request("session.list", {"label": label})
297
+ except CluError as exc:
298
+ _fail(f"查询失败:{exc.message}")
299
+ return
300
+ raw_sessions = result.get("sessions")
301
+ sessions = (
302
+ [dict(r) for r in raw_sessions if isinstance(r, dict)]
303
+ if isinstance(raw_sessions, list)
304
+ else []
305
+ )
306
+ if not sessions:
307
+ console.print("暂无托管会话")
308
+ return
309
+ table = Table(title="托管会话")
310
+ for col in ("connection", "purpose", "state", "attached"):
311
+ table.add_column(col)
312
+ for row in sessions:
313
+ table.add_row(
314
+ str(row.get("connection")),
315
+ str(row.get("purpose")),
316
+ "在线",
317
+ str(row.get("attached")),
318
+ )
319
+ console.print(table)
320
+
321
+
322
+ @app.command("resources")
323
+ def resources_cmd(
324
+ label: str = typer.Argument(..., help="cluster 连接 label。"),
325
+ partition: str = typer.Option(None, "--partition", "-p", help="只看该分区。"),
326
+ cpu_free: bool = typer.Option(False, "--cpu-free", help="只看有空闲 CPU 的节点。"),
327
+ cpu_gpu_free: bool = typer.Option(
328
+ False, "--cpu-gpu-free", help="只看 CPU 和 GPU 都有空闲的节点。"
329
+ ),
330
+ fully_idle: bool = typer.Option(False, "--fully-idle", help="只看 CPU 与 GPU 全空闲的节点。"),
331
+ gpu_only: bool = typer.Option(
332
+ False, "--gpu-only", help="只看有 GPU 的节点(可与其他条件叠加)。"
333
+ ),
334
+ nodes: bool = typer.Option(False, "--nodes", help="输出节点明细表。"),
335
+ ) -> None:
336
+ """集群资源余量:每分区/每节点的 CPU 与 GPU(总量/已占/空闲)。"""
337
+ flt = (
338
+ "cpu_free"
339
+ if cpu_free
340
+ else "cpu_gpu_free"
341
+ if cpu_gpu_free
342
+ else "fully_idle"
343
+ if fully_idle
344
+ else None
345
+ )
346
+ if sum(bool(x) for x in (cpu_free, cpu_gpu_free, fully_idle)) > 1:
347
+ _fail("--cpu-free/--cpu-gpu-free/--fully-idle 只能选一个")
348
+ return
349
+ try:
350
+ ensure_daemon()
351
+ result = _do_request(
352
+ "slurm.resources",
353
+ {
354
+ "label": label,
355
+ "partition": partition,
356
+ "filter": flt,
357
+ "gpu_only": gpu_only,
358
+ "include_nodes": nodes,
359
+ },
360
+ )
361
+ except CluError as exc:
362
+ _fail(f"查询失败:{exc.message}")
363
+ return
364
+
365
+ parts = result.get("partitions")
366
+ filters_desc = str(result.get("filters") or "")
367
+ table = Table(title=f"{label} 资源余量 [dim]{filters_desc}[/dim]")
368
+ for col in ("分区", "节点(空闲)", "CPU 空闲/总", "GPU 空闲/总"):
369
+ table.add_column(col)
370
+ for pname, p in parts.items() if isinstance(parts, dict) else []:
371
+ gpus = " ".join(f"{g}:{v['free']}/{v['total']}" for g, v in p["gpus"].items()) or "-"
372
+ cpu = p["cpu"]
373
+ table.add_row(
374
+ str(pname),
375
+ f"{p['nodes']}({p['idle_nodes']})",
376
+ f"{cpu['free']}/{cpu['total']}",
377
+ gpus,
378
+ )
379
+ console.print(table)
380
+
381
+ raw = result.get("nodes")
382
+ node_rows = [dict(r) for r in raw if isinstance(r, dict)] if isinstance(raw, list) else []
383
+ if node_rows:
384
+ ntable = Table(title=f"节点明细({len(node_rows)})")
385
+ for col in ("节点", "状态", "CPU 空闲/总", "GPU 空闲/总", "分区"):
386
+ ntable.add_column(col)
387
+ for n in node_rows:
388
+ gpus = (
389
+ " ".join(f"{g}:{v['free']}/{v['total']}" for g, v in n.get("gpus", {}).items())
390
+ or "-"
391
+ )
392
+ cpu = n.get("cpu", {})
393
+ state = str(n.get("state", ""))
394
+ style = (
395
+ "green" if "IDLE" in state else ("red" if not n.get("usable", True) else "yellow")
396
+ )
397
+ ntable.add_row(
398
+ str(n.get("node")),
399
+ f"[{style}]{state}[/{style}]",
400
+ f"{cpu.get('free')}/{cpu.get('total')}",
401
+ gpus,
402
+ ",".join(n.get("partitions") or []),
403
+ )
404
+ console.print(ntable)
405
+ console.print(f"[dim]{result.get('note')}[/dim]")
406
+
407
+
408
+ @app.command("alloc")
409
+ def alloc_cmd(
410
+ label: str,
411
+ purpose: str = typer.Option("alloc", "--purpose", help="会话用途名。"),
412
+ gpu: bool = typer.Option(False, "--gpu", help="只抢 GPU。"),
413
+ cpu: bool = typer.Option(False, "--cpu", help="只抢 CPU。"),
414
+ gpus: int = typer.Option(1, "--gpus", help="GPU 卡数。"),
415
+ cpus: int = typer.Option(None, "--cpus", help="CPU 核数(cpu 资源默认 4)。"),
416
+ partition: str = typer.Option(None, "--partition", "-p", help="显式分区(跳过自动选择)。"),
417
+ wait: float = typer.Option(120.0, "--wait", help="等待授予秒数。"),
418
+ ) -> None:
419
+ """抢占资源(默认 GPU 优先无则 CPU),granted 后 clu attach 接入计算节点。"""
420
+ if gpu and cpu:
421
+ _fail("--gpu 与 --cpu 只能选一个")
422
+ return
423
+ resource = "gpu" if gpu else "cpu" if cpu else None
424
+ try:
425
+ ensure_daemon()
426
+ result = _do_request(
427
+ "slurm.alloc",
428
+ {
429
+ "label": label,
430
+ "purpose": purpose,
431
+ "resource": resource,
432
+ "gpus": gpus,
433
+ "cpus": cpus,
434
+ "partition": partition,
435
+ "wait": wait,
436
+ },
437
+ )
438
+ except CluError as exc:
439
+ _fail(f"抢占失败:{exc.message}")
440
+ return
441
+ state = str(result.get("state"))
442
+ style = "green" if state == "granted" else "yellow" if state == "pending" else "red"
443
+ console.print(
444
+ f"[{style}]{state}[/{style}] job={result.get('job_id')} node={result.get('node')}"
445
+ f" partition={result.get('partition')}({result.get('chosen_by')})"
446
+ )
447
+ if state == "granted":
448
+ console.print(f"接入:[bold]clu attach {label} {purpose}[/bold](exit 即释放)")
449
+ if result.get("availability"):
450
+ console.print(f"[dim]余量:{result.get('availability')}[/dim]")
451
+ if result.get("error"):
452
+ console.print(f"[red]{result.get('error')}[/red]")
453
+
454
+
455
+ @app.command("attach")
456
+ def attach(label: str, purpose: str) -> None:
457
+ """接入托管会话的 tmux(交互式,经系统 ssh -t)。"""
458
+ import os
459
+
460
+ from clu.config.store import ConfigError, ConfigStore
461
+ from clu.tmux import session_name
462
+
463
+ try:
464
+ data = ConfigStore(connections_path()).load()
465
+ except ConfigError as exc:
466
+ _fail(f"配置读取失败:{exc}")
467
+ return
468
+ cfg = data.connections.get(label)
469
+ if cfg is None:
470
+ _fail(f"未知连接:{label}")
471
+ return
472
+ args = ["ssh", "-t", "-p", str(cfg.port)]
473
+ if cfg.identity:
474
+ args += ["-i", os.path.expanduser(cfg.identity)]
475
+ if cfg.proxy:
476
+ jump = data.connections.get(cfg.proxy)
477
+ if jump is not None:
478
+ jump_target = f"{jump.user}@{jump.host}"
479
+ if jump.port != 22:
480
+ jump_target += f":{jump.port}"
481
+ args += ["-J", jump_target]
482
+ target = f"{cfg.user}@{cfg.host}"
483
+ args += [target, "tmux attach -t " + session_name(label, purpose)]
484
+ console.print(f"[dim]{' '.join(args[: len(args) - 1])} tmux attach ...[/dim]")
485
+ os.execvp("ssh", args)
486
+
487
+
488
+ @forward_app.command("add")
489
+ def forward_add(
490
+ label: str,
491
+ remote_host: str,
492
+ remote_port: int,
493
+ local_port: int = typer.Option(0, "--local-port", "-l", help="本地端口,0 为随机分配。"),
494
+ ) -> None:
495
+ """建立转发:本机端口 -> 远端 host:port(经连接池隧道)。"""
496
+ try:
497
+ ensure_daemon()
498
+ row = _do_request(
499
+ "forward.add",
500
+ {
501
+ "label": label,
502
+ "remote_host": remote_host,
503
+ "remote_port": remote_port,
504
+ "local_port": local_port,
505
+ },
506
+ )
507
+ except CluError as exc:
508
+ _fail(f"转发建立失败:{exc.message}")
509
+ return
510
+ console.print(
511
+ f"[green]已建立[/green] 127.0.0.1:{row.get('local_port')}"
512
+ f" -> {remote_host}:{remote_port}({row.get('id')})"
513
+ )
514
+
515
+
516
+ @forward_app.command("list")
517
+ def forward_list() -> None:
518
+ """列出当前转发。"""
519
+ try:
520
+ ensure_daemon()
521
+ result = _do_request("forward.list", {})
522
+ except CluError as exc:
523
+ _fail(f"查询失败:{exc.message}")
524
+ return
525
+ raw = result.get("forwards")
526
+ rows = [dict(r) for r in raw if isinstance(r, dict)] if isinstance(raw, list) else []
527
+ if not rows:
528
+ console.print("暂无转发")
529
+ return
530
+ table = Table(title="本地转发")
531
+ for col in ("id", "connection", "local", "remote"):
532
+ table.add_column(col)
533
+ for row in rows:
534
+ table.add_row(
535
+ str(row.get("id")),
536
+ str(row.get("connection")),
537
+ f"127.0.0.1:{row.get('local_port')}",
538
+ f"{row.get('remote_host')}:{row.get('remote_port')}",
539
+ )
540
+ console.print(table)
541
+
542
+
543
+ @forward_app.command("remove")
544
+ def forward_remove(forward_id: str) -> None:
545
+ """移除转发。"""
546
+ try:
547
+ ensure_daemon()
548
+ _do_request("forward.remove", {"id": forward_id})
549
+ except CluError as exc:
550
+ _fail(f"移除失败:{exc.message}")
551
+ return
552
+ console.print(f"已移除 {forward_id}")
553
+
554
+
555
+ def cli_main() -> None:
556
+ """console_scripts 入口。"""
557
+ app()
clu/client.py ADDED
@@ -0,0 +1,87 @@
1
+ """unix socket 薄客户端库:CLI 与 MCP server 共用。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import clu.constants as C
11
+ from clu.protocol import RpcErr, RpcRequest, decode_response, encode_line
12
+
13
+
14
+ class CluError(Exception):
15
+ """daemon 返回的结构化错误,或本地通信失败。"""
16
+
17
+ def __init__(self, code: str, message: str, retry_after: float | None = None) -> None:
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.message = message
21
+ self.retry_after = retry_after
22
+
23
+
24
+ class CluClient:
25
+ """单连接复用的 RPC 客户端;断线自动重连一次。"""
26
+
27
+ def __init__(
28
+ self,
29
+ socket_path: Path | None = None,
30
+ timeout: float = C.CLIENT_TIMEOUT,
31
+ ) -> None:
32
+ self._socket_path = socket_path or C.daemon_socket()
33
+ self._timeout = timeout
34
+ self._reader: asyncio.StreamReader | None = None
35
+ self._writer: asyncio.StreamWriter | None = None
36
+
37
+ async def __aenter__(self) -> CluClient:
38
+ return self
39
+
40
+ async def __aexit__(self, *exc: object) -> None:
41
+ await self.aclose()
42
+
43
+ async def aclose(self) -> None:
44
+ if self._writer is not None:
45
+ self._writer.close()
46
+ with contextlib.suppress(OSError):
47
+ await self._writer.wait_closed()
48
+ self._writer = None
49
+ self._reader = None
50
+
51
+ async def _connect(self, retry: bool = True) -> None:
52
+ if self._writer is not None and not self._writer.is_closing():
53
+ return
54
+ try:
55
+ self._reader, self._writer = await asyncio.wait_for(
56
+ asyncio.open_unix_connection(str(self._socket_path)), self._timeout
57
+ )
58
+ except (TimeoutError, OSError) as exc:
59
+ raise CluError("daemon_unreachable", f"无法连接 daemon:{exc}") from exc
60
+ if retry is False:
61
+ return
62
+
63
+ async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
64
+ """发起一次 RPC。连接失效时自动重连一次重发。"""
65
+ for attempt in (1, 2):
66
+ await self._connect()
67
+ assert self._writer is not None and self._reader is not None
68
+ req = RpcRequest(method=method, params=params or {})
69
+ try:
70
+ self._writer.write(encode_line(req))
71
+ await self._writer.drain()
72
+ line = await asyncio.wait_for(self._reader.readline(), self._timeout)
73
+ except (TimeoutError, OSError) as exc:
74
+ await self.aclose()
75
+ if attempt == 1:
76
+ continue
77
+ raise CluError("daemon_unreachable", f"daemon 通信失败:{exc}") from exc
78
+ if not line:
79
+ await self.aclose()
80
+ if attempt == 1:
81
+ continue
82
+ raise CluError("daemon_unreachable", "daemon 关闭了连接") from None
83
+ resp = decode_response(line)
84
+ if isinstance(resp, RpcErr):
85
+ raise CluError(resp.error.code, resp.error.message, resp.error.retry_after)
86
+ return resp.result
87
+ raise CluError("internal", "unreachable") # pragma: no cover
clu/config/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """配置包:连接配置存储与 ssh config 导入。"""