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 ADDED
@@ -0,0 +1,3 @@
1
+ """clak:固定流程脚本壳。"""
2
+
3
+ __version__ = "0.0.5"
clak/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from clak.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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
+ )
@@ -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
@@ -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
+ )
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
@@ -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)
@@ -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()