esclak 0.0.5__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.
- clak/__init__.py +3 -0
- clak/__main__.py +4 -0
- clak/cli.py +60 -0
- clak/commands/__init__.py +1 -0
- clak/commands/_registry.py +40 -0
- clak/commands/clear.py +21 -0
- clak/commands/quit.py +17 -0
- clak/commands/status.py +21 -0
- clak/commands/types.py +71 -0
- clak/commands/version.py +22 -0
- clak/runtime/__init__.py +0 -0
- clak/runtime/ask/__init__.py +12 -0
- clak/runtime/ask/answer.py +34 -0
- clak/runtime/ask/request.py +25 -0
- clak/runtime/headless.py +101 -0
- clak/runtime/params.py +48 -0
- clak/runtime/session.py +113 -0
- clak/runtime/slash.py +72 -0
- clak/runtime/step_list.py +146 -0
- clak/scripts/__init__.py +5 -0
- clak/scripts/_registry.py +63 -0
- clak/scripts/pack_game_res.py +103 -0
- clak/scripts/select_server.py +75 -0
- clak/tui/__init__.py +0 -0
- clak/tui/app.py +155 -0
- clak/tui/event.py +28 -0
- clak/tui/screen.py +130 -0
- clak/tui/state.py +425 -0
- clak/tui/theme.py +10 -0
- clak/tui/views/__init__.py +0 -0
- clak/tui/views/ask.py +324 -0
- clak/tui/views/ask_controller.py +193 -0
- clak/tui/views/command_menu.py +58 -0
- clak/tui/views/context.py +22 -0
- clak/tui/views/history.py +108 -0
- clak/tui/views/input.py +233 -0
- esclak-0.0.5.dist-info/METADATA +171 -0
- esclak-0.0.5.dist-info/RECORD +40 -0
- esclak-0.0.5.dist-info/WHEEL +4 -0
- esclak-0.0.5.dist-info/entry_points.txt +2 -0
clak/__init__.py
ADDED
clak/__main__.py
ADDED
clak/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from clak.runtime.headless import HeadlessError, emit, run_line
|
|
7
|
+
from clak.tui.app import run_tui
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="clak", description="固定流程脚本 TUI")
|
|
12
|
+
sub = parser.add_subparsers(dest="command")
|
|
13
|
+
|
|
14
|
+
sub.add_parser("tui", help="打开交互 TUI(默认)")
|
|
15
|
+
|
|
16
|
+
exec_parser = sub.add_parser("exec", help="无头执行一条 slash 或 prompt")
|
|
17
|
+
exec_parser.add_argument(
|
|
18
|
+
"line",
|
|
19
|
+
help='要执行的行,例如 "/version" 或 "/pack-game-res"',
|
|
20
|
+
)
|
|
21
|
+
exec_parser.add_argument(
|
|
22
|
+
"--set",
|
|
23
|
+
action="append",
|
|
24
|
+
default=[],
|
|
25
|
+
metavar="FIELD=VALUE",
|
|
26
|
+
help="脚本参数(可重复),如 --set version=1.0 --set targets=base,cn",
|
|
27
|
+
)
|
|
28
|
+
exec_parser.add_argument(
|
|
29
|
+
"--use-defaults",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="未 --set 的 ask 字段使用 AskRequest.default(无交互批量)",
|
|
32
|
+
)
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> None:
|
|
37
|
+
parser = build_parser()
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
command = args.command
|
|
40
|
+
|
|
41
|
+
if command == "exec":
|
|
42
|
+
from clak.runtime.params import ScriptInputs
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
inputs = ScriptInputs.parse_set_flags(
|
|
46
|
+
args.set,
|
|
47
|
+
allow_defaults=args.use_defaults or not args.set,
|
|
48
|
+
)
|
|
49
|
+
run = run_line(args.line, script_inputs=inputs)
|
|
50
|
+
except (HeadlessError, ValueError) as error:
|
|
51
|
+
print(f"clak: {error}", file=sys.stderr)
|
|
52
|
+
code = error.code if isinstance(error, HeadlessError) else 2
|
|
53
|
+
raise SystemExit(code) from error
|
|
54
|
+
raise SystemExit(emit(run))
|
|
55
|
+
|
|
56
|
+
if command in (None, "tui"):
|
|
57
|
+
run_tui()
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
parser.error(f"未知子命令: {command}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# 各 slash 子命令见同目录模块;聚合在 registry。
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from clak.commands import (
|
|
2
|
+
clear,
|
|
3
|
+
quit,
|
|
4
|
+
status,
|
|
5
|
+
version,
|
|
6
|
+
)
|
|
7
|
+
from clak.commands.types import ScriptEntry, SlashCommand, SlashEntry
|
|
8
|
+
from clak.runtime.session import ScriptSession
|
|
9
|
+
from clak.scripts._registry import all_scripts
|
|
10
|
+
|
|
11
|
+
_BUILTINS: tuple[SlashCommand, ...] = (
|
|
12
|
+
clear.COMMAND,
|
|
13
|
+
version.COMMAND,
|
|
14
|
+
status.COMMAND,
|
|
15
|
+
quit.COMMAND,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
COMMANDS: tuple[SlashEntry, ...] = _BUILTINS + all_scripts()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def command_for_token(token: str) -> SlashEntry | None:
|
|
22
|
+
needle = token.lower()
|
|
23
|
+
for cmd in COMMANDS:
|
|
24
|
+
if needle in cmd.all_names():
|
|
25
|
+
return cmd
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def script_command(script_id: str) -> ScriptEntry | None:
|
|
30
|
+
for cmd in all_scripts():
|
|
31
|
+
if cmd.script_id == script_id:
|
|
32
|
+
return cmd
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_script(session: ScriptSession, script_id: str) -> None:
|
|
37
|
+
cmd = script_command(script_id)
|
|
38
|
+
if not cmd:
|
|
39
|
+
raise ValueError(f"未知脚本: {script_id}")
|
|
40
|
+
cmd.script_run(session)
|
clak/commands/clear.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from clak.commands.types import (
|
|
2
|
+
SlashAction,
|
|
3
|
+
SlashCommand,
|
|
4
|
+
SlashDispatchResult,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dispatch() -> SlashDispatchResult:
|
|
9
|
+
return SlashDispatchResult(
|
|
10
|
+
action=SlashAction.NEW_SESSION,
|
|
11
|
+
message="已清空 history",
|
|
12
|
+
status="Session cleared",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COMMAND = SlashCommand(
|
|
17
|
+
"/clear",
|
|
18
|
+
("/new",),
|
|
19
|
+
"清空 history",
|
|
20
|
+
handler=dispatch,
|
|
21
|
+
)
|
clak/commands/quit.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from clak.commands.types import (
|
|
2
|
+
SlashAction,
|
|
3
|
+
SlashCommand,
|
|
4
|
+
SlashDispatchResult,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dispatch() -> SlashDispatchResult:
|
|
9
|
+
return SlashDispatchResult(action=SlashAction.QUIT, status="再见")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
COMMAND = SlashCommand(
|
|
13
|
+
"/quit",
|
|
14
|
+
("/exit",),
|
|
15
|
+
"退出 TUI",
|
|
16
|
+
handler=dispatch,
|
|
17
|
+
)
|
clak/commands/status.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from clak.commands.types import (
|
|
2
|
+
SlashAction,
|
|
3
|
+
SlashCommand,
|
|
4
|
+
SlashDispatchResult,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def dispatch() -> SlashDispatchResult:
|
|
9
|
+
return SlashDispatchResult(
|
|
10
|
+
action=SlashAction.SHOW_STATUS,
|
|
11
|
+
message="clak 脚本聚合",
|
|
12
|
+
status="已显示状态",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
COMMAND = SlashCommand(
|
|
17
|
+
"/status",
|
|
18
|
+
("/context",),
|
|
19
|
+
"软件名与定位",
|
|
20
|
+
handler=dispatch,
|
|
21
|
+
)
|
clak/commands/types.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
from clak.runtime.session import ScriptSession
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SlashAction(Enum):
|
|
11
|
+
NONE = auto()
|
|
12
|
+
NEW_SESSION = auto()
|
|
13
|
+
SHOW_STATUS = auto()
|
|
14
|
+
RUN_SCRIPT = auto()
|
|
15
|
+
QUIT = auto()
|
|
16
|
+
UNKNOWN = auto()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class SlashMatch:
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SlashDispatchResult:
|
|
27
|
+
action: SlashAction
|
|
28
|
+
message: str | None = None
|
|
29
|
+
status: str | None = None
|
|
30
|
+
command: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SlashTokenKind(Enum):
|
|
34
|
+
NOT_SLASH = auto()
|
|
35
|
+
INCOMPLETE = auto()
|
|
36
|
+
AVAILABLE = auto()
|
|
37
|
+
UNKNOWN = auto()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SlashHandler = Callable[[], SlashDispatchResult]
|
|
41
|
+
ScriptRunner = Callable[[ScriptSession], None]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class SlashCommand:
|
|
46
|
+
"""纯 slash 命令:只分发,不跑脚本。"""
|
|
47
|
+
|
|
48
|
+
name: str
|
|
49
|
+
aliases: tuple[str, ...]
|
|
50
|
+
description: str
|
|
51
|
+
handler: SlashHandler
|
|
52
|
+
|
|
53
|
+
def all_names(self) -> tuple[str, ...]:
|
|
54
|
+
return (self.name, *self.aliases)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ScriptEntry:
|
|
59
|
+
"""脚本入口:slash 名 + 脚本 runner。"""
|
|
60
|
+
|
|
61
|
+
name: str
|
|
62
|
+
aliases: tuple[str, ...]
|
|
63
|
+
description: str
|
|
64
|
+
script_id: str
|
|
65
|
+
script_run: ScriptRunner
|
|
66
|
+
|
|
67
|
+
def all_names(self) -> tuple[str, ...]:
|
|
68
|
+
return (self.name, *self.aliases)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
SlashEntry = SlashCommand | ScriptEntry
|
clak/commands/version.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from clak import __version__
|
|
2
|
+
from clak.commands.types import (
|
|
3
|
+
SlashAction,
|
|
4
|
+
SlashCommand,
|
|
5
|
+
SlashDispatchResult,
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dispatch() -> SlashDispatchResult:
|
|
10
|
+
return SlashDispatchResult(
|
|
11
|
+
action=SlashAction.SHOW_STATUS,
|
|
12
|
+
message=f"clak {__version__}",
|
|
13
|
+
status="已显示版本",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
COMMAND = SlashCommand(
|
|
18
|
+
"/version",
|
|
19
|
+
(),
|
|
20
|
+
"显示工具版本",
|
|
21
|
+
handler=dispatch,
|
|
22
|
+
)
|
clak/runtime/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""ask 协议:请求、选项、答案与解析。"""
|
|
2
|
+
|
|
3
|
+
from clak.runtime.ask.answer import AskAnswer, parse_ask_answer
|
|
4
|
+
from clak.runtime.ask.request import AskChoice, AskMode, AskRequest
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AskAnswer",
|
|
8
|
+
"AskChoice",
|
|
9
|
+
"AskMode",
|
|
10
|
+
"AskRequest",
|
|
11
|
+
"parse_ask_answer",
|
|
12
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from clak.runtime.ask.request import AskMode, AskRequest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AskAnswer:
|
|
10
|
+
"""脚本侧结构化答案;线协议仅在边界序列化。"""
|
|
11
|
+
|
|
12
|
+
field_id: str
|
|
13
|
+
text: str
|
|
14
|
+
tokens: frozenset[str]
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def primary(self) -> str:
|
|
18
|
+
return self.text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def parse_ask_answer(request: AskRequest, wire: str) -> AskAnswer:
|
|
22
|
+
raw = wire.strip() or request.default
|
|
23
|
+
if request.mode == AskMode.MULTI:
|
|
24
|
+
parts = {p.strip() for p in raw.split(",") if p.strip()}
|
|
25
|
+
return AskAnswer(
|
|
26
|
+
field_id=request.field_id,
|
|
27
|
+
text=",".join(sorted(parts)),
|
|
28
|
+
tokens=frozenset(parts),
|
|
29
|
+
)
|
|
30
|
+
return AskAnswer(
|
|
31
|
+
field_id=request.field_id,
|
|
32
|
+
text=raw,
|
|
33
|
+
tokens=frozenset({raw}),
|
|
34
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from enum import Enum, auto
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AskMode(Enum):
|
|
8
|
+
SINGLE = auto()
|
|
9
|
+
MULTI = auto()
|
|
10
|
+
FILTER = auto()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AskChoice:
|
|
15
|
+
label: str
|
|
16
|
+
value: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AskRequest:
|
|
21
|
+
field_id: str
|
|
22
|
+
prompt: str
|
|
23
|
+
default: str
|
|
24
|
+
choices: tuple[AskChoice, ...] = ()
|
|
25
|
+
mode: AskMode = AskMode.SINGLE
|
clak/runtime/headless.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from clak.commands._registry import run_script
|
|
7
|
+
from clak.runtime.params import MissingScriptInputError, ScriptInputs
|
|
8
|
+
from clak.runtime.session import ScriptSession
|
|
9
|
+
from clak.runtime.slash import (
|
|
10
|
+
SlashAction,
|
|
11
|
+
SlashEngine,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HeadlessError(Exception):
|
|
16
|
+
def __init__(self, message: str, code: int = 2) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.code = code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class HeadlessRun:
|
|
23
|
+
text: str
|
|
24
|
+
exit_code: int = 0
|
|
25
|
+
stream: str = "stdout"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_line(
|
|
29
|
+
line: str,
|
|
30
|
+
*,
|
|
31
|
+
script_inputs: ScriptInputs | None = None,
|
|
32
|
+
) -> HeadlessRun:
|
|
33
|
+
stripped = line.strip()
|
|
34
|
+
if not stripped:
|
|
35
|
+
raise HeadlessError("输入不能为空", code=2)
|
|
36
|
+
if stripped.startswith("/"):
|
|
37
|
+
return _run_slash(stripped, script_inputs)
|
|
38
|
+
return _run_prompt(stripped)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _run_slash(
|
|
42
|
+
line: str,
|
|
43
|
+
script_inputs: ScriptInputs | None,
|
|
44
|
+
) -> HeadlessRun:
|
|
45
|
+
engine = SlashEngine()
|
|
46
|
+
result = engine.dispatch(line)
|
|
47
|
+
|
|
48
|
+
match result.action:
|
|
49
|
+
case SlashAction.QUIT:
|
|
50
|
+
return HeadlessRun("", 0)
|
|
51
|
+
case SlashAction.UNKNOWN:
|
|
52
|
+
msg = result.message or f"未知命令 {result.command}"
|
|
53
|
+
return HeadlessRun(msg, 2, stream="stderr")
|
|
54
|
+
case SlashAction.RUN_SCRIPT:
|
|
55
|
+
if not result.command:
|
|
56
|
+
return HeadlessRun("缺少脚本名", 2, stream="stderr")
|
|
57
|
+
return _run_script_headless(result.command, script_inputs)
|
|
58
|
+
case SlashAction.SHOW_STATUS | SlashAction.NEW_SESSION | SlashAction.NONE:
|
|
59
|
+
if result.message:
|
|
60
|
+
return HeadlessRun(result.message, 0)
|
|
61
|
+
if result.status:
|
|
62
|
+
return HeadlessRun(result.status, 0)
|
|
63
|
+
return HeadlessRun("", 0)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_prompt(prompt: str) -> HeadlessRun:
|
|
67
|
+
body = "请使用 slash 命令,例如 /pack-game-res"
|
|
68
|
+
return HeadlessRun(body, 0)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _run_script_headless(
|
|
72
|
+
script_id: str,
|
|
73
|
+
script_inputs: ScriptInputs | None,
|
|
74
|
+
) -> HeadlessRun:
|
|
75
|
+
lines: list[str] = []
|
|
76
|
+
inputs = script_inputs or ScriptInputs(allow_defaults=True)
|
|
77
|
+
session = ScriptSession(lines.append, inputs=inputs, fail_on_missing=not inputs.allow_defaults)
|
|
78
|
+
try:
|
|
79
|
+
run_script(session, script_id)
|
|
80
|
+
except ValueError as error:
|
|
81
|
+
return HeadlessRun(str(error), 2, stream="stderr")
|
|
82
|
+
except MissingScriptInputError as error:
|
|
83
|
+
return HeadlessRun(str(error), 2, stream="stderr")
|
|
84
|
+
if session.is_awaiting_input:
|
|
85
|
+
req = session.pending_request
|
|
86
|
+
field = req.field_id if req else "?"
|
|
87
|
+
return HeadlessRun(
|
|
88
|
+
f"脚本等待输入 [{field}],请用 --set {field}=... 补齐",
|
|
89
|
+
2,
|
|
90
|
+
stream="stderr",
|
|
91
|
+
)
|
|
92
|
+
return HeadlessRun("\n".join(lines), 0)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def emit(run: HeadlessRun) -> int:
|
|
96
|
+
if run.text:
|
|
97
|
+
if run.stream == "stderr":
|
|
98
|
+
print(run.text, file=sys.stderr)
|
|
99
|
+
else:
|
|
100
|
+
print(run.text)
|
|
101
|
+
return run.exit_code
|
clak/runtime/params.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MissingScriptInputError(ValueError):
|
|
7
|
+
def __init__(self, field_id: str, prompt: str) -> None:
|
|
8
|
+
super().__init__(f"缺少脚本输入 [{field_id}]: {prompt}")
|
|
9
|
+
self.field_id = field_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ScriptInputs:
|
|
14
|
+
"""Exec 无交互:只从此读取参数;未补齐则挂起或报错。"""
|
|
15
|
+
|
|
16
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
allow_defaults: bool = False
|
|
18
|
+
|
|
19
|
+
def get(self, field_id: str) -> str | None:
|
|
20
|
+
return self.values.get(field_id)
|
|
21
|
+
|
|
22
|
+
def set(self, field_id: str, wire: str) -> None:
|
|
23
|
+
self.values[field_id] = wire.strip()
|
|
24
|
+
|
|
25
|
+
def resolve_wire(self, field_id: str, default: str) -> str | None:
|
|
26
|
+
wire = self.get(field_id)
|
|
27
|
+
if wire is not None:
|
|
28
|
+
return wire
|
|
29
|
+
if self.allow_defaults and default:
|
|
30
|
+
return default
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_pairs(cls, pairs: dict[str, str], *, allow_defaults: bool = False) -> ScriptInputs:
|
|
35
|
+
return cls(values=dict(pairs), allow_defaults=allow_defaults)
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def parse_set_flags(cls, flags: list[str], *, allow_defaults: bool = False) -> ScriptInputs:
|
|
39
|
+
values: dict[str, str] = {}
|
|
40
|
+
for item in flags:
|
|
41
|
+
if "=" not in item:
|
|
42
|
+
raise ValueError(f"无效 --set 格式(需 field=value): {item}")
|
|
43
|
+
key, value = item.split("=", 1)
|
|
44
|
+
key = key.strip()
|
|
45
|
+
if not key:
|
|
46
|
+
raise ValueError(f"无效 --set 格式: {item}")
|
|
47
|
+
values[key] = value.strip()
|
|
48
|
+
return cls(values=values, allow_defaults=allow_defaults)
|
clak/runtime/session.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from clak.runtime.step_list import StepList
|
|
6
|
+
from clak.runtime.ask import AskAnswer, AskRequest, parse_ask_answer
|
|
7
|
+
from clak.runtime.params import MissingScriptInputError, ScriptInputs
|
|
8
|
+
|
|
9
|
+
LogFn = Callable[[str], None]
|
|
10
|
+
AnswerFn = Callable[[AskAnswer], None]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScriptSession:
|
|
14
|
+
"""Exec 无交互;缺参时挂起 gate,由 provide_input 补齐后继续。"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
log: LogFn,
|
|
19
|
+
*,
|
|
20
|
+
inputs: ScriptInputs | None = None,
|
|
21
|
+
fail_on_missing: bool = False,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.log = log
|
|
24
|
+
self.inputs = inputs or ScriptInputs()
|
|
25
|
+
self.fail_on_missing = fail_on_missing
|
|
26
|
+
self.step_list = StepList()
|
|
27
|
+
self._cancelled = False
|
|
28
|
+
self._running = False
|
|
29
|
+
self._on_finished: Callable[[], None] | None = None
|
|
30
|
+
self._pending_request: AskRequest | None = None
|
|
31
|
+
self._pending_answer: AnswerFn | None = None
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def pending_request(self) -> AskRequest | None:
|
|
35
|
+
return self._pending_request
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_awaiting_input(self) -> bool:
|
|
39
|
+
return self._pending_request is not None and not self._cancelled
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_running(self) -> bool:
|
|
43
|
+
return self._running and not self._cancelled
|
|
44
|
+
|
|
45
|
+
def on_finished(self, callback: Callable[[], None] | None) -> None:
|
|
46
|
+
self._on_finished = callback
|
|
47
|
+
|
|
48
|
+
def bind_step_end(self) -> None:
|
|
49
|
+
self.step_list.set_end_callback(self._notify_finished)
|
|
50
|
+
|
|
51
|
+
def _notify_finished(self) -> None:
|
|
52
|
+
if self._cancelled:
|
|
53
|
+
return
|
|
54
|
+
self._running = False
|
|
55
|
+
self._clear_pending()
|
|
56
|
+
if self._on_finished:
|
|
57
|
+
self._on_finished()
|
|
58
|
+
|
|
59
|
+
def cancel(self) -> None:
|
|
60
|
+
self._cancelled = True
|
|
61
|
+
self._running = False
|
|
62
|
+
self._clear_pending()
|
|
63
|
+
self.step_list.stop()
|
|
64
|
+
self.step_list.clear()
|
|
65
|
+
|
|
66
|
+
def ask_step(self, request: AskRequest, on_answer: AnswerFn) -> int:
|
|
67
|
+
if self._cancelled:
|
|
68
|
+
return 0
|
|
69
|
+
wire = self.inputs.resolve_wire(request.field_id, request.default)
|
|
70
|
+
if wire is not None:
|
|
71
|
+
on_answer(parse_ask_answer(request, wire))
|
|
72
|
+
return 0
|
|
73
|
+
if self.fail_on_missing:
|
|
74
|
+
raise MissingScriptInputError(request.field_id, request.prompt)
|
|
75
|
+
self._pending_request = request
|
|
76
|
+
self._pending_answer = on_answer
|
|
77
|
+
return 1
|
|
78
|
+
|
|
79
|
+
def provide_input(self, field_id: str, wire: str) -> bool:
|
|
80
|
+
if self._cancelled:
|
|
81
|
+
return False
|
|
82
|
+
self.inputs.set(field_id, wire)
|
|
83
|
+
if not self._pending_request or self._pending_request.field_id != field_id:
|
|
84
|
+
return False
|
|
85
|
+
request = self._pending_request
|
|
86
|
+
answer_cb = self._pending_answer
|
|
87
|
+
self._clear_pending()
|
|
88
|
+
if not answer_cb:
|
|
89
|
+
return False
|
|
90
|
+
answer_cb(parse_ask_answer(request, wire))
|
|
91
|
+
self.step_list.resume()
|
|
92
|
+
self._sync_running()
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def run(self) -> None:
|
|
96
|
+
if self._cancelled:
|
|
97
|
+
return
|
|
98
|
+
self._running = True
|
|
99
|
+
self.step_list.execute()
|
|
100
|
+
self._sync_running()
|
|
101
|
+
|
|
102
|
+
def _clear_pending(self) -> None:
|
|
103
|
+
self._pending_request = None
|
|
104
|
+
self._pending_answer = None
|
|
105
|
+
|
|
106
|
+
def _sync_running(self) -> None:
|
|
107
|
+
if self._cancelled:
|
|
108
|
+
self._running = False
|
|
109
|
+
return
|
|
110
|
+
if self.is_awaiting_input or self.step_list.is_waiting:
|
|
111
|
+
return
|
|
112
|
+
if self.step_list.is_finished:
|
|
113
|
+
self._running = False
|
clak/runtime/slash.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Slash 命令:实现见 commands/*,引擎在此。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from clak.commands._registry import COMMANDS, command_for_token
|
|
6
|
+
from clak.commands.types import (
|
|
7
|
+
ScriptEntry,
|
|
8
|
+
SlashAction,
|
|
9
|
+
SlashDispatchResult,
|
|
10
|
+
SlashEntry,
|
|
11
|
+
SlashMatch,
|
|
12
|
+
SlashTokenKind,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SlashAction",
|
|
17
|
+
"SlashDispatchResult",
|
|
18
|
+
"SlashEngine",
|
|
19
|
+
"SlashEntry",
|
|
20
|
+
"SlashMatch",
|
|
21
|
+
"SlashTokenKind",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlashEngine:
|
|
26
|
+
def filter(self, input_text: str) -> list[SlashMatch]:
|
|
27
|
+
token = input_text.split()[0] if input_text.split() else input_text
|
|
28
|
+
if not token.startswith("/"):
|
|
29
|
+
return []
|
|
30
|
+
needle = token.lower()
|
|
31
|
+
matches: list[SlashMatch] = []
|
|
32
|
+
for cmd in COMMANDS:
|
|
33
|
+
names = cmd.all_names()
|
|
34
|
+
if any(name.startswith(needle) or needle.startswith(name) for name in names):
|
|
35
|
+
matches.append(SlashMatch(cmd.name, cmd.description))
|
|
36
|
+
if not matches and input_text.startswith("/"):
|
|
37
|
+
for cmd in COMMANDS:
|
|
38
|
+
matches.append(SlashMatch(cmd.name, cmd.description))
|
|
39
|
+
return matches
|
|
40
|
+
|
|
41
|
+
def token_kind(self, token: str) -> SlashTokenKind:
|
|
42
|
+
if not token.startswith("/"):
|
|
43
|
+
return SlashTokenKind.NOT_SLASH
|
|
44
|
+
if command_for_token(token):
|
|
45
|
+
return SlashTokenKind.AVAILABLE
|
|
46
|
+
if token == "/":
|
|
47
|
+
return SlashTokenKind.INCOMPLETE
|
|
48
|
+
return SlashTokenKind.UNKNOWN
|
|
49
|
+
|
|
50
|
+
def tab_completion(self, input_text: str, selected: int) -> str | None:
|
|
51
|
+
matches = self.filter(input_text)
|
|
52
|
+
if not matches or selected >= len(matches):
|
|
53
|
+
return None
|
|
54
|
+
return matches[selected].name
|
|
55
|
+
|
|
56
|
+
def dispatch(self, line: str) -> SlashDispatchResult:
|
|
57
|
+
token = line.split()[0].lower() if line.split() else line.lower()
|
|
58
|
+
cmd = command_for_token(token)
|
|
59
|
+
if not cmd:
|
|
60
|
+
return SlashDispatchResult(
|
|
61
|
+
action=SlashAction.UNKNOWN,
|
|
62
|
+
command=token,
|
|
63
|
+
message=f"未知命令 {token}",
|
|
64
|
+
status="unknown slash",
|
|
65
|
+
)
|
|
66
|
+
if isinstance(cmd, ScriptEntry):
|
|
67
|
+
return SlashDispatchResult(
|
|
68
|
+
action=SlashAction.RUN_SCRIPT,
|
|
69
|
+
command=cmd.script_id,
|
|
70
|
+
status=cmd.script_id,
|
|
71
|
+
)
|
|
72
|
+
return cmd.handler()
|