ai_co_discuss 0.2.13 → 0.2.15
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.
- package/.agents/skills/discuss-host/SKILL.md +3 -2
- package/.agents/skills/discuss-participant/SKILL.md +1 -1
- package/README.md +13 -9
- package/backend/free_room.py +64 -0
- package/backend/identity.py +31 -31
- package/backend/runtime.py +364 -364
- package/backend/schema.sql +599 -580
- package/backend/server.py +1782 -1760
- package/docs/architecture.md +8 -4
- package/docs/cli.md +2 -0
- package/docs/runner.md +37 -4
- package/docs/workflow.md +148 -148
- package/package.json +2 -2
- package/runner/api_participant.mjs +186 -0
- package/runner/discuss.py +946 -946
- package/runner/discussion_adapters.mjs +6 -0
- package/runner/discussion_api.mjs +146 -0
- package/runner/discussion_launch.mjs +728 -664
- package/runner/discussion_runner.mjs +36 -7
- package/runner/discussion_service.mjs +395 -394
- package/runner/free_runner.mjs +6 -2
- package/web/app.js +2913 -2859
- package/web/backup.js +1 -1
- package/web/index.html +1 -1
- package/web/markdown.js +1 -1
- package/web/modern.css +1 -1
- package/web/modern.js +7362 -5077
package/runner/discuss.py
CHANGED
|
@@ -1,946 +1,946 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""AI CoDiscuss 命令行客户端:显式目标、按需读取、可重放的 HTTP 写入。"""
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import hashlib
|
|
6
|
-
import json
|
|
7
|
-
import math
|
|
8
|
-
import os
|
|
9
|
-
import re
|
|
10
|
-
import secrets
|
|
11
|
-
import sys
|
|
12
|
-
import tempfile
|
|
13
|
-
from dataclasses import dataclass
|
|
14
|
-
from http.client import HTTPException
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from urllib.error import HTTPError, URLError
|
|
17
|
-
from urllib.parse import unquote, urlencode, urlsplit
|
|
18
|
-
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
19
|
-
|
|
20
|
-
VERSION = "0.2.0"
|
|
21
|
-
DEFAULT_TIMEOUT = 20
|
|
22
|
-
MAX_BODY_BYTES = 512 * 1024
|
|
23
|
-
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
24
|
-
MAX_MESSAGE_ATTACHMENTS = 10
|
|
25
|
-
MAX_TEXT_LENGTH = 32000
|
|
26
|
-
MAX_SUMMARY_LENGTH = 1000
|
|
27
|
-
MAX_BRIEF_LENGTH = 1600
|
|
28
|
-
PARTICIPANTS = ("用户", "Codex", "Claude", "Gemini", "DeepSeek", "Qwen")
|
|
29
|
-
# A platform is a type; `Claude-评审` is a second Claude instance with its own session. Mirrors backend/identity.py.
|
|
30
|
-
INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Gemini|DeepSeek|Qwen)(?:-[0-9A-Za-z_一-鿿]{1,16})?")
|
|
31
|
-
PHASES = ("preparing", "initial", "discussing", "reviewing", "paused", "concluded")
|
|
32
|
-
KINDS = ("opinion", "moderation", "receipt", "chat")
|
|
33
|
-
OVERVIEW_FIELDS = ("id", "title", "goal", "host", "phase", "focus", "message_count",
|
|
34
|
-
"latest_id", "state_until", "current_turn", "queued_turns", "held_since", "floor", "runtime", "limits", "completed_rounds", "latest_receipts", "answered_turns", "duplicate", "submission_id",
|
|
35
|
-
"mode", "members", "free", "parent_room", "linked_rooms")
|
|
36
|
-
BRIEF_FIELDS = ("id", "title", "goal", "host", "user_request", "background", "boundaries", "experts")
|
|
37
|
-
ROOM_LIST_FIELDS = ("id", "title", "goal", "message_count", "latest_id", "created_at")
|
|
38
|
-
GUIDANCE = {
|
|
39
|
-
"initial": "先基于目标、背景和边界独立形成观点;提交前不读他人观点或主持总结。",
|
|
40
|
-
"paused": "停止处理安排并停止本讨论自己的后台任务,核对实际状态;非必要不生成 receipt,正常停止无需回执。",
|
|
41
|
-
"concluded": "话题已收束;停止本讨论自己的后台任务并核对实际状态;非必要不生成 receipt,正常停止无需回执。",
|
|
42
|
-
}
|
|
43
|
-
EXIT_CODES = {"0": "成功", "1": "HTTP/API 错误", "2": "参数或输入错误",
|
|
44
|
-
"3": "网络失败或响应无法确认", "130": "被中断,写入结果需核对"}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class CliError(Exception):
|
|
48
|
-
"""Carry a stable exit code and machine-readable error details."""
|
|
49
|
-
|
|
50
|
-
def __init__(self, message, code=2, **details):
|
|
51
|
-
super().__init__(message)
|
|
52
|
-
self.code = code
|
|
53
|
-
self.details = details
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class Parser(argparse.ArgumentParser):
|
|
57
|
-
"""Report parser failures through the same stderr contract as API errors."""
|
|
58
|
-
|
|
59
|
-
def error(self, message):
|
|
60
|
-
"""Raise a usage error instead of printing a separate argparse format."""
|
|
61
|
-
raise CliError(message, hint=f"查看 {self.prog} --help")
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
class NoRedirect(HTTPRedirectHandler):
|
|
65
|
-
"""Do not silently move a request to another service or convert POST to GET."""
|
|
66
|
-
|
|
67
|
-
def redirect_request(self, *args, **kwargs):
|
|
68
|
-
"""Leave redirects as HTTP errors so the target remains explicit."""
|
|
69
|
-
return None
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@dataclass
|
|
73
|
-
class RequestPlan:
|
|
74
|
-
"""Describe one request without performing network or persistence work."""
|
|
75
|
-
|
|
76
|
-
path: str
|
|
77
|
-
method: str = "GET"
|
|
78
|
-
body: dict | None = None
|
|
79
|
-
view: str | None = None
|
|
80
|
-
params: dict | None = None
|
|
81
|
-
upload_file: str | None = None
|
|
82
|
-
download_file: str | None = None
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def positive_int(value):
|
|
86
|
-
"""Parse a positive page or resource identifier."""
|
|
87
|
-
try:
|
|
88
|
-
number = int(value)
|
|
89
|
-
except ValueError as error:
|
|
90
|
-
raise argparse.ArgumentTypeError("必须为正整数") from error
|
|
91
|
-
if number < 1:
|
|
92
|
-
raise argparse.ArgumentTypeError("必须为正整数")
|
|
93
|
-
return number
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def ai_name(value):
|
|
97
|
-
"""Accept an AI platform or a named instance of one, such as Claude-评审."""
|
|
98
|
-
if not INSTANCE_PATTERN.fullmatch(value):
|
|
99
|
-
raise argparse.ArgumentTypeError("须为 Codex/Claude/Gemini/DeepSeek/Qwen,或平台名-后缀(后缀 1–16 个字母、数字、下划线或汉字)")
|
|
100
|
-
return value
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def participant(value):
|
|
104
|
-
"""Accept the user or any AI instance."""
|
|
105
|
-
return value if value == PARTICIPANTS[0] else ai_name(value)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def room_id(value):
|
|
109
|
-
"""Accept an existing room identifier without allowing path components."""
|
|
110
|
-
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
111
|
-
raise argparse.ArgumentTypeError("房间 ID 只能包含字母、数字、下划线和连字符")
|
|
112
|
-
return value
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def non_negative_int(value):
|
|
116
|
-
"""Parse a message cursor, including zero for the start of history."""
|
|
117
|
-
try:
|
|
118
|
-
number = int(value)
|
|
119
|
-
except ValueError as error:
|
|
120
|
-
raise argparse.ArgumentTypeError("必须为非负整数") from error
|
|
121
|
-
if number < 0:
|
|
122
|
-
raise argparse.ArgumentTypeError("必须为非负整数")
|
|
123
|
-
return number
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def submission_id(value):
|
|
127
|
-
"""Validate the server's reusable submission identifier format."""
|
|
128
|
-
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", value):
|
|
129
|
-
raise argparse.ArgumentTypeError("提交编号需为 8–100 位字母、数字、下划线或连字符")
|
|
130
|
-
return value
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def attachment_id(value):
|
|
134
|
-
"""Accept only opaque file identifiers, never local or remote paths."""
|
|
135
|
-
if not re.fullmatch(r"[a-f0-9]{32}", value):
|
|
136
|
-
raise argparse.ArgumentTypeError("附件 ID 需为服务端返回的 32 位小写十六进制编号")
|
|
137
|
-
return value
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def add_common(parser):
|
|
141
|
-
"""Allow common flags before or after subcommands without shared state."""
|
|
142
|
-
options = {"default": argparse.SUPPRESS}
|
|
143
|
-
parser.add_argument("--site", help="HTTP 服务地址;未指定时读 DISCUSS_URL", **options)
|
|
144
|
-
parser.add_argument("--as", dest="actor", type=participant,
|
|
145
|
-
help="业务写入的声明操作者;不是登录认证;api 使用原始请求体", **options)
|
|
146
|
-
parser.add_argument("-o", "--output", choices=("json", "text"),
|
|
147
|
-
help="默认 json;成功 stdout,错误 stderr", **options)
|
|
148
|
-
parser.add_argument("--timeout", type=float, help="请求超时秒数,默认 20", **options)
|
|
149
|
-
parser.add_argument("--dry-run", action="store_true", help="只预览写入请求,不联网", **options)
|
|
150
|
-
parser.add_argument("--access-file", help="本房间的受限凭据 JSON;不输出其 token", **options)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def add_text(parser, name, required=False):
|
|
154
|
-
"""Offer mutually exclusive literal and UTF-8 file inputs for one field."""
|
|
155
|
-
group = parser.add_mutually_exclusive_group(required=required)
|
|
156
|
-
group.add_argument(f"--{name}", help=f"{name} 正文", default=argparse.SUPPRESS)
|
|
157
|
-
group.add_argument(f"--{name}-file", help="UTF-8 文件;- 从标准输入读取",
|
|
158
|
-
default=argparse.SUPPRESS)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def add_topic(parser, create=False):
|
|
162
|
-
"""Expose topic fields, using a full experts map to avoid hidden merging."""
|
|
163
|
-
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
164
|
-
add_text(parser, field.replace("_", "-"), required=create and field == "title")
|
|
165
|
-
parser.add_argument("--host", type=participant, default=argparse.SUPPRESS,
|
|
166
|
-
help="主持人;创建时默认 --as 操作者")
|
|
167
|
-
parser.add_argument("--phase", choices=PHASES, default=argparse.SUPPRESS)
|
|
168
|
-
parser.add_argument("--experts-file", default=argparse.SUPPRESS,
|
|
169
|
-
help='完整参与者映射 JSON,如 {"Codex":"cli","Gemini":"cli"};替换整个映射')
|
|
170
|
-
for name in ("max-rounds", "max-minutes", "execution-minutes", "summary-minutes"):
|
|
171
|
-
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS)
|
|
172
|
-
parser.add_argument("--on-limit", choices=("pause", "summarize"), default=argparse.SUPPRESS)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def add_leaf(subcommands, registry, name, description, needs_room=False):
|
|
176
|
-
"""Register one discoverable command and its common options."""
|
|
177
|
-
parser = subcommands.add_parser(name.split()[-1], help=description, description=description,
|
|
178
|
-
allow_abbrev=False)
|
|
179
|
-
add_common(parser)
|
|
180
|
-
parser.set_defaults(operation=name)
|
|
181
|
-
if needs_room:
|
|
182
|
-
parser.add_argument("room", type=room_id, help="房间 ID;始终显式指定")
|
|
183
|
-
registry[name] = parser
|
|
184
|
-
return parser
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def room_commands(subcommands, registry):
|
|
188
|
-
"""Register room discovery, progressive reading, creation, and updates."""
|
|
189
|
-
for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
|
|
190
|
-
("brief", "按需读取背景、边界和专家配置"),
|
|
191
|
-
("protocol", "按需读取当前服务的参与协议")):
|
|
192
|
-
parser = add_leaf(subcommands, registry, f"room {action}", description, action != "list")
|
|
193
|
-
if action == "list":
|
|
194
|
-
parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
|
|
195
|
-
limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理", True)
|
|
196
|
-
limits.add_argument("--check", action="store_true")
|
|
197
|
-
create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
|
|
198
|
-
create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
|
|
199
|
-
add_topic(create, create=True)
|
|
200
|
-
create.add_argument("--submission-id", type=submission_id, required=True,
|
|
201
|
-
help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料")
|
|
202
|
-
update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
|
|
203
|
-
add_topic(update)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
def message_commands(subcommands, registry):
|
|
207
|
-
"""Register bounded search, exact reads, and attributed message writes."""
|
|
208
|
-
listing = add_leaf(subcommands, registry, "message list", "按页检索原文,每页 20 条", True)
|
|
209
|
-
paging = listing.add_mutually_exclusive_group()
|
|
210
|
-
paging.add_argument("--page", type=positive_int, default=1)
|
|
211
|
-
paging.add_argument("--after", type=non_negative_int, help="只读此消息编号之后的内容;按 next_after 续读")
|
|
212
|
-
listing.add_argument("--query", help="关键词或消息编号")
|
|
213
|
-
listing.add_argument("--author", type=participant)
|
|
214
|
-
listing.add_argument("--mention", type=participant)
|
|
215
|
-
listing.add_argument("--kind", choices=KINDS, help="筛选观点、主持说明或运行回执")
|
|
216
|
-
listing.add_argument("--submission-id", type=submission_id, help="按提交编号精确查询结果")
|
|
217
|
-
view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
|
|
218
|
-
view.add_argument("id", type=positive_int, help="消息编号")
|
|
219
|
-
post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明、代发或运行回执", True)
|
|
220
|
-
add_text(post, "body")
|
|
221
|
-
post.add_argument("--attachment", action="append", type=attachment_id, default=[],
|
|
222
|
-
help="已上传附件 ID;可重复指定,最多 10 个")
|
|
223
|
-
add_text(post, "summary")
|
|
224
|
-
post.add_argument("--summary-by", type=participant, help="摘要撰写者:原作者或实际操作者;默认操作者")
|
|
225
|
-
post.add_argument("--author", type=participant, help="原作者;默认等于 --as;不同即代发")
|
|
226
|
-
post.add_argument("--kind", choices=KINDS, default="opinion")
|
|
227
|
-
post.add_argument("--turn", type=positive_int, help="所回答的安排编号,仅 opinion 可用")
|
|
228
|
-
post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号,仅 chat 可用;执行失效后不能改作普通发言")
|
|
229
|
-
post.add_argument("--to", type=ai_name, action="append", default=[],
|
|
230
|
-
help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
|
|
231
|
-
post.add_argument("--request", type=positive_int, action="append", default=[],
|
|
232
|
-
help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
|
|
233
|
-
post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
|
|
234
|
-
post.add_argument("--submission-id", type=submission_id, required=True,
|
|
235
|
-
help="每次新提交的唯一编号;重试必须沿用同编号和正文")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
def attachment_commands(subcommands, registry):
|
|
239
|
-
"""Expose explicit upload, bounded discovery, and binary downloads."""
|
|
240
|
-
listing = add_leaf(subcommands, registry, "attachment list", "分页读取已发送附件;按提交编号核对上传", True)
|
|
241
|
-
listing.add_argument("--page", type=positive_int, default=1)
|
|
242
|
-
listing.add_argument("--message", type=positive_int, help="只读指定消息的附件")
|
|
243
|
-
listing.add_argument("--submission-id", type=submission_id, help="核对一次上传,包含尚未发送的文件")
|
|
244
|
-
view = add_leaf(subcommands, registry, "attachment view", "读取文件名、类型、大小与下载地址", True)
|
|
245
|
-
view.add_argument("id", type=attachment_id)
|
|
246
|
-
upload = add_leaf(subcommands, registry, "attachment upload", "上传原文件;之后用 message post --attachment 关联消息", True)
|
|
247
|
-
upload.add_argument("--file", required=True, help="本地原文件路径;最多 20 MiB")
|
|
248
|
-
upload.add_argument("--submission-id", type=submission_id, required=True)
|
|
249
|
-
download = add_leaf(subcommands, registry, "attachment download", "下载原文件到显式路径;不覆盖已有文件", True)
|
|
250
|
-
download.add_argument("id", type=attachment_id)
|
|
251
|
-
download.add_argument("--dest", required=True, help="保存文件的完整或相对路径;父目录须已存在")
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def turn_commands(subcommands, registry):
|
|
255
|
-
"""Keep planning, notification text, and delivery confirmation distinct."""
|
|
256
|
-
descriptions = {"list": "分页读取安排历史", "current": "读取阶段和最近安排;需核对状态及专家",
|
|
257
|
-
"view": "读取一个安排", "prompt": "生成简短通知;不发送、不标记送达",
|
|
258
|
-
"create": "安排一位专家,或用 --plan-file 一次安排整轮;不发送通知",
|
|
259
|
-
"continue": "处理用户操作或打断后,按原排队继续下一位",
|
|
260
|
-
"mark-notified": "实际送达后显式确认", "cancel": "取消指定未完成安排"}
|
|
261
|
-
for action, description in descriptions.items():
|
|
262
|
-
parser = add_leaf(subcommands, registry, f"turn {action}", description, True)
|
|
263
|
-
if action == "list":
|
|
264
|
-
parser.add_argument("--page", type=positive_int, default=1)
|
|
265
|
-
parser.add_argument("--submission-id", type=submission_id, help="按安排提交编号精确查询结果")
|
|
266
|
-
elif action not in ("create", "current", "continue"):
|
|
267
|
-
parser.add_argument("id", type=positive_int, help="安排编号")
|
|
268
|
-
if action == "create":
|
|
269
|
-
parser.add_argument("--expert", type=ai_name, help="单个安排的专家;与 --plan-file 二选一")
|
|
270
|
-
add_text(parser, "question")
|
|
271
|
-
parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
|
|
272
|
-
parser.add_argument("--submission-id", type=submission_id, required=True,
|
|
273
|
-
help="新安排唯一编号;重试沿用同编号和内容")
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def state_commands(subcommands, registry):
|
|
277
|
-
"""Register separately requested host summaries with explicit cutoffs."""
|
|
278
|
-
add_leaf(subcommands, registry, "state view", "读取主持摘要及材料截止编号", True)
|
|
279
|
-
parser = add_leaf(subcommands, registry, "state set", "保存主持摘要;不自动推定已读范围", True)
|
|
280
|
-
add_text(parser, "body", required=True)
|
|
281
|
-
add_text(parser, "brief")
|
|
282
|
-
parser.add_argument("--until", type=int, required=True, help="实际已读的材料截止编号,可为 0")
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def floor_commands(subcommands, registry):
|
|
286
|
-
"""Expose explicit floor inspection, nominated claims and early returns."""
|
|
287
|
-
add_leaf(subcommands, registry, "floor view", "读取当前持有者、下一位与版本", True)
|
|
288
|
-
for action in ("take", "return"):
|
|
289
|
-
parser = add_leaf(subcommands, registry, f"floor {action}", "领取或归还发言权", True)
|
|
290
|
-
parser.add_argument("--speaker", type=participant, help="实际持有者;代操作时显式指定,默认 --as")
|
|
291
|
-
parser.add_argument("--revision", type=non_negative_int, required=True, help="刚读取的发言权版本;变化后需重新核对")
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
def run_commands(subcommands, registry):
|
|
295
|
-
"""Expose mode and lifecycle controls independently of discussion phases."""
|
|
296
|
-
for action in ("view", "configure", "start", "pause", "stop"):
|
|
297
|
-
parser = add_leaf(subcommands, registry, f"run {action}", "查看或显式控制讨论运行", True)
|
|
298
|
-
if action != "view":
|
|
299
|
-
parser.add_argument("--revision", type=non_negative_int, required=True)
|
|
300
|
-
parser.add_argument("--mode", choices=("manual", "automatic"))
|
|
301
|
-
if action == "start":
|
|
302
|
-
parser.add_argument("--deadline", type=non_negative_int, help="截止时间 Unix 秒数;0 表示不限时长,仍受话题上限约束;省略时使用话题上限")
|
|
303
|
-
parser.add_argument("--phase", choices=("initial", "discussing", "reviewing"))
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
def execution_commands(subcommands, registry):
|
|
307
|
-
"""Enter one delivered task; duplicate enters cannot start duplicate work."""
|
|
308
|
-
for action in ("view", "enter", "fail"):
|
|
309
|
-
parser = add_leaf(subcommands, registry, f"execution {action}", "读取、进入或报告本次执行失败", True)
|
|
310
|
-
parser.add_argument("id", type=positive_int)
|
|
311
|
-
if action == "enter":
|
|
312
|
-
parser.add_argument("--session-id", help="实际软件会话 ID,默认从受限任务文件读取")
|
|
313
|
-
if action == "fail":
|
|
314
|
-
parser.add_argument("--reason", required=True)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def build_parser(prog="discuss"):
|
|
318
|
-
"""Build help and schema from the same command definitions."""
|
|
319
|
-
parser = Parser(prog=prog, description="AI CoDiscuss CLI:通过 HTTP 操作现有讨论网站。",
|
|
320
|
-
allow_abbrev=False, epilog="站点用 --site 或 DISCUSS_URL;不保存当前房间,不自动重试写入。")
|
|
321
|
-
add_common(parser)
|
|
322
|
-
parser.add_argument("--version", action="version", version=f"{prog} {VERSION}")
|
|
323
|
-
subcommands = parser.add_subparsers(dest="resource", required=True)
|
|
324
|
-
registry = {}
|
|
325
|
-
for name, register in (("room", room_commands), ("message", message_commands),
|
|
326
|
-
("attachment", attachment_commands),
|
|
327
|
-
("turn", turn_commands), ("state", state_commands), ("floor", floor_commands),
|
|
328
|
-
("run", run_commands), ("execution", execution_commands)):
|
|
329
|
-
group = subcommands.add_parser(name, help=f"{name} 操作", allow_abbrev=False)
|
|
330
|
-
add_common(group)
|
|
331
|
-
register(group.add_subparsers(dest="action", required=True), registry)
|
|
332
|
-
add_leaf(subcommands, registry, "health", "检查目标站点的 HTTP 健康接口")
|
|
333
|
-
api = add_leaf(subcommands, registry, "api", "直接调用本站 /api 或 /r;请求体原样传送")
|
|
334
|
-
api.add_argument("path", help="本站路径,可含查询参数;禁止完整 URL")
|
|
335
|
-
api.add_argument("--method", choices=("GET", "POST"), default="GET")
|
|
336
|
-
api.add_argument("--input", help="POST JSON 对象文件;- 从标准输入读取")
|
|
337
|
-
schema = add_leaf(subcommands, registry, "schema", "离线列出命令或指定命令的参数约定")
|
|
338
|
-
schema.add_argument("command", nargs="*", help="例如 message post;为空时列出全部命令")
|
|
339
|
-
return parser, registry
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def read_input(path):
|
|
343
|
-
"""Read UTF-8 content, accepting a BOM and at most one stdin consumer."""
|
|
344
|
-
if path == "-":
|
|
345
|
-
if sys.stdin.isatty():
|
|
346
|
-
raise CliError("--*-file - / --input - 需要通过管道提供标准输入")
|
|
347
|
-
return sys.stdin.read().lstrip("\ufeff")
|
|
348
|
-
try:
|
|
349
|
-
return Path(path).read_text(encoding="utf-8-sig")
|
|
350
|
-
except (OSError, UnicodeError) as error:
|
|
351
|
-
raise CliError(f"无法读取 UTF-8 文件 {path}: {error}") from error
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def read_object(path):
|
|
355
|
-
"""Require a JSON object rather than silently accepting arrays or scalars."""
|
|
356
|
-
try:
|
|
357
|
-
value = json.loads(read_input(path))
|
|
358
|
-
except ValueError as error:
|
|
359
|
-
raise CliError(f"无效 JSON: {error}") from error
|
|
360
|
-
if not isinstance(value, dict):
|
|
361
|
-
raise CliError("JSON 文件必须包含对象")
|
|
362
|
-
return value
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def text_field(args, field):
|
|
366
|
-
"""Read one literal or file-backed field without altering its content."""
|
|
367
|
-
values = vars(args)
|
|
368
|
-
path = values.get(f"{field}_file")
|
|
369
|
-
return read_input(path) if path is not None else values.get(field)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
def require_actor(args):
|
|
373
|
-
"""Require an explicit declared operator for domain writes."""
|
|
374
|
-
actor = getattr(args, "actor", None)
|
|
375
|
-
if not actor:
|
|
376
|
-
raise CliError("写入必须显式指定 --as;该字段声明操作者,不提供身份认证")
|
|
377
|
-
return actor
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def topic_payload(args):
|
|
381
|
-
"""Collect only supplied topic fields; expert configuration is a full map."""
|
|
382
|
-
payload = {}
|
|
383
|
-
limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes") if hasattr(args, key)}
|
|
384
|
-
if limits:
|
|
385
|
-
payload["limits"] = limits
|
|
386
|
-
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
387
|
-
value = text_field(args, field)
|
|
388
|
-
if value is not None:
|
|
389
|
-
payload[field] = value
|
|
390
|
-
for field in ("host", "phase"):
|
|
391
|
-
if hasattr(args, field):
|
|
392
|
-
payload[field] = getattr(args, field)
|
|
393
|
-
if hasattr(args, "experts_file"):
|
|
394
|
-
experts = read_object(args.experts_file)
|
|
395
|
-
if any(not INSTANCE_PATTERN.fullmatch(name) or mode not in ("cli", "online")
|
|
396
|
-
for name, mode in experts.items()):
|
|
397
|
-
raise CliError("专家映射的名称必须是有效 AI,模式必须是 cli 或 online")
|
|
398
|
-
payload["experts"] = experts
|
|
399
|
-
return payload
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def plan_room(args):
|
|
403
|
-
"""Translate room operations to one existing HTTP endpoint."""
|
|
404
|
-
if args.action == "list":
|
|
405
|
-
params = {"submission_id": args.submission_id} if args.submission_id else None
|
|
406
|
-
return RequestPlan("/api/rooms", view="room_list", params=params)
|
|
407
|
-
if args.action == "create":
|
|
408
|
-
payload = topic_payload(args)
|
|
409
|
-
payload.setdefault("host", require_actor(args))
|
|
410
|
-
payload["submission_id"] = args.submission_id
|
|
411
|
-
if args.folder_id is not None:
|
|
412
|
-
payload["folder_id"] = args.folder_id
|
|
413
|
-
return RequestPlan("/api/rooms", "POST", payload, "overview")
|
|
414
|
-
base = f"/api/rooms/{args.room}"
|
|
415
|
-
if args.action == "limits":
|
|
416
|
-
return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args)}, "overview") if args.check else RequestPlan(base + "/limits")
|
|
417
|
-
if args.action == "update":
|
|
418
|
-
payload = topic_payload(args)
|
|
419
|
-
if not payload:
|
|
420
|
-
raise CliError("room update 至少需要一个话题字段")
|
|
421
|
-
payload["actor"] = require_actor(args)
|
|
422
|
-
return RequestPlan(base + "/topic", "POST", payload, "overview")
|
|
423
|
-
if args.action == "protocol":
|
|
424
|
-
return RequestPlan(f"/r/{args.room}/protocol")
|
|
425
|
-
return RequestPlan(base, view="brief" if args.action == "brief" else "overview")
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
def plan_message(args):
|
|
429
|
-
"""Preserve operator, original author, message role, and replay identifier."""
|
|
430
|
-
base = f"/api/rooms/{args.room}/messages"
|
|
431
|
-
if args.action == "view":
|
|
432
|
-
return RequestPlan(f"{base}/{args.id}")
|
|
433
|
-
if args.action == "list":
|
|
434
|
-
params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
|
|
435
|
-
"after": args.after, "kind": args.kind, "submission_id": args.submission_id}
|
|
436
|
-
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
437
|
-
actor = require_actor(args)
|
|
438
|
-
if args.turn and args.kind != "opinion":
|
|
439
|
-
raise CliError("只有 opinion 可以关联 --turn;receipt 不完成安排")
|
|
440
|
-
if (args.execution or args.request or args.to) and (args.kind != "chat" or args.turn):
|
|
441
|
-
raise CliError("--execution / --request 仅用于自由房间的 chat 回答,不能与 --turn 同时使用")
|
|
442
|
-
if args.execution and args.request:
|
|
443
|
-
raise CliError("--execution 用于接入程序交付的执行,--request 用于手动接入成员,二者不能同用")
|
|
444
|
-
payload = {"author": args.author or actor, "submitted_by": actor, "source": "cli",
|
|
445
|
-
"kind": args.kind, "text": text_field(args, "body") or "", "submission_id": args.submission_id}
|
|
446
|
-
if len(args.attachment) > MAX_MESSAGE_ATTACHMENTS or len(set(args.attachment)) != len(args.attachment):
|
|
447
|
-
raise CliError("每条消息最多 10 个附件,不能重复")
|
|
448
|
-
if args.attachment:
|
|
449
|
-
payload["attachment_ids"] = args.attachment
|
|
450
|
-
if (not payload["text"].strip() and not args.attachment) or len(payload["text"].strip()) > MAX_TEXT_LENGTH:
|
|
451
|
-
raise CliError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 个 Unicode 字符;不会自动截断或分段")
|
|
452
|
-
summary = text_field(args, "summary")
|
|
453
|
-
if summary is not None:
|
|
454
|
-
if len(summary.strip()) > MAX_SUMMARY_LENGTH:
|
|
455
|
-
raise CliError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
456
|
-
payload["summary"] = summary
|
|
457
|
-
if args.summary_by:
|
|
458
|
-
payload["summary_by"] = args.summary_by
|
|
459
|
-
if args.turn:
|
|
460
|
-
payload["turn_id"] = args.turn
|
|
461
|
-
if args.execution:
|
|
462
|
-
payload["execution_id"] = args.execution
|
|
463
|
-
if args.request:
|
|
464
|
-
payload["request_ids"] = list(dict.fromkeys(args.request))
|
|
465
|
-
if args.to:
|
|
466
|
-
payload["to"] = list(dict.fromkeys(args.to))
|
|
467
|
-
if args.reply_to:
|
|
468
|
-
payload["reply_to"] = args.reply_to
|
|
469
|
-
return RequestPlan(base, "POST", payload)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
def plan_attachment(args):
|
|
473
|
-
"""Keep file bytes outside JSON messages and all storage targets explicit."""
|
|
474
|
-
base = f"/api/rooms/{args.room}/attachments"
|
|
475
|
-
if args.action == "list":
|
|
476
|
-
params = {"page": args.page, "message_id": args.message, "submission_id": args.submission_id}
|
|
477
|
-
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
478
|
-
if args.action == "upload":
|
|
479
|
-
path = Path(args.file)
|
|
480
|
-
try:
|
|
481
|
-
if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
|
482
|
-
raise CliError("上传路径须为文件,大小最多 20 MiB")
|
|
483
|
-
except OSError as error:
|
|
484
|
-
raise CliError(f"无法读取上传文件: {error}") from error
|
|
485
|
-
metadata = {"actor": require_actor(args), "file_name": path.name, "submission_id": args.submission_id}
|
|
486
|
-
return RequestPlan(base, "POST", params=metadata, upload_file=str(path))
|
|
487
|
-
if args.action == "download":
|
|
488
|
-
destination = Path(args.dest)
|
|
489
|
-
if destination.exists() or not destination.parent.is_dir():
|
|
490
|
-
raise CliError("--dest 须为不存在的文件路径,且父目录已存在;不会覆盖文件")
|
|
491
|
-
return RequestPlan(f"{base}/{args.id}/content", params={"download": "1"}, download_file=str(destination))
|
|
492
|
-
return RequestPlan(f"{base}/{args.id}")
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
def plan_turn(args):
|
|
496
|
-
"""Separate creating an assignment from external notification delivery."""
|
|
497
|
-
base = f"/api/rooms/{args.room}"
|
|
498
|
-
if args.action == "current":
|
|
499
|
-
return RequestPlan(base, view="current")
|
|
500
|
-
if args.action == "list":
|
|
501
|
-
params = {"page": args.page}
|
|
502
|
-
if args.submission_id:
|
|
503
|
-
params["submission_id"] = args.submission_id
|
|
504
|
-
return RequestPlan(base + "/turns", params=params)
|
|
505
|
-
if args.action == "create":
|
|
506
|
-
return RequestPlan(base + "/turns", "POST", turn_payload(args))
|
|
507
|
-
if args.action == "continue":
|
|
508
|
-
return RequestPlan(base + "/turns/continue", "POST", {"actor": require_actor(args)})
|
|
509
|
-
path = f"{base}/turns/{args.id}"
|
|
510
|
-
if args.action in ("mark-notified", "cancel"):
|
|
511
|
-
status = "notified" if args.action == "mark-notified" else "cancelled"
|
|
512
|
-
return RequestPlan(path, "POST", {"actor": require_actor(args), "status": status})
|
|
513
|
-
return RequestPlan(path, view="prompt" if args.action == "prompt" else None)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def turn_payload(args):
|
|
517
|
-
"""Build one arrangement or an ordered round plan from a UTF-8 JSON array."""
|
|
518
|
-
payload = {"actor": require_actor(args), "submission_id": args.submission_id}
|
|
519
|
-
question = text_field(args, "question")
|
|
520
|
-
if args.plan_file is not None:
|
|
521
|
-
if args.expert or question is not None:
|
|
522
|
-
raise CliError("--plan-file 不能与 --expert / --question 同时使用")
|
|
523
|
-
try:
|
|
524
|
-
turns = json.loads(read_input(args.plan_file))
|
|
525
|
-
except ValueError as error:
|
|
526
|
-
raise CliError(f"无效 JSON: {error}") from error
|
|
527
|
-
if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
|
|
528
|
-
raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
|
|
529
|
-
return {**payload, "turns": turns}
|
|
530
|
-
if not args.expert or question is None:
|
|
531
|
-
raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
|
|
532
|
-
return {**payload, "expert": args.expert, "question": question}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def plan_floor(args):
|
|
536
|
-
"""Send one versioned operation without silently claiming on a failed write."""
|
|
537
|
-
path = f"/api/rooms/{args.room}/floor"
|
|
538
|
-
if args.action == "view":
|
|
539
|
-
return RequestPlan(path)
|
|
540
|
-
actor = require_actor(args)
|
|
541
|
-
return RequestPlan(path, "POST", {"actor": actor, "speaker": args.speaker or actor,
|
|
542
|
-
"action": args.action, "revision": args.revision})
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
def plan_state(args):
|
|
546
|
-
"""Keep a host summary's cutoff explicit instead of assuming latest means read."""
|
|
547
|
-
base = f"/api/rooms/{args.room}"
|
|
548
|
-
if args.action == "view":
|
|
549
|
-
return RequestPlan(base, view="state")
|
|
550
|
-
if args.until < 0:
|
|
551
|
-
raise CliError("--until 不能小于 0")
|
|
552
|
-
payload = {"actor": require_actor(args), "text": text_field(args, "body"), "until": args.until}
|
|
553
|
-
brief = text_field(args, "brief")
|
|
554
|
-
if brief is not None:
|
|
555
|
-
if len(brief.strip()) > MAX_BRIEF_LENGTH:
|
|
556
|
-
raise CliError(f"主持简报最多 {MAX_BRIEF_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
557
|
-
payload["brief"] = brief
|
|
558
|
-
return RequestPlan(base + "/state", "POST", payload, "state")
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
def plan_api(args):
|
|
562
|
-
"""Allow literal HTTP requests only under the selected site's API/read roots."""
|
|
563
|
-
path = urlsplit(args.path)
|
|
564
|
-
decoded = unquote(path.path)
|
|
565
|
-
if (path.scheme or path.netloc or path.fragment or "\\" in decoded
|
|
566
|
-
or not decoded.startswith(("/api/", "/r/")) or ".." in decoded.split("/")):
|
|
567
|
-
raise CliError("api 路径必须是本站 /api/... 或 /r/...,不能切换主机或包含 ..")
|
|
568
|
-
if args.method == "GET" and args.input:
|
|
569
|
-
raise CliError("GET 不接受 --input;查询条件直接写在路径中")
|
|
570
|
-
if args.method == "POST" and not args.input:
|
|
571
|
-
raise CliError("POST 需要 --input JSON文件(或 -)")
|
|
572
|
-
return RequestPlan(args.path, args.method, read_object(args.input) if args.input else None)
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
def plan_run(args):
|
|
576
|
-
"""Build one versioned lifecycle operation without starting a daemon."""
|
|
577
|
-
path = f"/api/rooms/{args.room}/runtime"
|
|
578
|
-
if args.action == "view":
|
|
579
|
-
return RequestPlan(path)
|
|
580
|
-
body = {"action": args.action, "revision": args.revision}
|
|
581
|
-
for key in ("mode", "deadline", "phase"):
|
|
582
|
-
if getattr(args, key, None) is not None:
|
|
583
|
-
body[key] = getattr(args, key)
|
|
584
|
-
return RequestPlan(path, "POST", body)
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
def plan_execution(args):
|
|
588
|
-
"""Use the bound actual session when acknowledging a delivered execution."""
|
|
589
|
-
body = {"action": args.action}
|
|
590
|
-
if args.action == "enter":
|
|
591
|
-
access = read_object(args.access_file) if getattr(args, "access_file", None) else {}
|
|
592
|
-
body["session_id"] = args.session_id or access.get("session_id")
|
|
593
|
-
if not body["session_id"]:
|
|
594
|
-
raise CliError("尚未取得实际 session_id,请先核对接入回执")
|
|
595
|
-
if args.action == "fail":
|
|
596
|
-
body["reason"] = args.reason
|
|
597
|
-
return RequestPlan(f"/api/rooms/{args.room}/events/{args.id}", "POST", body)
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
def build_plan(args):
|
|
601
|
-
"""Validate local input before constructing any network request."""
|
|
602
|
-
stdin_count = sum(value == "-" for key, value in vars(args).items()
|
|
603
|
-
if key.endswith("_file") or key == "input")
|
|
604
|
-
if stdin_count > 1:
|
|
605
|
-
raise CliError("一次命令只能有一个字段从标准输入读取")
|
|
606
|
-
handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
|
|
607
|
-
"attachment": plan_attachment,
|
|
608
|
-
"state": plan_state, "floor": plan_floor, "api": plan_api,
|
|
609
|
-
"run": plan_run, "execution": plan_execution}
|
|
610
|
-
plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
|
|
611
|
-
if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
|
|
612
|
-
raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
|
|
613
|
-
if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
|
|
614
|
-
raise CliError(f"请求体超过服务端限制 {MAX_BODY_BYTES} 字节")
|
|
615
|
-
return plan
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
def resolve_site(args):
|
|
619
|
-
"""Resolve an explicit flag or environment URL without implicit production writes."""
|
|
620
|
-
site = getattr(args, "site", None) or os.getenv("DISCUSS_URL", "")
|
|
621
|
-
parsed = urlsplit(site)
|
|
622
|
-
if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
|
|
623
|
-
or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/")):
|
|
624
|
-
raise CliError("请用 --site 或 DISCUSS_URL 指定 http(s)://主机[:端口],不带路径、凭据或查询")
|
|
625
|
-
try:
|
|
626
|
-
parsed.port
|
|
627
|
-
except ValueError as error:
|
|
628
|
-
raise CliError(str(error)) from error
|
|
629
|
-
return site.rstrip("/")
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
def decode_response(response, plan):
|
|
633
|
-
"""Decode the expected payload while identifying uncertain write responses."""
|
|
634
|
-
if plan.download_file is not None:
|
|
635
|
-
return save_attachment_response(response, plan.download_file)
|
|
636
|
-
try:
|
|
637
|
-
text = response.read().decode("utf-8-sig")
|
|
638
|
-
if plan.path.startswith("/r/") and plan.method == "GET":
|
|
639
|
-
if "text/plain" not in response.headers.get("Content-Type", ""):
|
|
640
|
-
raise ValueError("预期纯文本响应")
|
|
641
|
-
return text
|
|
642
|
-
value = json.loads(text)
|
|
643
|
-
if not isinstance(value, dict):
|
|
644
|
-
raise ValueError("预期 JSON 对象")
|
|
645
|
-
return value
|
|
646
|
-
except (ValueError, UnicodeError) as error:
|
|
647
|
-
raise CliError("响应不是预期的 UTF-8 / JSON 内容", 3,
|
|
648
|
-
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
def save_attachment_response(response, destination):
|
|
652
|
-
"""Validate a complete bounded response before creating an exclusive local file."""
|
|
653
|
-
data = response.read(MAX_ATTACHMENT_BYTES + 1)
|
|
654
|
-
size_header = response.headers.get("Content-Length")
|
|
655
|
-
if len(data) > MAX_ATTACHMENT_BYTES or (size_header and len(data) != int(size_header)):
|
|
656
|
-
raise CliError("附件下载不完整或超过 20 MiB;未保存文件", 3, outcome="unavailable")
|
|
657
|
-
digest = hashlib.sha256(data).hexdigest()
|
|
658
|
-
if response.headers.get("X-Content-SHA256", digest) != digest:
|
|
659
|
-
raise CliError("附件校验失败;未保存文件", 3, outcome="unavailable")
|
|
660
|
-
path = Path(destination)
|
|
661
|
-
created = False
|
|
662
|
-
try:
|
|
663
|
-
with path.open("xb") as target:
|
|
664
|
-
created = True
|
|
665
|
-
target.write(data)
|
|
666
|
-
except OSError as error:
|
|
667
|
-
if created:
|
|
668
|
-
path.unlink(missing_ok=True)
|
|
669
|
-
raise CliError(f"无法保存附件;不会覆盖已有文件: {error}") from error
|
|
670
|
-
return {"path": str(path.resolve()), "size": len(data), "sha256": digest}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
def request_data(plan):
|
|
674
|
-
"""Load a bounded file for binary upload, or encode ordinary JSON requests."""
|
|
675
|
-
if plan.upload_file is not None:
|
|
676
|
-
try:
|
|
677
|
-
with Path(plan.upload_file).open("rb") as source:
|
|
678
|
-
data = source.read(MAX_ATTACHMENT_BYTES + 1)
|
|
679
|
-
except OSError as error:
|
|
680
|
-
raise CliError(f"无法读取上传文件: {error}") from error
|
|
681
|
-
if len(data) > MAX_ATTACHMENT_BYTES:
|
|
682
|
-
raise CliError("单个附件最多 20 MiB")
|
|
683
|
-
return data
|
|
684
|
-
return json.dumps(plan.body, ensure_ascii=False).encode("utf-8") if plan.body is not None else None
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
def http_error(error, plan):
|
|
688
|
-
"""Retain useful API errors and distinguish rejected from uncertain writes."""
|
|
689
|
-
try:
|
|
690
|
-
body = error.read().decode("utf-8")
|
|
691
|
-
value = json.loads(body)
|
|
692
|
-
message = value.get("error", body) if isinstance(value, dict) else body
|
|
693
|
-
except (ValueError, UnicodeError, OSError, HTTPException):
|
|
694
|
-
message = f"HTTP {error.code}: {error.reason}"
|
|
695
|
-
uncertain = plan.method == "POST" and error.code >= 500
|
|
696
|
-
return CliError(str(message), 1, http_status=error.code,
|
|
697
|
-
outcome="unknown" if uncertain else "rejected")
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def send_request(plan, url, timeout, headers=None):
|
|
701
|
-
"""Send exactly once, preserving TLS verification and bounded waiting."""
|
|
702
|
-
data = request_data(plan)
|
|
703
|
-
content_type = "application/octet-stream" if plan.upload_file is not None else "application/json"
|
|
704
|
-
request = Request(url, data=data, method=plan.method,
|
|
705
|
-
headers={"Content-Type": content_type, "Accept": "*/*" if plan.download_file else "application/json, text/plain",
|
|
706
|
-
"User-Agent": f"AI-CoDiscuss-CLI/{VERSION}", **(headers or {})})
|
|
707
|
-
try:
|
|
708
|
-
with build_opener(NoRedirect()).open(request, timeout=timeout) as response:
|
|
709
|
-
return decode_response(response, plan)
|
|
710
|
-
except HTTPError as error:
|
|
711
|
-
with error:
|
|
712
|
-
raise http_error(error, plan) from error
|
|
713
|
-
except (URLError, OSError, HTTPException) as error:
|
|
714
|
-
raise CliError(f"网络请求失败: {error}", 3,
|
|
715
|
-
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
def select_fields(value, names):
|
|
719
|
-
"""Project explicitly requested fields without expanding additional context."""
|
|
720
|
-
return {name: value[name] for name in names if name in value}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
def project_result(value, plan, site):
|
|
724
|
-
"""Keep summaries, briefing material, host state, and original text separate."""
|
|
725
|
-
if plan.view == "room_list":
|
|
726
|
-
return {"rooms": [select_fields(room, ROOM_LIST_FIELDS) for room in value["rooms"]]}
|
|
727
|
-
if plan.view in ("overview", "brief", "state", "current"):
|
|
728
|
-
fields = {"overview": OVERVIEW_FIELDS, "brief": BRIEF_FIELDS,
|
|
729
|
-
"state": ("id", "state_text", "state_until", "host_brief", "latest_receipts", "answered_turns"),
|
|
730
|
-
"current": ("id", "phase", "current_turn", "queued_turns", "held_since", "floor", "answered_turns")}[plan.view]
|
|
731
|
-
result = select_fields(value, fields)
|
|
732
|
-
if plan.view in ("overview", "current"):
|
|
733
|
-
result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
|
|
734
|
-
result["url"] = f"{site}/r/{value['id']}"
|
|
735
|
-
return result
|
|
736
|
-
if plan.view == "prompt":
|
|
737
|
-
entry = f"{site}/r/{value['room_id']}?participant={value['expert']}"
|
|
738
|
-
prompt = f"请 {value['expert']} 回应:{value['question']}\n安排编号:{value['id']}\n按需阅读:{entry}\n回答附简短摘要及完整正文,保留关键条件和不确定性。"
|
|
739
|
-
return {"turn_id": value["id"], "status": value["status"], "prompt": prompt,
|
|
740
|
-
"delivery": "未发送;仅生成文字,实际通知后使用 turn mark-notified"}
|
|
741
|
-
return value
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def command_schema(args, registry):
|
|
745
|
-
"""Describe available commands and fields from the live argparse registry."""
|
|
746
|
-
name = " ".join(args.command)
|
|
747
|
-
if not name:
|
|
748
|
-
return {"version": VERSION, "commands": {key: item.description for key, item in registry.items()},
|
|
749
|
-
"exit_codes": EXIT_CODES, "site": "--site > DISCUSS_URL;无内置默认站点",
|
|
750
|
-
"output": "默认 JSON;成功 stdout,错误 stderr;无交互提示、无自动重试"}
|
|
751
|
-
if name not in registry:
|
|
752
|
-
raise CliError(f"未知命令 {name};运行 discuss schema 查看可用命令")
|
|
753
|
-
parser = registry[name]
|
|
754
|
-
arguments = []
|
|
755
|
-
for action in parser._actions:
|
|
756
|
-
if action.dest == "help":
|
|
757
|
-
continue
|
|
758
|
-
item = {"name": action.dest, "flags": action.option_strings,
|
|
759
|
-
"required": action.required, "help": action.help}
|
|
760
|
-
if action.dest == "actor" and name in ("room create", "room update", "message post",
|
|
761
|
-
"turn create", "turn mark-notified", "turn cancel", "state set", "attachment upload"):
|
|
762
|
-
item["required"] = True
|
|
763
|
-
if action.choices is not None:
|
|
764
|
-
item["choices"] = list(action.choices)
|
|
765
|
-
if action.default not in (argparse.SUPPRESS, None):
|
|
766
|
-
item["default"] = action.default
|
|
767
|
-
arguments.append(item)
|
|
768
|
-
groups = [{"one_of": [item.dest for item in group._group_actions], "required": group.required}
|
|
769
|
-
for group in parser._mutually_exclusive_groups]
|
|
770
|
-
return {"command": name, "description": parser.description, "arguments": arguments,
|
|
771
|
-
"exclusive_groups": groups, "scope": "CLI 参数约定;服务端协议见 room protocol"}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
def text_output(value):
|
|
775
|
-
"""Render readable text while retaining complete message bodies and IDs."""
|
|
776
|
-
if isinstance(value, str):
|
|
777
|
-
return value
|
|
778
|
-
if isinstance(value, dict):
|
|
779
|
-
lines = []
|
|
780
|
-
for key, item in value.items():
|
|
781
|
-
if isinstance(item, (dict, list)):
|
|
782
|
-
lines.append(f"{key}:\n{text_output(item)}")
|
|
783
|
-
else:
|
|
784
|
-
lines.append(f"{key}: {item if item is not None else '—'}")
|
|
785
|
-
return "\n".join(lines)
|
|
786
|
-
if isinstance(value, list):
|
|
787
|
-
return "\n\n".join(text_output(item) for item in value) or "(空)"
|
|
788
|
-
return str(value)
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
def emit(value, output="json", error=False):
|
|
792
|
-
"""Write the stable JSON envelope or human text to the correct stream."""
|
|
793
|
-
stream = sys.stderr if error else sys.stdout
|
|
794
|
-
if output == "json":
|
|
795
|
-
rendered = json.dumps(value, ensure_ascii=False, indent=2)
|
|
796
|
-
else:
|
|
797
|
-
rendered = text_output(value if error else value["data"])
|
|
798
|
-
stream.write(rendered + "\n")
|
|
799
|
-
stream.flush()
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
def save_access(path, value):
|
|
803
|
-
"""Persist private capabilities before sending requests, without printing their content."""
|
|
804
|
-
target = Path(path)
|
|
805
|
-
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as stream:
|
|
806
|
-
temporary = Path(stream.name)
|
|
807
|
-
os.chmod(temporary, 0o600)
|
|
808
|
-
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
809
|
-
stream.flush()
|
|
810
|
-
os.fsync(stream.fileno())
|
|
811
|
-
try:
|
|
812
|
-
os.replace(temporary, target)
|
|
813
|
-
finally:
|
|
814
|
-
temporary.unlink(missing_ok=True)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def prepare_creation_access(args, site, plan):
|
|
818
|
-
"""Create or reuse one explicit private credential bundle for a stable creation id."""
|
|
819
|
-
if getattr(args, "operation", "") != "room create":
|
|
820
|
-
return
|
|
821
|
-
if not getattr(args, "access_file", None):
|
|
822
|
-
raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
|
|
823
|
-
path = Path(args.access_file)
|
|
824
|
-
payload = plan.body
|
|
825
|
-
access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
|
|
826
|
-
"submission_id": payload.get("submission_id"), "members": {}}
|
|
827
|
-
if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
|
|
828
|
-
raise CliError("建房凭据文件与本次站点或 submission_id 不符")
|
|
829
|
-
experts = payload.setdefault("experts", {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:]})
|
|
830
|
-
members = []
|
|
831
|
-
for name, capability in experts.items():
|
|
832
|
-
member = access["members"].setdefault(name, {"token": secrets.token_urlsafe(48)})
|
|
833
|
-
members.append({"name": name, "token": member["token"], "access": "online" if capability == "online" else "manual"})
|
|
834
|
-
payload["members"] = members
|
|
835
|
-
save_access(path, access)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
def finish_creation_access(args, site, plan, value):
|
|
839
|
-
"""Save the confirmed room and separate member files; return paths without secrets."""
|
|
840
|
-
if getattr(args, "operation", "") != "room create":
|
|
841
|
-
return {}
|
|
842
|
-
path = Path(args.access_file).resolve()
|
|
843
|
-
access = read_object(path)
|
|
844
|
-
access["room"] = value["id"]
|
|
845
|
-
save_access(path, access)
|
|
846
|
-
paths = {}
|
|
847
|
-
for member in plan.body["members"]:
|
|
848
|
-
if member["access"] == "online":
|
|
849
|
-
continue
|
|
850
|
-
member_path = path.with_name(f"{path.stem}.{member['name']}.json")
|
|
851
|
-
save_access(member_path, {"site": site, "room": value["id"], "token": member["token"]})
|
|
852
|
-
paths[member["name"]] = str(member_path)
|
|
853
|
-
return {"owner_access_file": str(path), "member_access_files": paths}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def access_headers(args, site, path):
|
|
857
|
-
"""Load a room-scoped secret only for its exact site and room."""
|
|
858
|
-
if not getattr(args, "access_file", None):
|
|
859
|
-
return {}
|
|
860
|
-
access = read_object(args.access_file)
|
|
861
|
-
expected = f"/api/rooms/{access.get('room', '')}"
|
|
862
|
-
target = urlsplit(path).path
|
|
863
|
-
creation = target == "/api/rooms" and bool(access.get("submission_id"))
|
|
864
|
-
reading = f"/r/{access.get('room', '')}"
|
|
865
|
-
if access.get("site", "").rstrip("/") != site or not (creation or target == expected or target.startswith(expected + "/") or target == reading or target.startswith(reading + "/")):
|
|
866
|
-
raise CliError("凭据文件不属于本次站点和房间")
|
|
867
|
-
token = access.get("token")
|
|
868
|
-
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
869
|
-
raise CliError("凭据文件中的 token 无效")
|
|
870
|
-
return {"X-Discussion-Token": token}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
def preview_body(value):
|
|
874
|
-
"""Keep private capability material out of offline request previews."""
|
|
875
|
-
if isinstance(value, dict):
|
|
876
|
-
return {key: "[redacted]" if key in ("token", "execution_token", "control_hash", "token_hash") else preview_body(item)
|
|
877
|
-
for key, item in value.items()}
|
|
878
|
-
if isinstance(value, list):
|
|
879
|
-
return [preview_body(item) for item in value]
|
|
880
|
-
return value
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
def run(args, registry):
|
|
884
|
-
"""Execute a parsed command, with schema and previews remaining offline."""
|
|
885
|
-
if args.resource == "schema":
|
|
886
|
-
return {"ok": True, "data": command_schema(args, registry)}
|
|
887
|
-
plan = build_plan(args)
|
|
888
|
-
site = resolve_site(args)
|
|
889
|
-
timeout = getattr(args, "timeout", DEFAULT_TIMEOUT)
|
|
890
|
-
if not math.isfinite(timeout) or timeout <= 0:
|
|
891
|
-
raise CliError("--timeout 必须为有限正数")
|
|
892
|
-
url = site + plan.path
|
|
893
|
-
if plan.params:
|
|
894
|
-
url += "?" + urlencode(plan.params)
|
|
895
|
-
meta = {"site": site, "method": plan.method, "path": plan.path}
|
|
896
|
-
if getattr(args, "dry_run", False):
|
|
897
|
-
return {"ok": True, "data": {"method": plan.method, "url": url, "body": preview_body(plan.body),
|
|
898
|
-
**({"file": plan.upload_file} if plan.upload_file else {}),
|
|
899
|
-
**({"destination": plan.download_file} if plan.download_file else {})},
|
|
900
|
-
"meta": {**meta, "dry_run": True, "validation": "仅本地参数;未验证房间、身份或服务端状态"}}
|
|
901
|
-
try:
|
|
902
|
-
prepare_creation_access(args, site, plan)
|
|
903
|
-
headers = access_headers(args, site, plan.path)
|
|
904
|
-
value = send_request(plan, url, timeout, headers) if headers else send_request(plan, url, timeout)
|
|
905
|
-
result = project_result(value, plan, site)
|
|
906
|
-
if getattr(args, "operation", "") == "room create":
|
|
907
|
-
result.update(finish_creation_access(args, site, plan, value))
|
|
908
|
-
except CliError as error:
|
|
909
|
-
error.details.update(meta)
|
|
910
|
-
if plan.method == "POST":
|
|
911
|
-
error.details["submission_id"] = (plan.body or plan.params or {}).get("submission_id")
|
|
912
|
-
error.details["hint"] = "未自动重试。创建结果未知时用对应 list --submission-id 查询;确认需要重试才沿用原编号和原始内容。"
|
|
913
|
-
raise
|
|
914
|
-
except (KeyError, TypeError) as error:
|
|
915
|
-
raise CliError("响应结构与 CLI 不兼容,请用 api 命令检查服务版本", 3,
|
|
916
|
-
**meta, outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
917
|
-
return {"ok": True, "data": result, "meta": meta}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
def main(argv=None):
|
|
921
|
-
"""Run the CLI without prompts; return documented process exit codes."""
|
|
922
|
-
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
923
|
-
if hasattr(stream, "reconfigure"):
|
|
924
|
-
stream.reconfigure(encoding="utf-8")
|
|
925
|
-
args = None
|
|
926
|
-
try:
|
|
927
|
-
parser, registry = build_parser(prog=os.getenv("DISCUSS_CLI_NAME", "discuss"))
|
|
928
|
-
args = parser.parse_args(argv)
|
|
929
|
-
emit(run(args, registry), getattr(args, "output", "json"))
|
|
930
|
-
return 0
|
|
931
|
-
except CliError as error:
|
|
932
|
-
output = getattr(args, "output", "json")
|
|
933
|
-
emit({"ok": False, "error": {"message": str(error), "exit_code": error.code, **error.details}},
|
|
934
|
-
output, error=True)
|
|
935
|
-
return error.code
|
|
936
|
-
except (ValueError, UnicodeError, OSError) as error:
|
|
937
|
-
emit({"ok": False, "error": {"message": str(error), "exit_code": 2}}, error=True)
|
|
938
|
-
return 2
|
|
939
|
-
except KeyboardInterrupt:
|
|
940
|
-
emit({"ok": False, "error": {"message": "已中断;若正在写入,请先读取记录核对结果。",
|
|
941
|
-
"exit_code": 130, "outcome": "unknown"}}, error=True)
|
|
942
|
-
return 130
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
if __name__ == "__main__":
|
|
946
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AI CoDiscuss 命令行客户端:显式目标、按需读取、可重放的 HTTP 写入。"""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from http.client import HTTPException
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import unquote, urlencode, urlsplit
|
|
18
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
19
|
+
|
|
20
|
+
VERSION = "0.2.0"
|
|
21
|
+
DEFAULT_TIMEOUT = 20
|
|
22
|
+
MAX_BODY_BYTES = 512 * 1024
|
|
23
|
+
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
|
24
|
+
MAX_MESSAGE_ATTACHMENTS = 10
|
|
25
|
+
MAX_TEXT_LENGTH = 32000
|
|
26
|
+
MAX_SUMMARY_LENGTH = 1000
|
|
27
|
+
MAX_BRIEF_LENGTH = 1600
|
|
28
|
+
PARTICIPANTS = ("用户", "Codex", "Claude", "Gemini", "DeepSeek", "Qwen", "Grok")
|
|
29
|
+
# A platform is a type; `Claude-评审` is a second Claude instance with its own session. Mirrors backend/identity.py.
|
|
30
|
+
INSTANCE_PATTERN = re.compile(r"(Codex|Claude|Gemini|DeepSeek|Qwen|Grok)(?:-[0-9A-Za-z_一-鿿]{1,16})?")
|
|
31
|
+
PHASES = ("preparing", "initial", "discussing", "reviewing", "paused", "concluded")
|
|
32
|
+
KINDS = ("opinion", "moderation", "receipt", "chat")
|
|
33
|
+
OVERVIEW_FIELDS = ("id", "title", "goal", "host", "phase", "focus", "message_count",
|
|
34
|
+
"latest_id", "state_until", "current_turn", "queued_turns", "held_since", "floor", "runtime", "limits", "completed_rounds", "latest_receipts", "answered_turns", "duplicate", "submission_id",
|
|
35
|
+
"mode", "members", "free", "parent_room", "linked_rooms")
|
|
36
|
+
BRIEF_FIELDS = ("id", "title", "goal", "host", "user_request", "background", "boundaries", "experts")
|
|
37
|
+
ROOM_LIST_FIELDS = ("id", "title", "goal", "message_count", "latest_id", "created_at")
|
|
38
|
+
GUIDANCE = {
|
|
39
|
+
"initial": "先基于目标、背景和边界独立形成观点;提交前不读他人观点或主持总结。",
|
|
40
|
+
"paused": "停止处理安排并停止本讨论自己的后台任务,核对实际状态;非必要不生成 receipt,正常停止无需回执。",
|
|
41
|
+
"concluded": "话题已收束;停止本讨论自己的后台任务并核对实际状态;非必要不生成 receipt,正常停止无需回执。",
|
|
42
|
+
}
|
|
43
|
+
EXIT_CODES = {"0": "成功", "1": "HTTP/API 错误", "2": "参数或输入错误",
|
|
44
|
+
"3": "网络失败或响应无法确认", "130": "被中断,写入结果需核对"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CliError(Exception):
|
|
48
|
+
"""Carry a stable exit code and machine-readable error details."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, message, code=2, **details):
|
|
51
|
+
super().__init__(message)
|
|
52
|
+
self.code = code
|
|
53
|
+
self.details = details
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Parser(argparse.ArgumentParser):
|
|
57
|
+
"""Report parser failures through the same stderr contract as API errors."""
|
|
58
|
+
|
|
59
|
+
def error(self, message):
|
|
60
|
+
"""Raise a usage error instead of printing a separate argparse format."""
|
|
61
|
+
raise CliError(message, hint=f"查看 {self.prog} --help")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NoRedirect(HTTPRedirectHandler):
|
|
65
|
+
"""Do not silently move a request to another service or convert POST to GET."""
|
|
66
|
+
|
|
67
|
+
def redirect_request(self, *args, **kwargs):
|
|
68
|
+
"""Leave redirects as HTTP errors so the target remains explicit."""
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RequestPlan:
|
|
74
|
+
"""Describe one request without performing network or persistence work."""
|
|
75
|
+
|
|
76
|
+
path: str
|
|
77
|
+
method: str = "GET"
|
|
78
|
+
body: dict | None = None
|
|
79
|
+
view: str | None = None
|
|
80
|
+
params: dict | None = None
|
|
81
|
+
upload_file: str | None = None
|
|
82
|
+
download_file: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def positive_int(value):
|
|
86
|
+
"""Parse a positive page or resource identifier."""
|
|
87
|
+
try:
|
|
88
|
+
number = int(value)
|
|
89
|
+
except ValueError as error:
|
|
90
|
+
raise argparse.ArgumentTypeError("必须为正整数") from error
|
|
91
|
+
if number < 1:
|
|
92
|
+
raise argparse.ArgumentTypeError("必须为正整数")
|
|
93
|
+
return number
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ai_name(value):
|
|
97
|
+
"""Accept an AI platform or a named instance of one, such as Claude-评审."""
|
|
98
|
+
if not INSTANCE_PATTERN.fullmatch(value):
|
|
99
|
+
raise argparse.ArgumentTypeError("须为 Codex/Claude/Gemini/DeepSeek/Qwen/Grok,或平台名-后缀(后缀 1–16 个字母、数字、下划线或汉字)")
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def participant(value):
|
|
104
|
+
"""Accept the user or any AI instance."""
|
|
105
|
+
return value if value == PARTICIPANTS[0] else ai_name(value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def room_id(value):
|
|
109
|
+
"""Accept an existing room identifier without allowing path components."""
|
|
110
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]+", value):
|
|
111
|
+
raise argparse.ArgumentTypeError("房间 ID 只能包含字母、数字、下划线和连字符")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def non_negative_int(value):
|
|
116
|
+
"""Parse a message cursor, including zero for the start of history."""
|
|
117
|
+
try:
|
|
118
|
+
number = int(value)
|
|
119
|
+
except ValueError as error:
|
|
120
|
+
raise argparse.ArgumentTypeError("必须为非负整数") from error
|
|
121
|
+
if number < 0:
|
|
122
|
+
raise argparse.ArgumentTypeError("必须为非负整数")
|
|
123
|
+
return number
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def submission_id(value):
|
|
127
|
+
"""Validate the server's reusable submission identifier format."""
|
|
128
|
+
if not re.fullmatch(r"[A-Za-z0-9_-]{8,100}", value):
|
|
129
|
+
raise argparse.ArgumentTypeError("提交编号需为 8–100 位字母、数字、下划线或连字符")
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def attachment_id(value):
|
|
134
|
+
"""Accept only opaque file identifiers, never local or remote paths."""
|
|
135
|
+
if not re.fullmatch(r"[a-f0-9]{32}", value):
|
|
136
|
+
raise argparse.ArgumentTypeError("附件 ID 需为服务端返回的 32 位小写十六进制编号")
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def add_common(parser):
|
|
141
|
+
"""Allow common flags before or after subcommands without shared state."""
|
|
142
|
+
options = {"default": argparse.SUPPRESS}
|
|
143
|
+
parser.add_argument("--site", help="HTTP 服务地址;未指定时读 DISCUSS_URL", **options)
|
|
144
|
+
parser.add_argument("--as", dest="actor", type=participant,
|
|
145
|
+
help="业务写入的声明操作者;不是登录认证;api 使用原始请求体", **options)
|
|
146
|
+
parser.add_argument("-o", "--output", choices=("json", "text"),
|
|
147
|
+
help="默认 json;成功 stdout,错误 stderr", **options)
|
|
148
|
+
parser.add_argument("--timeout", type=float, help="请求超时秒数,默认 20", **options)
|
|
149
|
+
parser.add_argument("--dry-run", action="store_true", help="只预览写入请求,不联网", **options)
|
|
150
|
+
parser.add_argument("--access-file", help="本房间的受限凭据 JSON;不输出其 token", **options)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def add_text(parser, name, required=False):
|
|
154
|
+
"""Offer mutually exclusive literal and UTF-8 file inputs for one field."""
|
|
155
|
+
group = parser.add_mutually_exclusive_group(required=required)
|
|
156
|
+
group.add_argument(f"--{name}", help=f"{name} 正文", default=argparse.SUPPRESS)
|
|
157
|
+
group.add_argument(f"--{name}-file", help="UTF-8 文件;- 从标准输入读取",
|
|
158
|
+
default=argparse.SUPPRESS)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def add_topic(parser, create=False):
|
|
162
|
+
"""Expose topic fields, using a full experts map to avoid hidden merging."""
|
|
163
|
+
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
164
|
+
add_text(parser, field.replace("_", "-"), required=create and field == "title")
|
|
165
|
+
parser.add_argument("--host", type=participant, default=argparse.SUPPRESS,
|
|
166
|
+
help="主持人;创建时默认 --as 操作者")
|
|
167
|
+
parser.add_argument("--phase", choices=PHASES, default=argparse.SUPPRESS)
|
|
168
|
+
parser.add_argument("--experts-file", default=argparse.SUPPRESS,
|
|
169
|
+
help='完整参与者映射 JSON,如 {"Codex":"cli","Gemini":"cli"};替换整个映射')
|
|
170
|
+
for name in ("max-rounds", "max-minutes", "execution-minutes", "summary-minutes"):
|
|
171
|
+
parser.add_argument(f"--{name}", type=non_negative_int, default=argparse.SUPPRESS)
|
|
172
|
+
parser.add_argument("--on-limit", choices=("pause", "summarize"), default=argparse.SUPPRESS)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def add_leaf(subcommands, registry, name, description, needs_room=False):
|
|
176
|
+
"""Register one discoverable command and its common options."""
|
|
177
|
+
parser = subcommands.add_parser(name.split()[-1], help=description, description=description,
|
|
178
|
+
allow_abbrev=False)
|
|
179
|
+
add_common(parser)
|
|
180
|
+
parser.set_defaults(operation=name)
|
|
181
|
+
if needs_room:
|
|
182
|
+
parser.add_argument("room", type=room_id, help="房间 ID;始终显式指定")
|
|
183
|
+
registry[name] = parser
|
|
184
|
+
return parser
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def room_commands(subcommands, registry):
|
|
188
|
+
"""Register room discovery, progressive reading, creation, and updates."""
|
|
189
|
+
for action, description in (("list", "列出房间概要"), ("view", "只读目标、阶段、焦点及当前安排"),
|
|
190
|
+
("brief", "按需读取背景、边界和专家配置"),
|
|
191
|
+
("protocol", "按需读取当前服务的参与协议")):
|
|
192
|
+
parser = add_leaf(subcommands, registry, f"room {action}", description, action != "list")
|
|
193
|
+
if action == "list":
|
|
194
|
+
parser.add_argument("--submission-id", type=submission_id, help="按建房提交编号精确查询")
|
|
195
|
+
limits = add_leaf(subcommands, registry, "room limits", "查看上限;--check 检查并执行到限处理", True)
|
|
196
|
+
limits.add_argument("--check", action="store_true")
|
|
197
|
+
create = add_leaf(subcommands, registry, "room create", "创建话题;不自动启动讨论或监控")
|
|
198
|
+
create.add_argument("--folder-id", help="直接在指定文件夹中创建;省略则未归类")
|
|
199
|
+
add_topic(create, create=True)
|
|
200
|
+
create.add_argument("--submission-id", type=submission_id, required=True,
|
|
201
|
+
help="新话题唯一编号;未知结果先查询,重试沿用编号和原始资料")
|
|
202
|
+
update = add_leaf(subcommands, registry, "room update", "维护话题;省略的字段保持不变", True)
|
|
203
|
+
add_topic(update)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def message_commands(subcommands, registry):
|
|
207
|
+
"""Register bounded search, exact reads, and attributed message writes."""
|
|
208
|
+
listing = add_leaf(subcommands, registry, "message list", "按页检索原文,每页 20 条", True)
|
|
209
|
+
paging = listing.add_mutually_exclusive_group()
|
|
210
|
+
paging.add_argument("--page", type=positive_int, default=1)
|
|
211
|
+
paging.add_argument("--after", type=non_negative_int, help="只读此消息编号之后的内容;按 next_after 续读")
|
|
212
|
+
listing.add_argument("--query", help="关键词或消息编号")
|
|
213
|
+
listing.add_argument("--author", type=participant)
|
|
214
|
+
listing.add_argument("--mention", type=participant)
|
|
215
|
+
listing.add_argument("--kind", choices=KINDS, help="筛选观点、主持说明或运行回执")
|
|
216
|
+
listing.add_argument("--submission-id", type=submission_id, help="按提交编号精确查询结果")
|
|
217
|
+
view = add_leaf(subcommands, registry, "message view", "读取一条精确原文与来源", True)
|
|
218
|
+
view.add_argument("id", type=positive_int, help="消息编号")
|
|
219
|
+
post = add_leaf(subcommands, registry, "message post", "提交观点、主持说明、代发或运行回执", True)
|
|
220
|
+
add_text(post, "body")
|
|
221
|
+
post.add_argument("--attachment", action="append", type=attachment_id, default=[],
|
|
222
|
+
help="已上传附件 ID;可重复指定,最多 10 个")
|
|
223
|
+
add_text(post, "summary")
|
|
224
|
+
post.add_argument("--summary-by", type=participant, help="摘要撰写者:原作者或实际操作者;默认操作者")
|
|
225
|
+
post.add_argument("--author", type=participant, help="原作者;默认等于 --as;不同即代发")
|
|
226
|
+
post.add_argument("--kind", choices=KINDS, default="opinion")
|
|
227
|
+
post.add_argument("--turn", type=positive_int, help="所回答的安排编号,仅 opinion 可用")
|
|
228
|
+
post.add_argument("--execution", type=positive_int, help="自由房间:本次执行编号,仅 chat 可用;执行失效后不能改作普通发言")
|
|
229
|
+
post.add_argument("--to", type=ai_name, action="append", default=[],
|
|
230
|
+
help="自由房间:主持人点名派活,普通成员提出回应建议(须房主确认);可重复")
|
|
231
|
+
post.add_argument("--request", type=positive_int, action="append", default=[],
|
|
232
|
+
help="自由房间手动接入成员:本次回答完成的请求编号,可重复;不能与 --execution 同用")
|
|
233
|
+
post.add_argument("--reply-to", type=positive_int, help="被引用的原文编号")
|
|
234
|
+
post.add_argument("--submission-id", type=submission_id, required=True,
|
|
235
|
+
help="每次新提交的唯一编号;重试必须沿用同编号和正文")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def attachment_commands(subcommands, registry):
|
|
239
|
+
"""Expose explicit upload, bounded discovery, and binary downloads."""
|
|
240
|
+
listing = add_leaf(subcommands, registry, "attachment list", "分页读取已发送附件;按提交编号核对上传", True)
|
|
241
|
+
listing.add_argument("--page", type=positive_int, default=1)
|
|
242
|
+
listing.add_argument("--message", type=positive_int, help="只读指定消息的附件")
|
|
243
|
+
listing.add_argument("--submission-id", type=submission_id, help="核对一次上传,包含尚未发送的文件")
|
|
244
|
+
view = add_leaf(subcommands, registry, "attachment view", "读取文件名、类型、大小与下载地址", True)
|
|
245
|
+
view.add_argument("id", type=attachment_id)
|
|
246
|
+
upload = add_leaf(subcommands, registry, "attachment upload", "上传原文件;之后用 message post --attachment 关联消息", True)
|
|
247
|
+
upload.add_argument("--file", required=True, help="本地原文件路径;最多 20 MiB")
|
|
248
|
+
upload.add_argument("--submission-id", type=submission_id, required=True)
|
|
249
|
+
download = add_leaf(subcommands, registry, "attachment download", "下载原文件到显式路径;不覆盖已有文件", True)
|
|
250
|
+
download.add_argument("id", type=attachment_id)
|
|
251
|
+
download.add_argument("--dest", required=True, help="保存文件的完整或相对路径;父目录须已存在")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def turn_commands(subcommands, registry):
|
|
255
|
+
"""Keep planning, notification text, and delivery confirmation distinct."""
|
|
256
|
+
descriptions = {"list": "分页读取安排历史", "current": "读取阶段和最近安排;需核对状态及专家",
|
|
257
|
+
"view": "读取一个安排", "prompt": "生成简短通知;不发送、不标记送达",
|
|
258
|
+
"create": "安排一位专家,或用 --plan-file 一次安排整轮;不发送通知",
|
|
259
|
+
"continue": "处理用户操作或打断后,按原排队继续下一位",
|
|
260
|
+
"mark-notified": "实际送达后显式确认", "cancel": "取消指定未完成安排"}
|
|
261
|
+
for action, description in descriptions.items():
|
|
262
|
+
parser = add_leaf(subcommands, registry, f"turn {action}", description, True)
|
|
263
|
+
if action == "list":
|
|
264
|
+
parser.add_argument("--page", type=positive_int, default=1)
|
|
265
|
+
parser.add_argument("--submission-id", type=submission_id, help="按安排提交编号精确查询结果")
|
|
266
|
+
elif action not in ("create", "current", "continue"):
|
|
267
|
+
parser.add_argument("id", type=positive_int, help="安排编号")
|
|
268
|
+
if action == "create":
|
|
269
|
+
parser.add_argument("--expert", type=ai_name, help="单个安排的专家;与 --plan-file 二选一")
|
|
270
|
+
add_text(parser, "question")
|
|
271
|
+
parser.add_argument("--plan-file", help='整轮安排 JSON 数组 [{"expert":"Claude","question":"…"}],按顺序接力;- 从标准输入读取')
|
|
272
|
+
parser.add_argument("--submission-id", type=submission_id, required=True,
|
|
273
|
+
help="新安排唯一编号;重试沿用同编号和内容")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def state_commands(subcommands, registry):
|
|
277
|
+
"""Register separately requested host summaries with explicit cutoffs."""
|
|
278
|
+
add_leaf(subcommands, registry, "state view", "读取主持摘要及材料截止编号", True)
|
|
279
|
+
parser = add_leaf(subcommands, registry, "state set", "保存主持摘要;不自动推定已读范围", True)
|
|
280
|
+
add_text(parser, "body", required=True)
|
|
281
|
+
add_text(parser, "brief")
|
|
282
|
+
parser.add_argument("--until", type=int, required=True, help="实际已读的材料截止编号,可为 0")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def floor_commands(subcommands, registry):
|
|
286
|
+
"""Expose explicit floor inspection, nominated claims and early returns."""
|
|
287
|
+
add_leaf(subcommands, registry, "floor view", "读取当前持有者、下一位与版本", True)
|
|
288
|
+
for action in ("take", "return"):
|
|
289
|
+
parser = add_leaf(subcommands, registry, f"floor {action}", "领取或归还发言权", True)
|
|
290
|
+
parser.add_argument("--speaker", type=participant, help="实际持有者;代操作时显式指定,默认 --as")
|
|
291
|
+
parser.add_argument("--revision", type=non_negative_int, required=True, help="刚读取的发言权版本;变化后需重新核对")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run_commands(subcommands, registry):
|
|
295
|
+
"""Expose mode and lifecycle controls independently of discussion phases."""
|
|
296
|
+
for action in ("view", "configure", "start", "pause", "stop"):
|
|
297
|
+
parser = add_leaf(subcommands, registry, f"run {action}", "查看或显式控制讨论运行", True)
|
|
298
|
+
if action != "view":
|
|
299
|
+
parser.add_argument("--revision", type=non_negative_int, required=True)
|
|
300
|
+
parser.add_argument("--mode", choices=("manual", "automatic"))
|
|
301
|
+
if action == "start":
|
|
302
|
+
parser.add_argument("--deadline", type=non_negative_int, help="截止时间 Unix 秒数;0 表示不限时长,仍受话题上限约束;省略时使用话题上限")
|
|
303
|
+
parser.add_argument("--phase", choices=("initial", "discussing", "reviewing"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def execution_commands(subcommands, registry):
|
|
307
|
+
"""Enter one delivered task; duplicate enters cannot start duplicate work."""
|
|
308
|
+
for action in ("view", "enter", "fail"):
|
|
309
|
+
parser = add_leaf(subcommands, registry, f"execution {action}", "读取、进入或报告本次执行失败", True)
|
|
310
|
+
parser.add_argument("id", type=positive_int)
|
|
311
|
+
if action == "enter":
|
|
312
|
+
parser.add_argument("--session-id", help="实际软件会话 ID,默认从受限任务文件读取")
|
|
313
|
+
if action == "fail":
|
|
314
|
+
parser.add_argument("--reason", required=True)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def build_parser(prog="discuss"):
|
|
318
|
+
"""Build help and schema from the same command definitions."""
|
|
319
|
+
parser = Parser(prog=prog, description="AI CoDiscuss CLI:通过 HTTP 操作现有讨论网站。",
|
|
320
|
+
allow_abbrev=False, epilog="站点用 --site 或 DISCUSS_URL;不保存当前房间,不自动重试写入。")
|
|
321
|
+
add_common(parser)
|
|
322
|
+
parser.add_argument("--version", action="version", version=f"{prog} {VERSION}")
|
|
323
|
+
subcommands = parser.add_subparsers(dest="resource", required=True)
|
|
324
|
+
registry = {}
|
|
325
|
+
for name, register in (("room", room_commands), ("message", message_commands),
|
|
326
|
+
("attachment", attachment_commands),
|
|
327
|
+
("turn", turn_commands), ("state", state_commands), ("floor", floor_commands),
|
|
328
|
+
("run", run_commands), ("execution", execution_commands)):
|
|
329
|
+
group = subcommands.add_parser(name, help=f"{name} 操作", allow_abbrev=False)
|
|
330
|
+
add_common(group)
|
|
331
|
+
register(group.add_subparsers(dest="action", required=True), registry)
|
|
332
|
+
add_leaf(subcommands, registry, "health", "检查目标站点的 HTTP 健康接口")
|
|
333
|
+
api = add_leaf(subcommands, registry, "api", "直接调用本站 /api 或 /r;请求体原样传送")
|
|
334
|
+
api.add_argument("path", help="本站路径,可含查询参数;禁止完整 URL")
|
|
335
|
+
api.add_argument("--method", choices=("GET", "POST"), default="GET")
|
|
336
|
+
api.add_argument("--input", help="POST JSON 对象文件;- 从标准输入读取")
|
|
337
|
+
schema = add_leaf(subcommands, registry, "schema", "离线列出命令或指定命令的参数约定")
|
|
338
|
+
schema.add_argument("command", nargs="*", help="例如 message post;为空时列出全部命令")
|
|
339
|
+
return parser, registry
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def read_input(path):
|
|
343
|
+
"""Read UTF-8 content, accepting a BOM and at most one stdin consumer."""
|
|
344
|
+
if path == "-":
|
|
345
|
+
if sys.stdin.isatty():
|
|
346
|
+
raise CliError("--*-file - / --input - 需要通过管道提供标准输入")
|
|
347
|
+
return sys.stdin.read().lstrip("\ufeff")
|
|
348
|
+
try:
|
|
349
|
+
return Path(path).read_text(encoding="utf-8-sig")
|
|
350
|
+
except (OSError, UnicodeError) as error:
|
|
351
|
+
raise CliError(f"无法读取 UTF-8 文件 {path}: {error}") from error
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def read_object(path):
|
|
355
|
+
"""Require a JSON object rather than silently accepting arrays or scalars."""
|
|
356
|
+
try:
|
|
357
|
+
value = json.loads(read_input(path))
|
|
358
|
+
except ValueError as error:
|
|
359
|
+
raise CliError(f"无效 JSON: {error}") from error
|
|
360
|
+
if not isinstance(value, dict):
|
|
361
|
+
raise CliError("JSON 文件必须包含对象")
|
|
362
|
+
return value
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def text_field(args, field):
|
|
366
|
+
"""Read one literal or file-backed field without altering its content."""
|
|
367
|
+
values = vars(args)
|
|
368
|
+
path = values.get(f"{field}_file")
|
|
369
|
+
return read_input(path) if path is not None else values.get(field)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def require_actor(args):
|
|
373
|
+
"""Require an explicit declared operator for domain writes."""
|
|
374
|
+
actor = getattr(args, "actor", None)
|
|
375
|
+
if not actor:
|
|
376
|
+
raise CliError("写入必须显式指定 --as;该字段声明操作者,不提供身份认证")
|
|
377
|
+
return actor
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def topic_payload(args):
|
|
381
|
+
"""Collect only supplied topic fields; expert configuration is a full map."""
|
|
382
|
+
payload = {}
|
|
383
|
+
limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes") if hasattr(args, key)}
|
|
384
|
+
if limits:
|
|
385
|
+
payload["limits"] = limits
|
|
386
|
+
for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
|
|
387
|
+
value = text_field(args, field)
|
|
388
|
+
if value is not None:
|
|
389
|
+
payload[field] = value
|
|
390
|
+
for field in ("host", "phase"):
|
|
391
|
+
if hasattr(args, field):
|
|
392
|
+
payload[field] = getattr(args, field)
|
|
393
|
+
if hasattr(args, "experts_file"):
|
|
394
|
+
experts = read_object(args.experts_file)
|
|
395
|
+
if any(not INSTANCE_PATTERN.fullmatch(name) or mode not in ("cli", "online")
|
|
396
|
+
for name, mode in experts.items()):
|
|
397
|
+
raise CliError("专家映射的名称必须是有效 AI,模式必须是 cli 或 online")
|
|
398
|
+
payload["experts"] = experts
|
|
399
|
+
return payload
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def plan_room(args):
|
|
403
|
+
"""Translate room operations to one existing HTTP endpoint."""
|
|
404
|
+
if args.action == "list":
|
|
405
|
+
params = {"submission_id": args.submission_id} if args.submission_id else None
|
|
406
|
+
return RequestPlan("/api/rooms", view="room_list", params=params)
|
|
407
|
+
if args.action == "create":
|
|
408
|
+
payload = topic_payload(args)
|
|
409
|
+
payload.setdefault("host", require_actor(args))
|
|
410
|
+
payload["submission_id"] = args.submission_id
|
|
411
|
+
if args.folder_id is not None:
|
|
412
|
+
payload["folder_id"] = args.folder_id
|
|
413
|
+
return RequestPlan("/api/rooms", "POST", payload, "overview")
|
|
414
|
+
base = f"/api/rooms/{args.room}"
|
|
415
|
+
if args.action == "limits":
|
|
416
|
+
return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args)}, "overview") if args.check else RequestPlan(base + "/limits")
|
|
417
|
+
if args.action == "update":
|
|
418
|
+
payload = topic_payload(args)
|
|
419
|
+
if not payload:
|
|
420
|
+
raise CliError("room update 至少需要一个话题字段")
|
|
421
|
+
payload["actor"] = require_actor(args)
|
|
422
|
+
return RequestPlan(base + "/topic", "POST", payload, "overview")
|
|
423
|
+
if args.action == "protocol":
|
|
424
|
+
return RequestPlan(f"/r/{args.room}/protocol")
|
|
425
|
+
return RequestPlan(base, view="brief" if args.action == "brief" else "overview")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def plan_message(args):
|
|
429
|
+
"""Preserve operator, original author, message role, and replay identifier."""
|
|
430
|
+
base = f"/api/rooms/{args.room}/messages"
|
|
431
|
+
if args.action == "view":
|
|
432
|
+
return RequestPlan(f"{base}/{args.id}")
|
|
433
|
+
if args.action == "list":
|
|
434
|
+
params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
|
|
435
|
+
"after": args.after, "kind": args.kind, "submission_id": args.submission_id}
|
|
436
|
+
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
437
|
+
actor = require_actor(args)
|
|
438
|
+
if args.turn and args.kind != "opinion":
|
|
439
|
+
raise CliError("只有 opinion 可以关联 --turn;receipt 不完成安排")
|
|
440
|
+
if (args.execution or args.request or args.to) and (args.kind != "chat" or args.turn):
|
|
441
|
+
raise CliError("--execution / --request 仅用于自由房间的 chat 回答,不能与 --turn 同时使用")
|
|
442
|
+
if args.execution and args.request:
|
|
443
|
+
raise CliError("--execution 用于接入程序交付的执行,--request 用于手动接入成员,二者不能同用")
|
|
444
|
+
payload = {"author": args.author or actor, "submitted_by": actor, "source": "cli",
|
|
445
|
+
"kind": args.kind, "text": text_field(args, "body") or "", "submission_id": args.submission_id}
|
|
446
|
+
if len(args.attachment) > MAX_MESSAGE_ATTACHMENTS or len(set(args.attachment)) != len(args.attachment):
|
|
447
|
+
raise CliError("每条消息最多 10 个附件,不能重复")
|
|
448
|
+
if args.attachment:
|
|
449
|
+
payload["attachment_ids"] = args.attachment
|
|
450
|
+
if (not payload["text"].strip() and not args.attachment) or len(payload["text"].strip()) > MAX_TEXT_LENGTH:
|
|
451
|
+
raise CliError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 个 Unicode 字符;不会自动截断或分段")
|
|
452
|
+
summary = text_field(args, "summary")
|
|
453
|
+
if summary is not None:
|
|
454
|
+
if len(summary.strip()) > MAX_SUMMARY_LENGTH:
|
|
455
|
+
raise CliError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
456
|
+
payload["summary"] = summary
|
|
457
|
+
if args.summary_by:
|
|
458
|
+
payload["summary_by"] = args.summary_by
|
|
459
|
+
if args.turn:
|
|
460
|
+
payload["turn_id"] = args.turn
|
|
461
|
+
if args.execution:
|
|
462
|
+
payload["execution_id"] = args.execution
|
|
463
|
+
if args.request:
|
|
464
|
+
payload["request_ids"] = list(dict.fromkeys(args.request))
|
|
465
|
+
if args.to:
|
|
466
|
+
payload["to"] = list(dict.fromkeys(args.to))
|
|
467
|
+
if args.reply_to:
|
|
468
|
+
payload["reply_to"] = args.reply_to
|
|
469
|
+
return RequestPlan(base, "POST", payload)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def plan_attachment(args):
|
|
473
|
+
"""Keep file bytes outside JSON messages and all storage targets explicit."""
|
|
474
|
+
base = f"/api/rooms/{args.room}/attachments"
|
|
475
|
+
if args.action == "list":
|
|
476
|
+
params = {"page": args.page, "message_id": args.message, "submission_id": args.submission_id}
|
|
477
|
+
return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
|
|
478
|
+
if args.action == "upload":
|
|
479
|
+
path = Path(args.file)
|
|
480
|
+
try:
|
|
481
|
+
if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
|
|
482
|
+
raise CliError("上传路径须为文件,大小最多 20 MiB")
|
|
483
|
+
except OSError as error:
|
|
484
|
+
raise CliError(f"无法读取上传文件: {error}") from error
|
|
485
|
+
metadata = {"actor": require_actor(args), "file_name": path.name, "submission_id": args.submission_id}
|
|
486
|
+
return RequestPlan(base, "POST", params=metadata, upload_file=str(path))
|
|
487
|
+
if args.action == "download":
|
|
488
|
+
destination = Path(args.dest)
|
|
489
|
+
if destination.exists() or not destination.parent.is_dir():
|
|
490
|
+
raise CliError("--dest 须为不存在的文件路径,且父目录已存在;不会覆盖文件")
|
|
491
|
+
return RequestPlan(f"{base}/{args.id}/content", params={"download": "1"}, download_file=str(destination))
|
|
492
|
+
return RequestPlan(f"{base}/{args.id}")
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def plan_turn(args):
|
|
496
|
+
"""Separate creating an assignment from external notification delivery."""
|
|
497
|
+
base = f"/api/rooms/{args.room}"
|
|
498
|
+
if args.action == "current":
|
|
499
|
+
return RequestPlan(base, view="current")
|
|
500
|
+
if args.action == "list":
|
|
501
|
+
params = {"page": args.page}
|
|
502
|
+
if args.submission_id:
|
|
503
|
+
params["submission_id"] = args.submission_id
|
|
504
|
+
return RequestPlan(base + "/turns", params=params)
|
|
505
|
+
if args.action == "create":
|
|
506
|
+
return RequestPlan(base + "/turns", "POST", turn_payload(args))
|
|
507
|
+
if args.action == "continue":
|
|
508
|
+
return RequestPlan(base + "/turns/continue", "POST", {"actor": require_actor(args)})
|
|
509
|
+
path = f"{base}/turns/{args.id}"
|
|
510
|
+
if args.action in ("mark-notified", "cancel"):
|
|
511
|
+
status = "notified" if args.action == "mark-notified" else "cancelled"
|
|
512
|
+
return RequestPlan(path, "POST", {"actor": require_actor(args), "status": status})
|
|
513
|
+
return RequestPlan(path, view="prompt" if args.action == "prompt" else None)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def turn_payload(args):
|
|
517
|
+
"""Build one arrangement or an ordered round plan from a UTF-8 JSON array."""
|
|
518
|
+
payload = {"actor": require_actor(args), "submission_id": args.submission_id}
|
|
519
|
+
question = text_field(args, "question")
|
|
520
|
+
if args.plan_file is not None:
|
|
521
|
+
if args.expert or question is not None:
|
|
522
|
+
raise CliError("--plan-file 不能与 --expert / --question 同时使用")
|
|
523
|
+
try:
|
|
524
|
+
turns = json.loads(read_input(args.plan_file))
|
|
525
|
+
except ValueError as error:
|
|
526
|
+
raise CliError(f"无效 JSON: {error}") from error
|
|
527
|
+
if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
|
|
528
|
+
raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
|
|
529
|
+
return {**payload, "turns": turns}
|
|
530
|
+
if not args.expert or question is None:
|
|
531
|
+
raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
|
|
532
|
+
return {**payload, "expert": args.expert, "question": question}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def plan_floor(args):
|
|
536
|
+
"""Send one versioned operation without silently claiming on a failed write."""
|
|
537
|
+
path = f"/api/rooms/{args.room}/floor"
|
|
538
|
+
if args.action == "view":
|
|
539
|
+
return RequestPlan(path)
|
|
540
|
+
actor = require_actor(args)
|
|
541
|
+
return RequestPlan(path, "POST", {"actor": actor, "speaker": args.speaker or actor,
|
|
542
|
+
"action": args.action, "revision": args.revision})
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def plan_state(args):
|
|
546
|
+
"""Keep a host summary's cutoff explicit instead of assuming latest means read."""
|
|
547
|
+
base = f"/api/rooms/{args.room}"
|
|
548
|
+
if args.action == "view":
|
|
549
|
+
return RequestPlan(base, view="state")
|
|
550
|
+
if args.until < 0:
|
|
551
|
+
raise CliError("--until 不能小于 0")
|
|
552
|
+
payload = {"actor": require_actor(args), "text": text_field(args, "body"), "until": args.until}
|
|
553
|
+
brief = text_field(args, "brief")
|
|
554
|
+
if brief is not None:
|
|
555
|
+
if len(brief.strip()) > MAX_BRIEF_LENGTH:
|
|
556
|
+
raise CliError(f"主持简报最多 {MAX_BRIEF_LENGTH} 个 Unicode 字符;不会自动截断")
|
|
557
|
+
payload["brief"] = brief
|
|
558
|
+
return RequestPlan(base + "/state", "POST", payload, "state")
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def plan_api(args):
|
|
562
|
+
"""Allow literal HTTP requests only under the selected site's API/read roots."""
|
|
563
|
+
path = urlsplit(args.path)
|
|
564
|
+
decoded = unquote(path.path)
|
|
565
|
+
if (path.scheme or path.netloc or path.fragment or "\\" in decoded
|
|
566
|
+
or not decoded.startswith(("/api/", "/r/")) or ".." in decoded.split("/")):
|
|
567
|
+
raise CliError("api 路径必须是本站 /api/... 或 /r/...,不能切换主机或包含 ..")
|
|
568
|
+
if args.method == "GET" and args.input:
|
|
569
|
+
raise CliError("GET 不接受 --input;查询条件直接写在路径中")
|
|
570
|
+
if args.method == "POST" and not args.input:
|
|
571
|
+
raise CliError("POST 需要 --input JSON文件(或 -)")
|
|
572
|
+
return RequestPlan(args.path, args.method, read_object(args.input) if args.input else None)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def plan_run(args):
|
|
576
|
+
"""Build one versioned lifecycle operation without starting a daemon."""
|
|
577
|
+
path = f"/api/rooms/{args.room}/runtime"
|
|
578
|
+
if args.action == "view":
|
|
579
|
+
return RequestPlan(path)
|
|
580
|
+
body = {"action": args.action, "revision": args.revision}
|
|
581
|
+
for key in ("mode", "deadline", "phase"):
|
|
582
|
+
if getattr(args, key, None) is not None:
|
|
583
|
+
body[key] = getattr(args, key)
|
|
584
|
+
return RequestPlan(path, "POST", body)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def plan_execution(args):
|
|
588
|
+
"""Use the bound actual session when acknowledging a delivered execution."""
|
|
589
|
+
body = {"action": args.action}
|
|
590
|
+
if args.action == "enter":
|
|
591
|
+
access = read_object(args.access_file) if getattr(args, "access_file", None) else {}
|
|
592
|
+
body["session_id"] = args.session_id or access.get("session_id")
|
|
593
|
+
if not body["session_id"]:
|
|
594
|
+
raise CliError("尚未取得实际 session_id,请先核对接入回执")
|
|
595
|
+
if args.action == "fail":
|
|
596
|
+
body["reason"] = args.reason
|
|
597
|
+
return RequestPlan(f"/api/rooms/{args.room}/events/{args.id}", "POST", body)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def build_plan(args):
|
|
601
|
+
"""Validate local input before constructing any network request."""
|
|
602
|
+
stdin_count = sum(value == "-" for key, value in vars(args).items()
|
|
603
|
+
if key.endswith("_file") or key == "input")
|
|
604
|
+
if stdin_count > 1:
|
|
605
|
+
raise CliError("一次命令只能有一个字段从标准输入读取")
|
|
606
|
+
handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
|
|
607
|
+
"attachment": plan_attachment,
|
|
608
|
+
"state": plan_state, "floor": plan_floor, "api": plan_api,
|
|
609
|
+
"run": plan_run, "execution": plan_execution}
|
|
610
|
+
plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
|
|
611
|
+
if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
|
|
612
|
+
raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
|
|
613
|
+
if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
|
|
614
|
+
raise CliError(f"请求体超过服务端限制 {MAX_BODY_BYTES} 字节")
|
|
615
|
+
return plan
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def resolve_site(args):
|
|
619
|
+
"""Resolve an explicit flag or environment URL without implicit production writes."""
|
|
620
|
+
site = getattr(args, "site", None) or os.getenv("DISCUSS_URL", "")
|
|
621
|
+
parsed = urlsplit(site)
|
|
622
|
+
if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
|
|
623
|
+
or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/")):
|
|
624
|
+
raise CliError("请用 --site 或 DISCUSS_URL 指定 http(s)://主机[:端口],不带路径、凭据或查询")
|
|
625
|
+
try:
|
|
626
|
+
parsed.port
|
|
627
|
+
except ValueError as error:
|
|
628
|
+
raise CliError(str(error)) from error
|
|
629
|
+
return site.rstrip("/")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def decode_response(response, plan):
|
|
633
|
+
"""Decode the expected payload while identifying uncertain write responses."""
|
|
634
|
+
if plan.download_file is not None:
|
|
635
|
+
return save_attachment_response(response, plan.download_file)
|
|
636
|
+
try:
|
|
637
|
+
text = response.read().decode("utf-8-sig")
|
|
638
|
+
if plan.path.startswith("/r/") and plan.method == "GET":
|
|
639
|
+
if "text/plain" not in response.headers.get("Content-Type", ""):
|
|
640
|
+
raise ValueError("预期纯文本响应")
|
|
641
|
+
return text
|
|
642
|
+
value = json.loads(text)
|
|
643
|
+
if not isinstance(value, dict):
|
|
644
|
+
raise ValueError("预期 JSON 对象")
|
|
645
|
+
return value
|
|
646
|
+
except (ValueError, UnicodeError) as error:
|
|
647
|
+
raise CliError("响应不是预期的 UTF-8 / JSON 内容", 3,
|
|
648
|
+
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def save_attachment_response(response, destination):
|
|
652
|
+
"""Validate a complete bounded response before creating an exclusive local file."""
|
|
653
|
+
data = response.read(MAX_ATTACHMENT_BYTES + 1)
|
|
654
|
+
size_header = response.headers.get("Content-Length")
|
|
655
|
+
if len(data) > MAX_ATTACHMENT_BYTES or (size_header and len(data) != int(size_header)):
|
|
656
|
+
raise CliError("附件下载不完整或超过 20 MiB;未保存文件", 3, outcome="unavailable")
|
|
657
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
658
|
+
if response.headers.get("X-Content-SHA256", digest) != digest:
|
|
659
|
+
raise CliError("附件校验失败;未保存文件", 3, outcome="unavailable")
|
|
660
|
+
path = Path(destination)
|
|
661
|
+
created = False
|
|
662
|
+
try:
|
|
663
|
+
with path.open("xb") as target:
|
|
664
|
+
created = True
|
|
665
|
+
target.write(data)
|
|
666
|
+
except OSError as error:
|
|
667
|
+
if created:
|
|
668
|
+
path.unlink(missing_ok=True)
|
|
669
|
+
raise CliError(f"无法保存附件;不会覆盖已有文件: {error}") from error
|
|
670
|
+
return {"path": str(path.resolve()), "size": len(data), "sha256": digest}
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def request_data(plan):
|
|
674
|
+
"""Load a bounded file for binary upload, or encode ordinary JSON requests."""
|
|
675
|
+
if plan.upload_file is not None:
|
|
676
|
+
try:
|
|
677
|
+
with Path(plan.upload_file).open("rb") as source:
|
|
678
|
+
data = source.read(MAX_ATTACHMENT_BYTES + 1)
|
|
679
|
+
except OSError as error:
|
|
680
|
+
raise CliError(f"无法读取上传文件: {error}") from error
|
|
681
|
+
if len(data) > MAX_ATTACHMENT_BYTES:
|
|
682
|
+
raise CliError("单个附件最多 20 MiB")
|
|
683
|
+
return data
|
|
684
|
+
return json.dumps(plan.body, ensure_ascii=False).encode("utf-8") if plan.body is not None else None
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def http_error(error, plan):
|
|
688
|
+
"""Retain useful API errors and distinguish rejected from uncertain writes."""
|
|
689
|
+
try:
|
|
690
|
+
body = error.read().decode("utf-8")
|
|
691
|
+
value = json.loads(body)
|
|
692
|
+
message = value.get("error", body) if isinstance(value, dict) else body
|
|
693
|
+
except (ValueError, UnicodeError, OSError, HTTPException):
|
|
694
|
+
message = f"HTTP {error.code}: {error.reason}"
|
|
695
|
+
uncertain = plan.method == "POST" and error.code >= 500
|
|
696
|
+
return CliError(str(message), 1, http_status=error.code,
|
|
697
|
+
outcome="unknown" if uncertain else "rejected")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def send_request(plan, url, timeout, headers=None):
|
|
701
|
+
"""Send exactly once, preserving TLS verification and bounded waiting."""
|
|
702
|
+
data = request_data(plan)
|
|
703
|
+
content_type = "application/octet-stream" if plan.upload_file is not None else "application/json"
|
|
704
|
+
request = Request(url, data=data, method=plan.method,
|
|
705
|
+
headers={"Content-Type": content_type, "Accept": "*/*" if plan.download_file else "application/json, text/plain",
|
|
706
|
+
"User-Agent": f"AI-CoDiscuss-CLI/{VERSION}", **(headers or {})})
|
|
707
|
+
try:
|
|
708
|
+
with build_opener(NoRedirect()).open(request, timeout=timeout) as response:
|
|
709
|
+
return decode_response(response, plan)
|
|
710
|
+
except HTTPError as error:
|
|
711
|
+
with error:
|
|
712
|
+
raise http_error(error, plan) from error
|
|
713
|
+
except (URLError, OSError, HTTPException) as error:
|
|
714
|
+
raise CliError(f"网络请求失败: {error}", 3,
|
|
715
|
+
outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def select_fields(value, names):
|
|
719
|
+
"""Project explicitly requested fields without expanding additional context."""
|
|
720
|
+
return {name: value[name] for name in names if name in value}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def project_result(value, plan, site):
|
|
724
|
+
"""Keep summaries, briefing material, host state, and original text separate."""
|
|
725
|
+
if plan.view == "room_list":
|
|
726
|
+
return {"rooms": [select_fields(room, ROOM_LIST_FIELDS) for room in value["rooms"]]}
|
|
727
|
+
if plan.view in ("overview", "brief", "state", "current"):
|
|
728
|
+
fields = {"overview": OVERVIEW_FIELDS, "brief": BRIEF_FIELDS,
|
|
729
|
+
"state": ("id", "state_text", "state_until", "host_brief", "latest_receipts", "answered_turns"),
|
|
730
|
+
"current": ("id", "phase", "current_turn", "queued_turns", "held_since", "floor", "answered_turns")}[plan.view]
|
|
731
|
+
result = select_fields(value, fields)
|
|
732
|
+
if plan.view in ("overview", "current"):
|
|
733
|
+
result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
|
|
734
|
+
result["url"] = f"{site}/r/{value['id']}"
|
|
735
|
+
return result
|
|
736
|
+
if plan.view == "prompt":
|
|
737
|
+
entry = f"{site}/r/{value['room_id']}?participant={value['expert']}"
|
|
738
|
+
prompt = f"请 {value['expert']} 回应:{value['question']}\n安排编号:{value['id']}\n按需阅读:{entry}\n回答附简短摘要及完整正文,保留关键条件和不确定性。"
|
|
739
|
+
return {"turn_id": value["id"], "status": value["status"], "prompt": prompt,
|
|
740
|
+
"delivery": "未发送;仅生成文字,实际通知后使用 turn mark-notified"}
|
|
741
|
+
return value
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def command_schema(args, registry):
|
|
745
|
+
"""Describe available commands and fields from the live argparse registry."""
|
|
746
|
+
name = " ".join(args.command)
|
|
747
|
+
if not name:
|
|
748
|
+
return {"version": VERSION, "commands": {key: item.description for key, item in registry.items()},
|
|
749
|
+
"exit_codes": EXIT_CODES, "site": "--site > DISCUSS_URL;无内置默认站点",
|
|
750
|
+
"output": "默认 JSON;成功 stdout,错误 stderr;无交互提示、无自动重试"}
|
|
751
|
+
if name not in registry:
|
|
752
|
+
raise CliError(f"未知命令 {name};运行 discuss schema 查看可用命令")
|
|
753
|
+
parser = registry[name]
|
|
754
|
+
arguments = []
|
|
755
|
+
for action in parser._actions:
|
|
756
|
+
if action.dest == "help":
|
|
757
|
+
continue
|
|
758
|
+
item = {"name": action.dest, "flags": action.option_strings,
|
|
759
|
+
"required": action.required, "help": action.help}
|
|
760
|
+
if action.dest == "actor" and name in ("room create", "room update", "message post",
|
|
761
|
+
"turn create", "turn mark-notified", "turn cancel", "state set", "attachment upload"):
|
|
762
|
+
item["required"] = True
|
|
763
|
+
if action.choices is not None:
|
|
764
|
+
item["choices"] = list(action.choices)
|
|
765
|
+
if action.default not in (argparse.SUPPRESS, None):
|
|
766
|
+
item["default"] = action.default
|
|
767
|
+
arguments.append(item)
|
|
768
|
+
groups = [{"one_of": [item.dest for item in group._group_actions], "required": group.required}
|
|
769
|
+
for group in parser._mutually_exclusive_groups]
|
|
770
|
+
return {"command": name, "description": parser.description, "arguments": arguments,
|
|
771
|
+
"exclusive_groups": groups, "scope": "CLI 参数约定;服务端协议见 room protocol"}
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def text_output(value):
|
|
775
|
+
"""Render readable text while retaining complete message bodies and IDs."""
|
|
776
|
+
if isinstance(value, str):
|
|
777
|
+
return value
|
|
778
|
+
if isinstance(value, dict):
|
|
779
|
+
lines = []
|
|
780
|
+
for key, item in value.items():
|
|
781
|
+
if isinstance(item, (dict, list)):
|
|
782
|
+
lines.append(f"{key}:\n{text_output(item)}")
|
|
783
|
+
else:
|
|
784
|
+
lines.append(f"{key}: {item if item is not None else '—'}")
|
|
785
|
+
return "\n".join(lines)
|
|
786
|
+
if isinstance(value, list):
|
|
787
|
+
return "\n\n".join(text_output(item) for item in value) or "(空)"
|
|
788
|
+
return str(value)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def emit(value, output="json", error=False):
|
|
792
|
+
"""Write the stable JSON envelope or human text to the correct stream."""
|
|
793
|
+
stream = sys.stderr if error else sys.stdout
|
|
794
|
+
if output == "json":
|
|
795
|
+
rendered = json.dumps(value, ensure_ascii=False, indent=2)
|
|
796
|
+
else:
|
|
797
|
+
rendered = text_output(value if error else value["data"])
|
|
798
|
+
stream.write(rendered + "\n")
|
|
799
|
+
stream.flush()
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def save_access(path, value):
|
|
803
|
+
"""Persist private capabilities before sending requests, without printing their content."""
|
|
804
|
+
target = Path(path)
|
|
805
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as stream:
|
|
806
|
+
temporary = Path(stream.name)
|
|
807
|
+
os.chmod(temporary, 0o600)
|
|
808
|
+
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
809
|
+
stream.flush()
|
|
810
|
+
os.fsync(stream.fileno())
|
|
811
|
+
try:
|
|
812
|
+
os.replace(temporary, target)
|
|
813
|
+
finally:
|
|
814
|
+
temporary.unlink(missing_ok=True)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def prepare_creation_access(args, site, plan):
|
|
818
|
+
"""Create or reuse one explicit private credential bundle for a stable creation id."""
|
|
819
|
+
if getattr(args, "operation", "") != "room create":
|
|
820
|
+
return
|
|
821
|
+
if not getattr(args, "access_file", None):
|
|
822
|
+
raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
|
|
823
|
+
path = Path(args.access_file)
|
|
824
|
+
payload = plan.body
|
|
825
|
+
access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
|
|
826
|
+
"submission_id": payload.get("submission_id"), "members": {}}
|
|
827
|
+
if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
|
|
828
|
+
raise CliError("建房凭据文件与本次站点或 submission_id 不符")
|
|
829
|
+
experts = payload.setdefault("experts", {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:]})
|
|
830
|
+
members = []
|
|
831
|
+
for name, capability in experts.items():
|
|
832
|
+
member = access["members"].setdefault(name, {"token": secrets.token_urlsafe(48)})
|
|
833
|
+
members.append({"name": name, "token": member["token"], "access": "online" if capability == "online" else "manual"})
|
|
834
|
+
payload["members"] = members
|
|
835
|
+
save_access(path, access)
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def finish_creation_access(args, site, plan, value):
|
|
839
|
+
"""Save the confirmed room and separate member files; return paths without secrets."""
|
|
840
|
+
if getattr(args, "operation", "") != "room create":
|
|
841
|
+
return {}
|
|
842
|
+
path = Path(args.access_file).resolve()
|
|
843
|
+
access = read_object(path)
|
|
844
|
+
access["room"] = value["id"]
|
|
845
|
+
save_access(path, access)
|
|
846
|
+
paths = {}
|
|
847
|
+
for member in plan.body["members"]:
|
|
848
|
+
if member["access"] == "online":
|
|
849
|
+
continue
|
|
850
|
+
member_path = path.with_name(f"{path.stem}.{member['name']}.json")
|
|
851
|
+
save_access(member_path, {"site": site, "room": value["id"], "token": member["token"]})
|
|
852
|
+
paths[member["name"]] = str(member_path)
|
|
853
|
+
return {"owner_access_file": str(path), "member_access_files": paths}
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def access_headers(args, site, path):
|
|
857
|
+
"""Load a room-scoped secret only for its exact site and room."""
|
|
858
|
+
if not getattr(args, "access_file", None):
|
|
859
|
+
return {}
|
|
860
|
+
access = read_object(args.access_file)
|
|
861
|
+
expected = f"/api/rooms/{access.get('room', '')}"
|
|
862
|
+
target = urlsplit(path).path
|
|
863
|
+
creation = target == "/api/rooms" and bool(access.get("submission_id"))
|
|
864
|
+
reading = f"/r/{access.get('room', '')}"
|
|
865
|
+
if access.get("site", "").rstrip("/") != site or not (creation or target == expected or target.startswith(expected + "/") or target == reading or target.startswith(reading + "/")):
|
|
866
|
+
raise CliError("凭据文件不属于本次站点和房间")
|
|
867
|
+
token = access.get("token")
|
|
868
|
+
if not isinstance(token, str) or not 32 <= len(token) <= 256:
|
|
869
|
+
raise CliError("凭据文件中的 token 无效")
|
|
870
|
+
return {"X-Discussion-Token": token}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def preview_body(value):
|
|
874
|
+
"""Keep private capability material out of offline request previews."""
|
|
875
|
+
if isinstance(value, dict):
|
|
876
|
+
return {key: "[redacted]" if key in ("token", "execution_token", "control_hash", "token_hash") else preview_body(item)
|
|
877
|
+
for key, item in value.items()}
|
|
878
|
+
if isinstance(value, list):
|
|
879
|
+
return [preview_body(item) for item in value]
|
|
880
|
+
return value
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def run(args, registry):
|
|
884
|
+
"""Execute a parsed command, with schema and previews remaining offline."""
|
|
885
|
+
if args.resource == "schema":
|
|
886
|
+
return {"ok": True, "data": command_schema(args, registry)}
|
|
887
|
+
plan = build_plan(args)
|
|
888
|
+
site = resolve_site(args)
|
|
889
|
+
timeout = getattr(args, "timeout", DEFAULT_TIMEOUT)
|
|
890
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
891
|
+
raise CliError("--timeout 必须为有限正数")
|
|
892
|
+
url = site + plan.path
|
|
893
|
+
if plan.params:
|
|
894
|
+
url += "?" + urlencode(plan.params)
|
|
895
|
+
meta = {"site": site, "method": plan.method, "path": plan.path}
|
|
896
|
+
if getattr(args, "dry_run", False):
|
|
897
|
+
return {"ok": True, "data": {"method": plan.method, "url": url, "body": preview_body(plan.body),
|
|
898
|
+
**({"file": plan.upload_file} if plan.upload_file else {}),
|
|
899
|
+
**({"destination": plan.download_file} if plan.download_file else {})},
|
|
900
|
+
"meta": {**meta, "dry_run": True, "validation": "仅本地参数;未验证房间、身份或服务端状态"}}
|
|
901
|
+
try:
|
|
902
|
+
prepare_creation_access(args, site, plan)
|
|
903
|
+
headers = access_headers(args, site, plan.path)
|
|
904
|
+
value = send_request(plan, url, timeout, headers) if headers else send_request(plan, url, timeout)
|
|
905
|
+
result = project_result(value, plan, site)
|
|
906
|
+
if getattr(args, "operation", "") == "room create":
|
|
907
|
+
result.update(finish_creation_access(args, site, plan, value))
|
|
908
|
+
except CliError as error:
|
|
909
|
+
error.details.update(meta)
|
|
910
|
+
if plan.method == "POST":
|
|
911
|
+
error.details["submission_id"] = (plan.body or plan.params or {}).get("submission_id")
|
|
912
|
+
error.details["hint"] = "未自动重试。创建结果未知时用对应 list --submission-id 查询;确认需要重试才沿用原编号和原始内容。"
|
|
913
|
+
raise
|
|
914
|
+
except (KeyError, TypeError) as error:
|
|
915
|
+
raise CliError("响应结构与 CLI 不兼容,请用 api 命令检查服务版本", 3,
|
|
916
|
+
**meta, outcome="unknown" if plan.method == "POST" else "unavailable") from error
|
|
917
|
+
return {"ok": True, "data": result, "meta": meta}
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main(argv=None):
|
|
921
|
+
"""Run the CLI without prompts; return documented process exit codes."""
|
|
922
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
923
|
+
if hasattr(stream, "reconfigure"):
|
|
924
|
+
stream.reconfigure(encoding="utf-8")
|
|
925
|
+
args = None
|
|
926
|
+
try:
|
|
927
|
+
parser, registry = build_parser(prog=os.getenv("DISCUSS_CLI_NAME", "discuss"))
|
|
928
|
+
args = parser.parse_args(argv)
|
|
929
|
+
emit(run(args, registry), getattr(args, "output", "json"))
|
|
930
|
+
return 0
|
|
931
|
+
except CliError as error:
|
|
932
|
+
output = getattr(args, "output", "json")
|
|
933
|
+
emit({"ok": False, "error": {"message": str(error), "exit_code": error.code, **error.details}},
|
|
934
|
+
output, error=True)
|
|
935
|
+
return error.code
|
|
936
|
+
except (ValueError, UnicodeError, OSError) as error:
|
|
937
|
+
emit({"ok": False, "error": {"message": str(error), "exit_code": 2}}, error=True)
|
|
938
|
+
return 2
|
|
939
|
+
except KeyboardInterrupt:
|
|
940
|
+
emit({"ok": False, "error": {"message": "已中断;若正在写入,请先读取记录核对结果。",
|
|
941
|
+
"exit_code": 130, "outcome": "unknown"}}, error=True)
|
|
942
|
+
return 130
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
if __name__ == "__main__":
|
|
946
|
+
sys.exit(main())
|