inkcre-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.
inkcre_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Independent InKCre REST consumer; no Core runtime imports."""
inkcre_cli/command.py ADDED
@@ -0,0 +1,187 @@
1
+ """Small shared Click options and one invocation's resources."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Literal
6
+ from urllib.parse import quote
7
+
8
+ import click
9
+ from pydantic import BaseModel, TypeAdapter
10
+
11
+ from . import connection
12
+ from .errors import CommandError
13
+ from .http import CoreRESTClient
14
+ from .output import Output
15
+
16
+
17
+ class Command(click.Command):
18
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
19
+ original = args.copy()
20
+ try:
21
+ return super().parse_args(ctx, args)
22
+ except click.UsageError:
23
+ # Click rejects unknown options before eager callbacks run. Its native
24
+ # tolerant parse can still find presentation options without scanning
25
+ # raw argv (which would mistake an --input-json value for an option).
26
+ with self.make_context(
27
+ ctx.info_name,
28
+ original,
29
+ parent=ctx.parent,
30
+ obj=ctx.obj,
31
+ resilient_parsing=True,
32
+ ignore_unknown_options=True,
33
+ ):
34
+ pass
35
+ raise
36
+
37
+
38
+ class Group(Command, click.Group):
39
+ command_class = Command
40
+ group_class = type
41
+
42
+
43
+ class Entity(BaseModel):
44
+ type: Literal["block", "relation"]
45
+ id: int
46
+
47
+
48
+ def entity(value: str, *, block_only: bool = False) -> Entity:
49
+ kind, separator, identity = value.partition(":")
50
+ if not separator:
51
+ raise click.BadParameter("使用 block:<id> 或 relation:<id>")
52
+ result = Entity.model_validate({"type": kind, "id": identity})
53
+ if block_only and result.type != "block":
54
+ raise click.BadParameter("此操作需要 block:<id>")
55
+ return result
56
+
57
+
58
+ def segment(value: str | int) -> str:
59
+ return quote(str(value), safe="")
60
+
61
+
62
+ def duration(value: str) -> float:
63
+ suffix = value[-1:] if value[-1:].isalpha() else ""
64
+ scale = {"": 1, "s": 1, "m": 60, "h": 3600}
65
+ try:
66
+ result = float(value[:-1] if suffix else value) * scale[suffix]
67
+ if not 0 < result < float("inf"):
68
+ raise ValueError
69
+ return result
70
+ except (ValueError, KeyError) as error:
71
+ raise click.BadParameter("使用正数秒,或 30s / 2m / 1h") from error
72
+
73
+
74
+ class Invocation:
75
+ def __init__(self):
76
+ self.connection_name: str | None = None
77
+ self.connections_file: Path | None = None
78
+ self.output = Output()
79
+ self._client: CoreRESTClient | None = None
80
+
81
+ @property
82
+ def local_file(self) -> Path:
83
+ return connection.file_path(self.connections_file)
84
+
85
+ @property
86
+ def client(self) -> CoreRESTClient:
87
+ if self._client is None:
88
+ _, selected = connection.select(connection.load(self.local_file), self.connection_name)
89
+ self._client = CoreRESTClient(selected)
90
+ return self._client
91
+
92
+ def close(self) -> None:
93
+ if self._client:
94
+ self._client.close()
95
+
96
+ def send(
97
+ self,
98
+ method: str,
99
+ path: str,
100
+ *,
101
+ params: dict | None = None,
102
+ body: Any = None,
103
+ strip: tuple[str, ...] = (),
104
+ selectors: dict[str, str] | None = None,
105
+ ) -> Any:
106
+ try:
107
+ return self.client.request(method, path, params=params, body=body)
108
+ except CommandError as error:
109
+ if error.status == 422 and isinstance(error.detail, list):
110
+ detail = []
111
+ for item in error.detail:
112
+ loc = list(item.get("loc", ()))
113
+ if loc[:1] == ["body"]:
114
+ loc = loc[1:]
115
+ if loc[: len(strip)] == list(strip):
116
+ loc = loc[len(strip) :]
117
+ if loc and selectors and loc[0] in selectors:
118
+ loc[0] = selectors[loc[0]]
119
+ detail.append({**item, "loc": loc})
120
+ raise CommandError(detail, status=error.status) from error
121
+ raise
122
+
123
+ def show(self, value: Any, *, no_content: bool = False, partial_failure: bool = False) -> None:
124
+ self.output.emit(value, no_content=no_content)
125
+ if partial_failure:
126
+ raise CommandError("结果中存在逐项错误;成功项已保留")
127
+
128
+
129
+ def _option(ctx: click.Context, parameter: click.Parameter, value: Any) -> Any:
130
+ invocation = ctx.ensure_object(Invocation)
131
+ if value is not None:
132
+ if parameter.name == "json_mode":
133
+ invocation.output.json_mode = value
134
+ elif parameter.name == "output_dir":
135
+ invocation.output.directory = value
136
+ else:
137
+ setattr(invocation, parameter.name, value)
138
+ return value
139
+
140
+
141
+ def common(function):
142
+ options: list[tuple[tuple[str, ...], dict[str, Any]]] = [
143
+ (("--connection", "connection_name"), {"help": "本次使用的本机连接名"}),
144
+ (("--connections-file",), {"type": click.Path(path_type=Path), "help": "替换连接配置文件"}),
145
+ (
146
+ ("--output-dir",),
147
+ {"type": click.Path(path_type=Path), "help": "保存完整结果,返回入口文件"},
148
+ ),
149
+ (
150
+ ("--json", "json_mode"),
151
+ {"is_flag": True, "default": None, "is_eager": True, "help": "完整紧凑 JSON"},
152
+ ),
153
+ ]
154
+ for args, kwargs in options:
155
+ function = click.option(*args, callback=_option, expose_value=False, **kwargs)(function)
156
+ return function
157
+
158
+
159
+ def input_options(function):
160
+ function = click.option("--schema", is_flag=True, help="只查询输入 JSON Schema,不执行操作")(
161
+ function
162
+ )
163
+ function = click.option("--input-json", help="直接提供 JSON 对象")(function)
164
+ return click.option(
165
+ "--input", "input_file", type=click.Path(path_type=Path), help="JSON 文件,- 表示 stdin"
166
+ )(function)
167
+
168
+
169
+ def paging(function):
170
+ function = click.option("--cursor", help="前一页返回的 next_cursor;保留相同过滤条件")(function)
171
+ return click.option(
172
+ "--limit", type=click.IntRange(min=1), help="本次最多返回多少项;不自动翻页"
173
+ )(function)
174
+
175
+
176
+ def load_input(input_file: Path | None, input_json: str | None) -> dict[str, Any]:
177
+ if input_file is not None and input_json is not None:
178
+ raise click.UsageError("--input 与 --input-json 不能同时使用")
179
+ if input_file is not None:
180
+ text = (
181
+ sys.stdin.read() if str(input_file) == "-" else input_file.read_text(encoding="utf-8")
182
+ )
183
+ elif input_json is not None:
184
+ text = input_json
185
+ else:
186
+ return {}
187
+ return TypeAdapter(dict[str, Any]).validate_json(text)
@@ -0,0 +1 @@
1
+ """Ordinary command groups, not an Extension command registry."""
@@ -0,0 +1,121 @@
1
+ """Agent definitions and read-only AI/profile discovery."""
2
+
3
+ import click
4
+
5
+ from ..command import Group, Invocation, common, input_options, load_input, paging, segment
6
+ from ..schema import request_schema
7
+
8
+
9
+ @click.group(cls=Group)
10
+ def agent():
11
+ """管理 Agent definition;不启动对话或本机 Agent runtime。"""
12
+
13
+
14
+ @agent.command("list")
15
+ @paging
16
+ @common
17
+ @click.pass_obj
18
+ def list_agents(inv: Invocation, limit, cursor):
19
+ """列出 Agent definition。"""
20
+ inv.show(inv.send("GET", "/agents", params={"limit": limit, "cursor": cursor}))
21
+
22
+
23
+ @agent.command()
24
+ @click.argument("agent_id", type=int)
25
+ @common
26
+ @click.pass_obj
27
+ def get(inv: Invocation, agent_id):
28
+ """读取一个 Agent definition。"""
29
+ inv.show(inv.send("GET", f"/agents/{agent_id}"))
30
+
31
+
32
+ @agent.command()
33
+ @input_options
34
+ @common
35
+ @click.pass_obj
36
+ def create(inv: Invocation, schema, input_file, input_json):
37
+ """创建 Agent definition;模型用 ai models、工具用 agent tools 发现。"""
38
+ if schema:
39
+ return inv.show(request_schema(inv.client, "/agents", "POST"))
40
+ inv.show(inv.send("POST", "/agents", body=load_input(input_file, input_json)))
41
+
42
+
43
+ @agent.command()
44
+ @click.argument("agent_id", type=int)
45
+ @input_options
46
+ @common
47
+ @click.pass_obj
48
+ def update(inv: Invocation, agent_id, schema, input_file, input_json):
49
+ """修改提交的 Agent definition 字段。"""
50
+ if schema:
51
+ return inv.show(request_schema(inv.client, "/agents/{agent_id}", "PATCH"))
52
+ inv.show(inv.send("PATCH", f"/agents/{agent_id}", body=load_input(input_file, input_json)))
53
+
54
+
55
+ @agent.command()
56
+ @click.argument("agent_id", type=int)
57
+ @common
58
+ @click.pass_obj
59
+ def delete(inv: Invocation, agent_id):
60
+ """删除 Agent definition。"""
61
+ inv.show(inv.send("DELETE", f"/agents/{agent_id}"), no_content=True)
62
+
63
+
64
+ @agent.command()
65
+ @click.argument("tool_id", required=False)
66
+ @paging
67
+ @common
68
+ @click.pass_obj
69
+ def tools(inv: Invocation, tool_id, limit, cursor):
70
+ """列出本机已注册 Agent Tool;给定 ID 时取得完整合同,不执行工具。"""
71
+ inv.show(
72
+ inv.send(
73
+ "GET",
74
+ "/agent-tools" + ("/" + segment(tool_id) if tool_id else ""),
75
+ params={"limit": limit, "cursor": cursor},
76
+ )
77
+ )
78
+
79
+
80
+ @click.group(cls=Group)
81
+ def ai():
82
+ """发现 AI 模型,不混入 Agent definition 的管理范围。"""
83
+
84
+
85
+ @ai.command()
86
+ @click.argument("model_id", type=int, required=False)
87
+ @paging
88
+ @common
89
+ @click.pass_obj
90
+ def models(inv: Invocation, model_id, limit, cursor):
91
+ """列出 AIModel,或读取指定 ID;不返回 Provider 的 secret config。"""
92
+ inv.show(
93
+ inv.send(
94
+ "GET",
95
+ "/ai/models" + (f"/{model_id}" if model_id is not None else ""),
96
+ params={"limit": limit, "cursor": cursor},
97
+ )
98
+ )
99
+
100
+
101
+ @click.group("embedding-profile", cls=Group)
102
+ def embedding_profile():
103
+ """只读发现语义检索可选的 embedding profile。"""
104
+
105
+
106
+ @embedding_profile.command("list")
107
+ @paging
108
+ @common
109
+ @click.pass_obj
110
+ def list_profiles(inv: Invocation, limit, cursor):
111
+ """列出 EmbeddingProfile。"""
112
+ inv.show(inv.send("GET", "/embedding-profiles", params={"limit": limit, "cursor": cursor}))
113
+
114
+
115
+ @embedding_profile.command("get")
116
+ @click.argument("profile_id", type=int)
117
+ @common
118
+ @click.pass_obj
119
+ def get_profile(inv: Invocation, profile_id):
120
+ """读取 EmbeddingProfile。"""
121
+ inv.show(inv.send("GET", f"/embedding-profiles/{profile_id}"))
@@ -0,0 +1,95 @@
1
+ """Deployment config values and their runtime-owned input schemas."""
2
+
3
+ import click
4
+
5
+ from ..command import Group, Invocation, common, input_options, load_input, paging, segment
6
+ from ..schema import partial
7
+
8
+
9
+ @click.group(cls=Group)
10
+ def config():
11
+ """远端 deployment 配置,与本机 connection 文件分开。"""
12
+
13
+
14
+ @config.command("list")
15
+ @paging
16
+ @common
17
+ @click.pass_obj
18
+ def list_configs(inv: Invocation, limit, cursor):
19
+ """列出保存的 deployment config,不要求 schema 已加载。"""
20
+ inv.show(inv.send("GET", "/configs", params={"limit": limit, "cursor": cursor}))
21
+
22
+
23
+ @config.command()
24
+ @click.argument("key")
25
+ @common
26
+ @click.pass_obj
27
+ def get(inv: Invocation, key):
28
+ """读取配置的实际 schema/value。"""
29
+ inv.show(inv.send("GET", "/configs/" + segment(key)))
30
+
31
+
32
+ @config.command()
33
+ @click.argument("schema_id", required=False)
34
+ @paging
35
+ @common
36
+ @click.pass_obj
37
+ def schemas(inv: Invocation, schema_id, limit, cursor):
38
+ """发现接入 Peer 已加载的 schema;给出 ID 时读取输入合同。"""
39
+ inv.show(
40
+ inv.send(
41
+ "GET",
42
+ "/config-schemas" + ("/" + segment(schema_id) if schema_id else ""),
43
+ params={"limit": limit, "cursor": cursor},
44
+ )
45
+ )
46
+
47
+
48
+ @config.command()
49
+ @click.argument("key")
50
+ @click.option("--schema-id", required=True)
51
+ @input_options
52
+ @common
53
+ @click.pass_obj
54
+ def replace(inv: Invocation, key, schema_id, schema, input_file, input_json):
55
+ """创建或完整替换;JSON 是 value 本身,不再包装 value。"""
56
+ if schema:
57
+ return inv.show(inv.send("GET", "/config-schemas/" + segment(schema_id))["input_schema"])
58
+ inv.show(
59
+ inv.send(
60
+ "PUT",
61
+ "/configs/" + segment(key),
62
+ body={
63
+ "schema": schema_id,
64
+ "value": load_input(input_file, input_json),
65
+ },
66
+ strip=("value",),
67
+ selectors={"schema": "--schema-id"},
68
+ )
69
+ )
70
+
71
+
72
+ @config.command()
73
+ @click.argument("key")
74
+ @input_options
75
+ @common
76
+ @click.pass_obj
77
+ def update(inv: Invocation, key, schema, input_file, input_json):
78
+ """更新 value 的提交字段;嵌套值按 owner 合同处理。"""
79
+ if schema:
80
+ existing = inv.send("GET", "/configs/" + segment(key))
81
+ return inv.show(
82
+ partial(
83
+ inv.send("GET", "/config-schemas/" + segment(existing["schema"]))["input_schema"]
84
+ )
85
+ )
86
+ inv.show(inv.send("PATCH", "/configs/" + segment(key), body=load_input(input_file, input_json)))
87
+
88
+
89
+ @config.command()
90
+ @click.argument("key")
91
+ @common
92
+ @click.pass_obj
93
+ def delete(inv: Invocation, key):
94
+ """删除保存的配置值。"""
95
+ inv.show(inv.send("DELETE", "/configs/" + segment(key)), no_content=True)
@@ -0,0 +1,109 @@
1
+ """Offline connection editing and explicit connectivity observation."""
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+ import httpx
7
+
8
+ from .. import connection as store
9
+ from ..command import Group, Invocation, common, input_options, load_input
10
+ from ..errors import CommandError
11
+
12
+
13
+ @click.group(cls=Group)
14
+ def connection():
15
+ """本机命名连接,不是远端 deployment config 或 Peer 注册。"""
16
+
17
+
18
+ @connection.command("list")
19
+ @common
20
+ @click.pass_obj
21
+ def list_connections(inv: Invocation):
22
+ """离线列出名称、地址与保存的默认名。"""
23
+ data = store.load(inv.local_file)
24
+ inv.show(
25
+ {
26
+ "default": data.default,
27
+ "connections": {
28
+ name: {"base_url": str(value.base_url)} for name, value in data.connections.items()
29
+ },
30
+ }
31
+ )
32
+
33
+
34
+ @connection.command()
35
+ @click.argument("name")
36
+ @common
37
+ @click.pass_obj
38
+ def get(inv: Invocation, name):
39
+ """离线读取指定连接,包括保存的 JWT secret。"""
40
+ _, value = store.select(store.load(inv.local_file), name)
41
+ inv.show(value.model_dump(mode="json"))
42
+
43
+
44
+ @connection.command("set")
45
+ @click.argument("name")
46
+ @input_options
47
+ @common
48
+ @click.pass_obj
49
+ def set_connection(inv: Invocation, name, schema, input_file, input_json):
50
+ """创建或完整替换命名连接;输入 base_url 与 jwt_secret。"""
51
+ if schema:
52
+ return inv.show(store.Connection.model_json_schema())
53
+ value = store.Connection.model_validate(load_input(input_file, input_json))
54
+ data = store.load(inv.local_file)
55
+ data.connections[name] = value
56
+ if data.default is None:
57
+ data.default = name
58
+ store.save(inv.local_file, data)
59
+ inv.show({"name": name, "base_url": str(value.base_url), "default": data.default})
60
+
61
+
62
+ @connection.command()
63
+ @click.argument("name")
64
+ @common
65
+ @click.pass_obj
66
+ def use(inv: Invocation, name):
67
+ """修改本机默认连接。"""
68
+ data = store.load(inv.local_file)
69
+ store.select(data, name)
70
+ data.default = name
71
+ store.save(inv.local_file, data)
72
+ inv.show({"default": name})
73
+
74
+
75
+ @connection.command()
76
+ @click.argument("name")
77
+ @common
78
+ @click.pass_obj
79
+ def delete(inv: Invocation, name):
80
+ """删除本机连接;不操作远端部署。"""
81
+ data = store.load(inv.local_file)
82
+ store.select(data, name)
83
+ del data.connections[name]
84
+ if data.default == name:
85
+ data.default = None
86
+ store.save(inv.local_file, data)
87
+ inv.show(None, no_content=True)
88
+
89
+
90
+ @connection.command()
91
+ @common
92
+ @click.pass_obj
93
+ def check(inv: Invocation):
94
+ """分别检查公共 readyz 与受保护读取,不把就绪误当成认证成功。"""
95
+ result: dict[str, Any] = {"endpoint": inv.client.base_url}
96
+ failed = False
97
+ for key, path, authenticated in (
98
+ ("readyz", "/readyz", False),
99
+ ("authenticated_read", "/peers/self", True),
100
+ ):
101
+ try:
102
+ result[key] = inv.client.request("GET", path, authenticated=authenticated)
103
+ except CommandError as error:
104
+ result[key] = {"error": error.as_dict()}
105
+ failed = True
106
+ except httpx.RequestError as error:
107
+ result[key] = {"error": {"detail": str(error)}}
108
+ failed = True
109
+ inv.show(result, partial_failure=failed)
@@ -0,0 +1,125 @@
1
+ """Extension Host REST management; no CLI-specific Extension adapters."""
2
+
3
+ import click
4
+
5
+ from ..command import Group, Invocation, common, input_options, load_input, paging, segment
6
+ from ..errors import CommandError
7
+ from ..schema import partial
8
+
9
+
10
+ def route(coordinate: str) -> str:
11
+ namespace, separator, name = coordinate.partition("/")
12
+ if not separator or not namespace or not name or "/" in name:
13
+ raise click.BadParameter("Extension 使用 namespace/name coordinate")
14
+ return "/extensions/" + segment(namespace) + "/" + segment(name)
15
+
16
+
17
+ @click.group(cls=Group)
18
+ def extension():
19
+ """安装、配置和启停 Extension;发布仍由 Extension 的交付流程负责。"""
20
+
21
+
22
+ @extension.command("list")
23
+ @paging
24
+ @common
25
+ @click.pass_obj
26
+ def list_extensions(inv: Invocation, limit, cursor):
27
+ """列出 Extension 的安装、启用意图与运行状态。"""
28
+ inv.show(inv.send("GET", "/extensions", params={"limit": limit, "cursor": cursor}))
29
+
30
+
31
+ @extension.command()
32
+ @click.argument("coordinate")
33
+ @common
34
+ @click.pass_obj
35
+ def get(inv: Invocation, coordinate):
36
+ """读取一个已安装 Extension。"""
37
+ inv.show(inv.send("GET", route(coordinate)))
38
+
39
+
40
+ @extension.command()
41
+ @click.argument("coordinate")
42
+ @click.option("--version", required=True, help="准确的已发布版本,不解析 latest")
43
+ @common
44
+ @click.pass_obj
45
+ def install(inv: Invocation, coordinate, version):
46
+ """安装准确版本;不隐式启用。"""
47
+ inv.show(inv.send("POST", route(coordinate), params={"version": version}))
48
+
49
+
50
+ @extension.command()
51
+ @click.argument("coordinate")
52
+ @common
53
+ @click.pass_obj
54
+ def uninstall(inv: Invocation, coordinate):
55
+ """卸载 Extension。"""
56
+ inv.show(inv.send("DELETE", route(coordinate)), no_content=True)
57
+
58
+
59
+ @extension.command()
60
+ @click.argument("coordinate")
61
+ @click.option("--peer", help="启用意图的目标 Peer;省略时为接入 Peer")
62
+ @common
63
+ @click.pass_obj
64
+ def enable(inv: Invocation, coordinate, peer):
65
+ """在指定或当前 Peer 启用 Extension。"""
66
+ inv.show(inv.send("POST", route(coordinate) + "/enable", params={"route_to_peer": peer}))
67
+
68
+
69
+ @extension.command()
70
+ @click.argument("coordinate")
71
+ @click.option("--peer", help="禁用意图的目标 Peer;省略时为接入 Peer")
72
+ @common
73
+ @click.pass_obj
74
+ def disable(inv: Invocation, coordinate, peer):
75
+ """在指定或当前 Peer 禁用 Extension。"""
76
+ inv.show(inv.send("POST", route(coordinate) + "/disable", params={"route_to_peer": peer}))
77
+
78
+
79
+ @extension.group()
80
+ def config():
81
+ """读写 Extension 的持久配置,不要求先启用。"""
82
+
83
+
84
+ @config.command("get")
85
+ @click.argument("coordinate")
86
+ @common
87
+ @click.pass_obj
88
+ def get_config(inv: Invocation, coordinate):
89
+ """从安装记录读取 config;不调用业务 schema 验证。"""
90
+ inv.show(inv.send("GET", route(coordinate))["config"])
91
+
92
+
93
+ def config_schema(inv: Invocation, coordinate: str) -> dict:
94
+ contract = inv.send("GET", route(coordinate))["config_schema"]
95
+ if contract is None:
96
+ raise CommandError("该 Extension 尚未发布配置 schema;可在启用后重新发现")
97
+ return contract
98
+
99
+
100
+ @config.command("replace")
101
+ @click.argument("coordinate")
102
+ @input_options
103
+ @common
104
+ @click.pass_obj
105
+ def replace_config(inv: Invocation, coordinate, schema, input_file, input_json):
106
+ """完整替换 Extension config。"""
107
+ if schema:
108
+ return inv.show(config_schema(inv, coordinate))
109
+ inv.show(
110
+ inv.send("PUT", route(coordinate) + "/config", body=load_input(input_file, input_json))
111
+ )
112
+
113
+
114
+ @config.command("update")
115
+ @click.argument("coordinate")
116
+ @input_options
117
+ @common
118
+ @click.pass_obj
119
+ def update_config(inv: Invocation, coordinate, schema, input_file, input_json):
120
+ """更新提交的 config 字段。"""
121
+ if schema:
122
+ return inv.show(partial(config_schema(inv, coordinate)))
123
+ inv.show(
124
+ inv.send("PATCH", route(coordinate) + "/config", body=load_input(input_file, input_json))
125
+ )