ai_co_discuss 0.2.34 → 0.2.35

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/runner/discuss.py CHANGED
@@ -1,970 +1,973 @@
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,再读凭据中的 site", **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 文件(- 读标准输入);未指定时读环境变量 DISCUSS_ACCESS 中的同样 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 或凭据文件中的 site;不保存当前房间,不自动重试写入。")
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 load_access(args):
366
- """Read the room credential from --access-file, else from the DISCUSS_ACCESS JSON text; empty when neither is given."""
367
- if getattr(args, "access_file", None):
368
- return read_object(args.access_file)
369
- text = os.getenv("DISCUSS_ACCESS", "").strip()
370
- if not text:
371
- return {}
372
- try:
373
- value = json.loads(text)
374
- except ValueError as error:
375
- raise CliError(f"DISCUSS_ACCESS 不是有效 JSON: {error}") from error
376
- if not isinstance(value, dict):
377
- raise CliError("DISCUSS_ACCESS 必须是 JSON 对象")
378
- return value
379
-
380
-
381
- def text_field(args, field):
382
- """Read one literal or file-backed field without altering its content."""
383
- values = vars(args)
384
- path = values.get(f"{field}_file")
385
- return read_input(path) if path is not None else values.get(field)
386
-
387
-
388
- def require_actor(args):
389
- """Require an explicit declared operator for domain writes."""
390
- actor = getattr(args, "actor", None)
391
- if not actor:
392
- raise CliError("写入必须显式指定 --as;该字段声明操作者,不提供身份认证")
393
- return actor
394
-
395
-
396
- def topic_payload(args):
397
- """Collect only supplied topic fields; expert configuration is a full map."""
398
- payload = {}
399
- limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes") if hasattr(args, key)}
400
- if limits:
401
- payload["limits"] = limits
402
- for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
403
- value = text_field(args, field)
404
- if value is not None:
405
- payload[field] = value
406
- for field in ("host", "phase"):
407
- if hasattr(args, field):
408
- payload[field] = getattr(args, field)
409
- if hasattr(args, "experts_file"):
410
- experts = read_object(args.experts_file)
411
- if any(not INSTANCE_PATTERN.fullmatch(name) or mode not in ("cli", "online")
412
- for name, mode in experts.items()):
413
- raise CliError("专家映射的名称必须是有效 AI,模式必须是 cli 或 online")
414
- payload["experts"] = experts
415
- return payload
416
-
417
-
418
- def plan_room(args):
419
- """Translate room operations to one existing HTTP endpoint."""
420
- if args.action == "list":
421
- params = {"submission_id": args.submission_id} if args.submission_id else None
422
- return RequestPlan("/api/rooms", view="room_list", params=params)
423
- if args.action == "create":
424
- payload = topic_payload(args)
425
- payload.setdefault("host", require_actor(args))
426
- payload["submission_id"] = args.submission_id
427
- if args.folder_id is not None:
428
- payload["folder_id"] = args.folder_id
429
- return RequestPlan("/api/rooms", "POST", payload, "overview")
430
- base = f"/api/rooms/{args.room}"
431
- if args.action == "limits":
432
- return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args)}, "overview") if args.check else RequestPlan(base + "/limits")
433
- if args.action == "update":
434
- payload = topic_payload(args)
435
- if not payload:
436
- raise CliError("room update 至少需要一个话题字段")
437
- payload["actor"] = require_actor(args)
438
- return RequestPlan(base + "/topic", "POST", payload, "overview")
439
- if args.action == "protocol":
440
- return RequestPlan(f"/r/{args.room}/protocol")
441
- return RequestPlan(base, view="brief" if args.action == "brief" else "overview")
442
-
443
-
444
- def plan_message(args):
445
- """Preserve operator, original author, message role, and replay identifier."""
446
- base = f"/api/rooms/{args.room}/messages"
447
- if args.action == "view":
448
- return RequestPlan(f"{base}/{args.id}")
449
- if args.action == "list":
450
- params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
451
- "after": args.after, "kind": args.kind, "submission_id": args.submission_id}
452
- return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
453
- actor = require_actor(args)
454
- if args.turn and args.kind != "opinion":
455
- raise CliError("只有 opinion 可以关联 --turn;receipt 不完成安排")
456
- if (args.execution or args.request or args.to) and (args.kind != "chat" or args.turn):
457
- raise CliError("--execution / --request 仅用于自由房间的 chat 回答,不能与 --turn 同时使用")
458
- if args.execution and args.request:
459
- raise CliError("--execution 用于接入程序交付的执行,--request 用于手动接入成员,二者不能同用")
460
- payload = {"author": args.author or actor, "submitted_by": actor, "source": "cli",
461
- "kind": args.kind, "text": text_field(args, "body") or "", "submission_id": args.submission_id}
462
- if len(args.attachment) > MAX_MESSAGE_ATTACHMENTS or len(set(args.attachment)) != len(args.attachment):
463
- raise CliError("每条消息最多 10 个附件,不能重复")
464
- if args.attachment:
465
- payload["attachment_ids"] = args.attachment
466
- if (not payload["text"].strip() and not args.attachment) or len(payload["text"].strip()) > MAX_TEXT_LENGTH:
467
- raise CliError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 个 Unicode 字符;不会自动截断或分段")
468
- summary = text_field(args, "summary")
469
- if summary is not None:
470
- if len(summary.strip()) > MAX_SUMMARY_LENGTH:
471
- raise CliError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 个 Unicode 字符;不会自动截断")
472
- payload["summary"] = summary
473
- if args.summary_by:
474
- payload["summary_by"] = args.summary_by
475
- if args.turn:
476
- payload["turn_id"] = args.turn
477
- if args.execution:
478
- payload["execution_id"] = args.execution
479
- if args.request:
480
- payload["request_ids"] = list(dict.fromkeys(args.request))
481
- if args.to:
482
- payload["to"] = list(dict.fromkeys(args.to))
483
- if args.reply_to:
484
- payload["reply_to"] = args.reply_to
485
- # A hosting Claude Code session exposes its own ID; the room records it so the local service can take the member over.
486
- session_id = os.getenv("CLAUDE_CODE_SESSION_ID", "").strip()
487
- if session_id:
488
- payload["session_id"] = session_id
489
- payload["session_client"] = "claude"
490
- return RequestPlan(base, "POST", payload)
491
-
492
-
493
- def plan_attachment(args):
494
- """Keep file bytes outside JSON messages and all storage targets explicit."""
495
- base = f"/api/rooms/{args.room}/attachments"
496
- if args.action == "list":
497
- params = {"page": args.page, "message_id": args.message, "submission_id": args.submission_id}
498
- return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
499
- if args.action == "upload":
500
- path = Path(args.file)
501
- try:
502
- if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
503
- raise CliError("上传路径须为文件,大小最多 20 MiB")
504
- except OSError as error:
505
- raise CliError(f"无法读取上传文件: {error}") from error
506
- metadata = {"actor": require_actor(args), "file_name": path.name, "submission_id": args.submission_id}
507
- return RequestPlan(base, "POST", params=metadata, upload_file=str(path))
508
- if args.action == "download":
509
- destination = Path(args.dest)
510
- if destination.exists() or not destination.parent.is_dir():
511
- raise CliError("--dest 须为不存在的文件路径,且父目录已存在;不会覆盖文件")
512
- return RequestPlan(f"{base}/{args.id}/content", params={"download": "1"}, download_file=str(destination))
513
- return RequestPlan(f"{base}/{args.id}")
514
-
515
-
516
- def plan_turn(args):
517
- """Separate creating an assignment from external notification delivery."""
518
- base = f"/api/rooms/{args.room}"
519
- if args.action == "current":
520
- return RequestPlan(base, view="current")
521
- if args.action == "list":
522
- params = {"page": args.page}
523
- if args.submission_id:
524
- params["submission_id"] = args.submission_id
525
- return RequestPlan(base + "/turns", params=params)
526
- if args.action == "create":
527
- return RequestPlan(base + "/turns", "POST", turn_payload(args))
528
- if args.action == "continue":
529
- return RequestPlan(base + "/turns/continue", "POST", {"actor": require_actor(args)})
530
- path = f"{base}/turns/{args.id}"
531
- if args.action in ("mark-notified", "cancel"):
532
- status = "notified" if args.action == "mark-notified" else "cancelled"
533
- return RequestPlan(path, "POST", {"actor": require_actor(args), "status": status})
534
- return RequestPlan(path, view="prompt" if args.action == "prompt" else None)
535
-
536
-
537
- def turn_payload(args):
538
- """Build one arrangement or an ordered round plan from a UTF-8 JSON array."""
539
- payload = {"actor": require_actor(args), "submission_id": args.submission_id}
540
- question = text_field(args, "question")
541
- if args.plan_file is not None:
542
- if args.expert or question is not None:
543
- raise CliError("--plan-file 不能与 --expert / --question 同时使用")
544
- try:
545
- turns = json.loads(read_input(args.plan_file))
546
- except ValueError as error:
547
- raise CliError(f"无效 JSON: {error}") from error
548
- if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
549
- raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
550
- return {**payload, "turns": turns}
551
- if not args.expert or question is None:
552
- raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
553
- return {**payload, "expert": args.expert, "question": question}
554
-
555
-
556
- def plan_floor(args):
557
- """Send one versioned operation without silently claiming on a failed write."""
558
- path = f"/api/rooms/{args.room}/floor"
559
- if args.action == "view":
560
- return RequestPlan(path)
561
- actor = require_actor(args)
562
- return RequestPlan(path, "POST", {"actor": actor, "speaker": args.speaker or actor,
563
- "action": args.action, "revision": args.revision})
564
-
565
-
566
- def plan_state(args):
567
- """Keep a host summary's cutoff explicit instead of assuming latest means read."""
568
- base = f"/api/rooms/{args.room}"
569
- if args.action == "view":
570
- return RequestPlan(base, view="state")
571
- if args.until < 0:
572
- raise CliError("--until 不能小于 0")
573
- payload = {"actor": require_actor(args), "text": text_field(args, "body"), "until": args.until}
574
- brief = text_field(args, "brief")
575
- if brief is not None:
576
- if len(brief.strip()) > MAX_BRIEF_LENGTH:
577
- raise CliError(f"主持简报最多 {MAX_BRIEF_LENGTH} 个 Unicode 字符;不会自动截断")
578
- payload["brief"] = brief
579
- return RequestPlan(base + "/state", "POST", payload, "state")
580
-
581
-
582
- def plan_api(args):
583
- """Allow literal HTTP requests only under the selected site's API/read roots."""
584
- path = urlsplit(args.path)
585
- decoded = unquote(path.path)
586
- if (path.scheme or path.netloc or path.fragment or "\\" in decoded
587
- or not decoded.startswith(("/api/", "/r/")) or ".." in decoded.split("/")):
588
- raise CliError("api 路径必须是本站 /api/... 或 /r/...,不能切换主机或包含 ..")
589
- if args.method == "GET" and args.input:
590
- raise CliError("GET 不接受 --input;查询条件直接写在路径中")
591
- if args.method == "POST" and not args.input:
592
- raise CliError("POST 需要 --input JSON文件(或 -)")
593
- return RequestPlan(args.path, args.method, read_object(args.input) if args.input else None)
594
-
595
-
596
- def plan_run(args):
597
- """Build one versioned lifecycle operation without starting a daemon."""
598
- path = f"/api/rooms/{args.room}/runtime"
599
- if args.action == "view":
600
- return RequestPlan(path)
601
- body = {"action": args.action, "revision": args.revision}
602
- for key in ("mode", "deadline", "phase"):
603
- if getattr(args, key, None) is not None:
604
- body[key] = getattr(args, key)
605
- return RequestPlan(path, "POST", body)
606
-
607
-
608
- def plan_execution(args):
609
- """Use the bound actual session when acknowledging a delivered execution."""
610
- body = {"action": args.action}
611
- if args.action == "enter":
612
- access = load_access(args)
613
- body["session_id"] = args.session_id or access.get("session_id")
614
- if not body["session_id"]:
615
- raise CliError("尚未取得实际 session_id,请先核对接入回执")
616
- if args.action == "fail":
617
- body["reason"] = args.reason
618
- return RequestPlan(f"/api/rooms/{args.room}/events/{args.id}", "POST", body)
619
-
620
-
621
- def build_plan(args):
622
- """Validate local input before constructing any network request."""
623
- stdin_count = sum(value == "-" for key, value in vars(args).items()
624
- if key.endswith("_file") or key == "input")
625
- if stdin_count > 1:
626
- raise CliError("一次命令只能有一个字段从标准输入读取")
627
- handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
628
- "attachment": plan_attachment,
629
- "state": plan_state, "floor": plan_floor, "api": plan_api,
630
- "run": plan_run, "execution": plan_execution}
631
- plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
632
- if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
633
- raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
634
- if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
635
- raise CliError(f"请求体超过服务端限制 {MAX_BODY_BYTES} 字节")
636
- return plan
637
-
638
-
639
- def resolve_site(args):
640
- """Resolve the flag, then the environment, then the site the access file is bound to; never a built-in default."""
641
- site = getattr(args, "site", None) or os.getenv("DISCUSS_URL", "")
642
- if not site:
643
- # The credential's own site is the only place it is ever sent back to.
644
- site = str(load_access(args).get("site", ""))
645
- parsed = urlsplit(site)
646
- if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
647
- or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/")):
648
- raise CliError("请用 --site、DISCUSS_URL 或凭据(--access-file / DISCUSS_ACCESS)指定 http(s)://主机[:端口],不带路径、凭据或查询")
649
- try:
650
- parsed.port
651
- except ValueError as error:
652
- raise CliError(str(error)) from error
653
- return site.rstrip("/")
654
-
655
-
656
- def decode_response(response, plan):
657
- """Decode the expected payload while identifying uncertain write responses."""
658
- if plan.download_file is not None:
659
- return save_attachment_response(response, plan.download_file)
660
- try:
661
- text = response.read().decode("utf-8-sig")
662
- if plan.path.startswith("/r/") and plan.method == "GET":
663
- if "text/plain" not in response.headers.get("Content-Type", ""):
664
- raise ValueError("预期纯文本响应")
665
- return text
666
- value = json.loads(text)
667
- if not isinstance(value, dict):
668
- raise ValueError("预期 JSON 对象")
669
- return value
670
- except (ValueError, UnicodeError) as error:
671
- raise CliError("响应不是预期的 UTF-8 / JSON 内容", 3,
672
- outcome="unknown" if plan.method == "POST" else "unavailable") from error
673
-
674
-
675
- def save_attachment_response(response, destination):
676
- """Validate a complete bounded response before creating an exclusive local file."""
677
- data = response.read(MAX_ATTACHMENT_BYTES + 1)
678
- size_header = response.headers.get("Content-Length")
679
- if len(data) > MAX_ATTACHMENT_BYTES or (size_header and len(data) != int(size_header)):
680
- raise CliError("附件下载不完整或超过 20 MiB;未保存文件", 3, outcome="unavailable")
681
- digest = hashlib.sha256(data).hexdigest()
682
- if response.headers.get("X-Content-SHA256", digest) != digest:
683
- raise CliError("附件校验失败;未保存文件", 3, outcome="unavailable")
684
- path = Path(destination)
685
- created = False
686
- try:
687
- with path.open("xb") as target:
688
- created = True
689
- target.write(data)
690
- except OSError as error:
691
- if created:
692
- path.unlink(missing_ok=True)
693
- raise CliError(f"无法保存附件;不会覆盖已有文件: {error}") from error
694
- return {"path": str(path.resolve()), "size": len(data), "sha256": digest}
695
-
696
-
697
- def request_data(plan):
698
- """Load a bounded file for binary upload, or encode ordinary JSON requests."""
699
- if plan.upload_file is not None:
700
- try:
701
- with Path(plan.upload_file).open("rb") as source:
702
- data = source.read(MAX_ATTACHMENT_BYTES + 1)
703
- except OSError as error:
704
- raise CliError(f"无法读取上传文件: {error}") from error
705
- if len(data) > MAX_ATTACHMENT_BYTES:
706
- raise CliError("单个附件最多 20 MiB")
707
- return data
708
- return json.dumps(plan.body, ensure_ascii=False).encode("utf-8") if plan.body is not None else None
709
-
710
-
711
- def http_error(error, plan):
712
- """Retain useful API errors and distinguish rejected from uncertain writes."""
713
- try:
714
- body = error.read().decode("utf-8")
715
- value = json.loads(body)
716
- message = value.get("error", body) if isinstance(value, dict) else body
717
- except (ValueError, UnicodeError, OSError, HTTPException):
718
- message = f"HTTP {error.code}: {error.reason}"
719
- uncertain = plan.method == "POST" and error.code >= 500
720
- return CliError(str(message), 1, http_status=error.code,
721
- outcome="unknown" if uncertain else "rejected")
722
-
723
-
724
- def send_request(plan, url, timeout, headers=None):
725
- """Send exactly once, preserving TLS verification and bounded waiting."""
726
- data = request_data(plan)
727
- content_type = "application/octet-stream" if plan.upload_file is not None else "application/json"
728
- request = Request(url, data=data, method=plan.method,
729
- headers={"Content-Type": content_type, "Accept": "*/*" if plan.download_file else "application/json, text/plain",
730
- "User-Agent": f"AI-CoDiscuss-CLI/{VERSION}", **(headers or {})})
731
- try:
732
- with build_opener(NoRedirect()).open(request, timeout=timeout) as response:
733
- return decode_response(response, plan)
734
- except HTTPError as error:
735
- with error:
736
- raise http_error(error, plan) from error
737
- except (URLError, OSError, HTTPException) as error:
738
- raise CliError(f"网络请求失败: {error}", 3,
739
- outcome="unknown" if plan.method == "POST" else "unavailable") from error
740
-
741
-
742
- def select_fields(value, names):
743
- """Project explicitly requested fields without expanding additional context."""
744
- return {name: value[name] for name in names if name in value}
745
-
746
-
747
- def project_result(value, plan, site):
748
- """Keep summaries, briefing material, host state, and original text separate."""
749
- if plan.view == "room_list":
750
- return {"rooms": [select_fields(room, ROOM_LIST_FIELDS) for room in value["rooms"]]}
751
- if plan.view in ("overview", "brief", "state", "current"):
752
- fields = {"overview": OVERVIEW_FIELDS, "brief": BRIEF_FIELDS,
753
- "state": ("id", "state_text", "state_until", "host_brief", "latest_receipts", "answered_turns"),
754
- "current": ("id", "phase", "current_turn", "queued_turns", "held_since", "floor", "answered_turns")}[plan.view]
755
- result = select_fields(value, fields)
756
- if plan.view in ("overview", "current"):
757
- result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
758
- result["url"] = f"{site}/r/{value['id']}"
759
- return result
760
- if plan.view == "prompt":
761
- entry = f"{site}/r/{value['room_id']}?participant={value['expert']}"
762
- prompt = f"请 {value['expert']} 回应:{value['question']}\n安排编号:{value['id']}\n按需阅读:{entry}\n回答附简短摘要及完整正文,保留关键条件和不确定性。"
763
- return {"turn_id": value["id"], "status": value["status"], "prompt": prompt,
764
- "delivery": "未发送;仅生成文字,实际通知后使用 turn mark-notified"}
765
- return value
766
-
767
-
768
- def command_schema(args, registry):
769
- """Describe available commands and fields from the live argparse registry."""
770
- name = " ".join(args.command)
771
- if not name:
772
- return {"version": VERSION, "commands": {key: item.description for key, item in registry.items()},
773
- "exit_codes": EXIT_CODES, "site": "--site > DISCUSS_URL > 凭据(--access-file 或 DISCUSS_ACCESS)中的 site;无内置默认站点",
774
- "output": "默认 JSON;成功 stdout,错误 stderr;无交互提示、无自动重试"}
775
- if name not in registry:
776
- raise CliError(f"未知命令 {name};运行 discuss schema 查看可用命令")
777
- parser = registry[name]
778
- arguments = []
779
- for action in parser._actions:
780
- if action.dest == "help":
781
- continue
782
- item = {"name": action.dest, "flags": action.option_strings,
783
- "required": action.required, "help": action.help}
784
- if action.dest == "actor" and name in ("room create", "room update", "message post",
785
- "turn create", "turn mark-notified", "turn cancel", "state set", "attachment upload"):
786
- item["required"] = True
787
- if action.choices is not None:
788
- item["choices"] = list(action.choices)
789
- if action.default not in (argparse.SUPPRESS, None):
790
- item["default"] = action.default
791
- arguments.append(item)
792
- groups = [{"one_of": [item.dest for item in group._group_actions], "required": group.required}
793
- for group in parser._mutually_exclusive_groups]
794
- return {"command": name, "description": parser.description, "arguments": arguments,
795
- "exclusive_groups": groups, "scope": "CLI 参数约定;服务端协议见 room protocol"}
796
-
797
-
798
- def text_output(value):
799
- """Render readable text while retaining complete message bodies and IDs."""
800
- if isinstance(value, str):
801
- return value
802
- if isinstance(value, dict):
803
- lines = []
804
- for key, item in value.items():
805
- if isinstance(item, (dict, list)):
806
- lines.append(f"{key}:\n{text_output(item)}")
807
- else:
808
- lines.append(f"{key}: {item if item is not None else '—'}")
809
- return "\n".join(lines)
810
- if isinstance(value, list):
811
- return "\n\n".join(text_output(item) for item in value) or "(空)"
812
- return str(value)
813
-
814
-
815
- def emit(value, output="json", error=False):
816
- """Write the stable JSON envelope or human text to the correct stream."""
817
- stream = sys.stderr if error else sys.stdout
818
- if output == "json":
819
- rendered = json.dumps(value, ensure_ascii=False, indent=2)
820
- else:
821
- rendered = text_output(value if error else value["data"])
822
- stream.write(rendered + "\n")
823
- stream.flush()
824
-
825
-
826
- def save_access(path, value):
827
- """Persist private capabilities before sending requests, without printing their content."""
828
- target = Path(path)
829
- with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as stream:
830
- temporary = Path(stream.name)
831
- os.chmod(temporary, 0o600)
832
- json.dump(value, stream, ensure_ascii=False, indent=2)
833
- stream.flush()
834
- os.fsync(stream.fileno())
835
- try:
836
- os.replace(temporary, target)
837
- finally:
838
- temporary.unlink(missing_ok=True)
839
-
840
-
841
- def prepare_creation_access(args, site, plan):
842
- """Create or reuse one explicit private credential bundle for a stable creation id."""
843
- if getattr(args, "operation", "") != "room create":
844
- return
845
- if not getattr(args, "access_file", None):
846
- raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
847
- path = Path(args.access_file)
848
- payload = plan.body
849
- access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
850
- "submission_id": payload.get("submission_id"), "members": {}}
851
- if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
852
- raise CliError("建房凭据文件与本次站点或 submission_id 不符")
853
- experts = payload.setdefault("experts", {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:]})
854
- members = []
855
- for name, capability in experts.items():
856
- member = access["members"].setdefault(name, {"token": secrets.token_urlsafe(48)})
857
- members.append({"name": name, "token": member["token"], "access": "online" if capability == "online" else "manual"})
858
- payload["members"] = members
859
- save_access(path, access)
860
-
861
-
862
- def finish_creation_access(args, site, plan, value):
863
- """Save the confirmed room and separate member files; return paths without secrets."""
864
- if getattr(args, "operation", "") != "room create":
865
- return {}
866
- path = Path(args.access_file).resolve()
867
- access = read_object(path)
868
- access["room"] = value["id"]
869
- save_access(path, access)
870
- paths = {}
871
- for member in plan.body["members"]:
872
- if member["access"] == "online":
873
- continue
874
- member_path = path.with_name(f"{path.stem}.{member['name']}.json")
875
- save_access(member_path, {"site": site, "room": value["id"], "token": member["token"]})
876
- paths[member["name"]] = str(member_path)
877
- return {"owner_access_file": str(path), "member_access_files": paths}
878
-
879
-
880
- def access_headers(args, site, path):
881
- """Load a room-scoped secret only for its exact site and room."""
882
- access = load_access(args)
883
- if not access:
884
- return {}
885
- expected = f"/api/rooms/{access.get('room', '')}"
886
- target = urlsplit(path).path
887
- creation = target == "/api/rooms" and bool(access.get("submission_id"))
888
- reading = f"/r/{access.get('room', '')}"
889
- if access.get("site", "").rstrip("/") != site or not (creation or target == expected or target.startswith(expected + "/") or target == reading or target.startswith(reading + "/")):
890
- raise CliError("凭据文件不属于本次站点和房间")
891
- token = access.get("token")
892
- if not isinstance(token, str) or not 32 <= len(token) <= 256:
893
- raise CliError("凭据文件中的 token 无效")
894
- return {"X-Discussion-Token": token}
895
-
896
-
897
- def preview_body(value):
898
- """Keep private capability material out of offline request previews."""
899
- if isinstance(value, dict):
900
- return {key: "[redacted]" if key in ("token", "execution_token", "control_hash", "token_hash") else preview_body(item)
901
- for key, item in value.items()}
902
- if isinstance(value, list):
903
- return [preview_body(item) for item in value]
904
- return value
905
-
906
-
907
- def run(args, registry):
908
- """Execute a parsed command, with schema and previews remaining offline."""
909
- if args.resource == "schema":
910
- return {"ok": True, "data": command_schema(args, registry)}
911
- plan = build_plan(args)
912
- site = resolve_site(args)
913
- timeout = getattr(args, "timeout", DEFAULT_TIMEOUT)
914
- if not math.isfinite(timeout) or timeout <= 0:
915
- raise CliError("--timeout 必须为有限正数")
916
- url = site + plan.path
917
- if plan.params:
918
- url += "?" + urlencode(plan.params)
919
- meta = {"site": site, "method": plan.method, "path": plan.path}
920
- if getattr(args, "dry_run", False):
921
- return {"ok": True, "data": {"method": plan.method, "url": url, "body": preview_body(plan.body),
922
- **({"file": plan.upload_file} if plan.upload_file else {}),
923
- **({"destination": plan.download_file} if plan.download_file else {})},
924
- "meta": {**meta, "dry_run": True, "validation": "仅本地参数;未验证房间、身份或服务端状态"}}
925
- try:
926
- prepare_creation_access(args, site, plan)
927
- headers = access_headers(args, site, plan.path)
928
- value = send_request(plan, url, timeout, headers) if headers else send_request(plan, url, timeout)
929
- result = project_result(value, plan, site)
930
- if getattr(args, "operation", "") == "room create":
931
- result.update(finish_creation_access(args, site, plan, value))
932
- except CliError as error:
933
- error.details.update(meta)
934
- if plan.method == "POST":
935
- error.details["submission_id"] = (plan.body or plan.params or {}).get("submission_id")
936
- error.details["hint"] = "未自动重试。创建结果未知时用对应 list --submission-id 查询;确认需要重试才沿用原编号和原始内容。"
937
- raise
938
- except (KeyError, TypeError) as error:
939
- raise CliError("响应结构与 CLI 不兼容,请用 api 命令检查服务版本", 3,
940
- **meta, outcome="unknown" if plan.method == "POST" else "unavailable") from error
941
- return {"ok": True, "data": result, "meta": meta}
942
-
943
-
944
- def main(argv=None):
945
- """Run the CLI without prompts; return documented process exit codes."""
946
- for stream in (sys.stdin, sys.stdout, sys.stderr):
947
- if hasattr(stream, "reconfigure"):
948
- stream.reconfigure(encoding="utf-8")
949
- args = None
950
- try:
951
- parser, registry = build_parser(prog=os.getenv("DISCUSS_CLI_NAME", "discuss"))
952
- args = parser.parse_args(argv)
953
- emit(run(args, registry), getattr(args, "output", "json"))
954
- return 0
955
- except CliError as error:
956
- output = getattr(args, "output", "json")
957
- emit({"ok": False, "error": {"message": str(error), "exit_code": error.code, **error.details}},
958
- output, error=True)
959
- return error.code
960
- except (ValueError, UnicodeError, OSError) as error:
961
- emit({"ok": False, "error": {"message": str(error), "exit_code": 2}}, error=True)
962
- return 2
963
- except KeyboardInterrupt:
964
- emit({"ok": False, "error": {"message": "已中断;若正在写入,请先读取记录核对结果。",
965
- "exit_code": 130, "outcome": "unknown"}}, error=True)
966
- return 130
967
-
968
-
969
- if __name__ == "__main__":
970
- 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,再读凭据中的 site", **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 文件(- 读标准输入);未指定时依次读取 DISCUSS_ACCESS_FILE 文件、DISCUSS_ACCESS 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 或凭据文件中的 site;不保存当前房间,不自动重试写入。")
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 load_access(args):
366
+ """Read an explicit file, a process-bound file, or inline credentials, in that order."""
367
+ path = getattr(args, "access_file", None) or os.getenv("DISCUSS_ACCESS_FILE", "").strip()
368
+ if path:
369
+ return read_object(path)
370
+ text = os.getenv("DISCUSS_ACCESS", "").strip()
371
+ if not text:
372
+ return {}
373
+ try:
374
+ value = json.loads(text)
375
+ except ValueError as error:
376
+ raise CliError(f"DISCUSS_ACCESS 不是有效 JSON: {error}") from error
377
+ if not isinstance(value, dict):
378
+ raise CliError("DISCUSS_ACCESS 必须是 JSON 对象")
379
+ return value
380
+
381
+
382
+ def text_field(args, field):
383
+ """Read one literal or file-backed field without altering its content."""
384
+ values = vars(args)
385
+ path = values.get(f"{field}_file")
386
+ return read_input(path) if path is not None else values.get(field)
387
+
388
+
389
+ def require_actor(args):
390
+ """Require an explicit declared operator for domain writes."""
391
+ actor = getattr(args, "actor", None)
392
+ if not actor:
393
+ raise CliError("写入必须显式指定 --as;该字段声明操作者,不提供身份认证")
394
+ return actor
395
+
396
+
397
+ def topic_payload(args):
398
+ """Collect only supplied topic fields; expert configuration is a full map."""
399
+ payload = {}
400
+ limits = {key: getattr(args, key) for key in ("max_rounds", "max_minutes", "execution_minutes", "on_limit", "summary_minutes") if hasattr(args, key)}
401
+ if limits:
402
+ payload["limits"] = limits
403
+ for field in ("title", "goal", "user_request", "background", "boundaries", "focus"):
404
+ value = text_field(args, field)
405
+ if value is not None:
406
+ payload[field] = value
407
+ for field in ("host", "phase"):
408
+ if hasattr(args, field):
409
+ payload[field] = getattr(args, field)
410
+ if hasattr(args, "experts_file"):
411
+ experts = read_object(args.experts_file)
412
+ if any(not INSTANCE_PATTERN.fullmatch(name) or mode not in ("cli", "online")
413
+ for name, mode in experts.items()):
414
+ raise CliError("专家映射的名称必须是有效 AI,模式必须是 cli 或 online")
415
+ payload["experts"] = experts
416
+ return payload
417
+
418
+
419
+ def plan_room(args):
420
+ """Translate room operations to one existing HTTP endpoint."""
421
+ if args.action == "list":
422
+ params = {"submission_id": args.submission_id} if args.submission_id else None
423
+ return RequestPlan("/api/rooms", view="room_list", params=params)
424
+ if args.action == "create":
425
+ payload = topic_payload(args)
426
+ payload.setdefault("host", require_actor(args))
427
+ payload["submission_id"] = args.submission_id
428
+ if args.folder_id is not None:
429
+ payload["folder_id"] = args.folder_id
430
+ return RequestPlan("/api/rooms", "POST", payload, "overview")
431
+ base = f"/api/rooms/{args.room}"
432
+ if args.action == "limits":
433
+ return RequestPlan(base + "/limits", "POST", {"actor": require_actor(args)}, "overview") if args.check else RequestPlan(base + "/limits")
434
+ if args.action == "update":
435
+ payload = topic_payload(args)
436
+ if not payload:
437
+ raise CliError("room update 至少需要一个话题字段")
438
+ payload["actor"] = require_actor(args)
439
+ return RequestPlan(base + "/topic", "POST", payload, "overview")
440
+ if args.action == "protocol":
441
+ return RequestPlan(f"/r/{args.room}/protocol")
442
+ return RequestPlan(base, view="brief" if args.action == "brief" else "overview")
443
+
444
+
445
+ def plan_message(args):
446
+ """Preserve operator, original author, message role, and replay identifier."""
447
+ base = f"/api/rooms/{args.room}/messages"
448
+ if args.action == "view":
449
+ return RequestPlan(f"{base}/{args.id}")
450
+ if args.action == "list":
451
+ params = {"page": args.page, "q": args.query, "author": args.author, "mention": args.mention,
452
+ "after": args.after, "kind": args.kind, "submission_id": args.submission_id}
453
+ return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
454
+ actor = require_actor(args)
455
+ if args.turn and args.kind != "opinion":
456
+ raise CliError("只有 opinion 可以关联 --turn;receipt 不完成安排")
457
+ if (args.execution or args.request or args.to) and (args.kind != "chat" or args.turn):
458
+ raise CliError("--execution / --request 仅用于自由房间的 chat 回答,不能与 --turn 同时使用")
459
+ if args.execution and args.request:
460
+ raise CliError("--execution 用于接入程序交付的执行,--request 用于手动接入成员,二者不能同用")
461
+ payload = {"author": args.author or actor, "submitted_by": actor, "source": "cli",
462
+ "kind": args.kind, "text": text_field(args, "body") or "", "submission_id": args.submission_id}
463
+ if len(args.attachment) > MAX_MESSAGE_ATTACHMENTS or len(set(args.attachment)) != len(args.attachment):
464
+ raise CliError("每条消息最多 10 个附件,不能重复")
465
+ if args.attachment:
466
+ payload["attachment_ids"] = args.attachment
467
+ if (not payload["text"].strip() and not args.attachment) or len(payload["text"].strip()) > MAX_TEXT_LENGTH:
468
+ raise CliError(f"发言需包含正文或附件,正文最多 {MAX_TEXT_LENGTH} 个 Unicode 字符;不会自动截断或分段")
469
+ summary = text_field(args, "summary")
470
+ if summary is not None:
471
+ if len(summary.strip()) > MAX_SUMMARY_LENGTH:
472
+ raise CliError(f"回答摘要最多 {MAX_SUMMARY_LENGTH} 个 Unicode 字符;不会自动截断")
473
+ payload["summary"] = summary
474
+ if args.summary_by:
475
+ payload["summary_by"] = args.summary_by
476
+ if args.turn:
477
+ payload["turn_id"] = args.turn
478
+ if args.execution:
479
+ payload["execution_id"] = args.execution
480
+ if args.request:
481
+ payload["request_ids"] = list(dict.fromkeys(args.request))
482
+ if args.to:
483
+ payload["to"] = list(dict.fromkeys(args.to))
484
+ if args.reply_to:
485
+ payload["reply_to"] = args.reply_to
486
+ # A hosting Claude Code or Codex session exposes its own ID; the room records it so the local service can take the member over.
487
+ for variable, client in (("CLAUDE_CODE_SESSION_ID", "claude"), ("CODEX_THREAD_ID", "codex")):
488
+ session_id = os.getenv(variable, "").strip()
489
+ if session_id:
490
+ payload["session_id"] = session_id
491
+ payload["session_client"] = client
492
+ break
493
+ return RequestPlan(base, "POST", payload)
494
+
495
+
496
+ def plan_attachment(args):
497
+ """Keep file bytes outside JSON messages and all storage targets explicit."""
498
+ base = f"/api/rooms/{args.room}/attachments"
499
+ if args.action == "list":
500
+ params = {"page": args.page, "message_id": args.message, "submission_id": args.submission_id}
501
+ return RequestPlan(base, params={key: value for key, value in params.items() if value is not None})
502
+ if args.action == "upload":
503
+ path = Path(args.file)
504
+ try:
505
+ if not path.is_file() or path.stat().st_size > MAX_ATTACHMENT_BYTES:
506
+ raise CliError("上传路径须为文件,大小最多 20 MiB")
507
+ except OSError as error:
508
+ raise CliError(f"无法读取上传文件: {error}") from error
509
+ metadata = {"actor": require_actor(args), "file_name": path.name, "submission_id": args.submission_id}
510
+ return RequestPlan(base, "POST", params=metadata, upload_file=str(path))
511
+ if args.action == "download":
512
+ destination = Path(args.dest)
513
+ if destination.exists() or not destination.parent.is_dir():
514
+ raise CliError("--dest 须为不存在的文件路径,且父目录已存在;不会覆盖文件")
515
+ return RequestPlan(f"{base}/{args.id}/content", params={"download": "1"}, download_file=str(destination))
516
+ return RequestPlan(f"{base}/{args.id}")
517
+
518
+
519
+ def plan_turn(args):
520
+ """Separate creating an assignment from external notification delivery."""
521
+ base = f"/api/rooms/{args.room}"
522
+ if args.action == "current":
523
+ return RequestPlan(base, view="current")
524
+ if args.action == "list":
525
+ params = {"page": args.page}
526
+ if args.submission_id:
527
+ params["submission_id"] = args.submission_id
528
+ return RequestPlan(base + "/turns", params=params)
529
+ if args.action == "create":
530
+ return RequestPlan(base + "/turns", "POST", turn_payload(args))
531
+ if args.action == "continue":
532
+ return RequestPlan(base + "/turns/continue", "POST", {"actor": require_actor(args)})
533
+ path = f"{base}/turns/{args.id}"
534
+ if args.action in ("mark-notified", "cancel"):
535
+ status = "notified" if args.action == "mark-notified" else "cancelled"
536
+ return RequestPlan(path, "POST", {"actor": require_actor(args), "status": status})
537
+ return RequestPlan(path, view="prompt" if args.action == "prompt" else None)
538
+
539
+
540
+ def turn_payload(args):
541
+ """Build one arrangement or an ordered round plan from a UTF-8 JSON array."""
542
+ payload = {"actor": require_actor(args), "submission_id": args.submission_id}
543
+ question = text_field(args, "question")
544
+ if args.plan_file is not None:
545
+ if args.expert or question is not None:
546
+ raise CliError("--plan-file 不能与 --expert / --question 同时使用")
547
+ try:
548
+ turns = json.loads(read_input(args.plan_file))
549
+ except ValueError as error:
550
+ raise CliError(f"无效 JSON: {error}") from error
551
+ if not isinstance(turns, list) or not all(isinstance(item, dict) for item in turns):
552
+ raise CliError("--plan-file 必须是由 expert、question 对象组成的数组")
553
+ return {**payload, "turns": turns}
554
+ if not args.expert or question is None:
555
+ raise CliError("需要 --expert 与 --question/--question-file,或改用 --plan-file")
556
+ return {**payload, "expert": args.expert, "question": question}
557
+
558
+
559
+ def plan_floor(args):
560
+ """Send one versioned operation without silently claiming on a failed write."""
561
+ path = f"/api/rooms/{args.room}/floor"
562
+ if args.action == "view":
563
+ return RequestPlan(path)
564
+ actor = require_actor(args)
565
+ return RequestPlan(path, "POST", {"actor": actor, "speaker": args.speaker or actor,
566
+ "action": args.action, "revision": args.revision})
567
+
568
+
569
+ def plan_state(args):
570
+ """Keep a host summary's cutoff explicit instead of assuming latest means read."""
571
+ base = f"/api/rooms/{args.room}"
572
+ if args.action == "view":
573
+ return RequestPlan(base, view="state")
574
+ if args.until < 0:
575
+ raise CliError("--until 不能小于 0")
576
+ payload = {"actor": require_actor(args), "text": text_field(args, "body"), "until": args.until}
577
+ brief = text_field(args, "brief")
578
+ if brief is not None:
579
+ if len(brief.strip()) > MAX_BRIEF_LENGTH:
580
+ raise CliError(f"主持简报最多 {MAX_BRIEF_LENGTH} 个 Unicode 字符;不会自动截断")
581
+ payload["brief"] = brief
582
+ return RequestPlan(base + "/state", "POST", payload, "state")
583
+
584
+
585
+ def plan_api(args):
586
+ """Allow literal HTTP requests only under the selected site's API/read roots."""
587
+ path = urlsplit(args.path)
588
+ decoded = unquote(path.path)
589
+ if (path.scheme or path.netloc or path.fragment or "\\" in decoded
590
+ or not decoded.startswith(("/api/", "/r/")) or ".." in decoded.split("/")):
591
+ raise CliError("api 路径必须是本站 /api/... 或 /r/...,不能切换主机或包含 ..")
592
+ if args.method == "GET" and args.input:
593
+ raise CliError("GET 不接受 --input;查询条件直接写在路径中")
594
+ if args.method == "POST" and not args.input:
595
+ raise CliError("POST 需要 --input JSON文件(或 -)")
596
+ return RequestPlan(args.path, args.method, read_object(args.input) if args.input else None)
597
+
598
+
599
+ def plan_run(args):
600
+ """Build one versioned lifecycle operation without starting a daemon."""
601
+ path = f"/api/rooms/{args.room}/runtime"
602
+ if args.action == "view":
603
+ return RequestPlan(path)
604
+ body = {"action": args.action, "revision": args.revision}
605
+ for key in ("mode", "deadline", "phase"):
606
+ if getattr(args, key, None) is not None:
607
+ body[key] = getattr(args, key)
608
+ return RequestPlan(path, "POST", body)
609
+
610
+
611
+ def plan_execution(args):
612
+ """Use the bound actual session when acknowledging a delivered execution."""
613
+ body = {"action": args.action}
614
+ if args.action == "enter":
615
+ access = load_access(args)
616
+ body["session_id"] = args.session_id or access.get("session_id")
617
+ if not body["session_id"]:
618
+ raise CliError("尚未取得实际 session_id,请先核对接入回执")
619
+ if args.action == "fail":
620
+ body["reason"] = args.reason
621
+ return RequestPlan(f"/api/rooms/{args.room}/events/{args.id}", "POST", body)
622
+
623
+
624
+ def build_plan(args):
625
+ """Validate local input before constructing any network request."""
626
+ stdin_count = sum(value == "-" for key, value in vars(args).items()
627
+ if key.endswith("_file") or key == "input")
628
+ if stdin_count > 1:
629
+ raise CliError("一次命令只能有一个字段从标准输入读取")
630
+ handlers = {"room": plan_room, "message": plan_message, "turn": plan_turn,
631
+ "attachment": plan_attachment,
632
+ "state": plan_state, "floor": plan_floor, "api": plan_api,
633
+ "run": plan_run, "execution": plan_execution}
634
+ plan = RequestPlan("/api/health") if args.resource == "health" else handlers[args.resource](args)
635
+ if getattr(args, "dry_run", False) and plan.method != "POST" and plan.download_file is None:
636
+ raise CliError("--dry-run 仅用于写入命令;读取命令本身不会修改数据")
637
+ if plan.body is not None and len(json.dumps(plan.body, ensure_ascii=False).encode("utf-8")) > MAX_BODY_BYTES:
638
+ raise CliError(f"请求体超过服务端限制 {MAX_BODY_BYTES} 字节")
639
+ return plan
640
+
641
+
642
+ def resolve_site(args):
643
+ """Resolve the flag, then the environment, then the site the access file is bound to; never a built-in default."""
644
+ site = getattr(args, "site", None) or os.getenv("DISCUSS_URL", "")
645
+ if not site:
646
+ # The credential's own site is the only place it is ever sent back to.
647
+ site = str(load_access(args).get("site", ""))
648
+ parsed = urlsplit(site)
649
+ if (parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username
650
+ or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/")):
651
+ raise CliError("请用 --site、DISCUSS_URL 或凭据(--access-file / DISCUSS_ACCESS_FILE / DISCUSS_ACCESS)指定 http(s)://主机[:端口],不带路径、凭据或查询")
652
+ try:
653
+ parsed.port
654
+ except ValueError as error:
655
+ raise CliError(str(error)) from error
656
+ return site.rstrip("/")
657
+
658
+
659
+ def decode_response(response, plan):
660
+ """Decode the expected payload while identifying uncertain write responses."""
661
+ if plan.download_file is not None:
662
+ return save_attachment_response(response, plan.download_file)
663
+ try:
664
+ text = response.read().decode("utf-8-sig")
665
+ if plan.path.startswith("/r/") and plan.method == "GET":
666
+ if "text/plain" not in response.headers.get("Content-Type", ""):
667
+ raise ValueError("预期纯文本响应")
668
+ return text
669
+ value = json.loads(text)
670
+ if not isinstance(value, dict):
671
+ raise ValueError("预期 JSON 对象")
672
+ return value
673
+ except (ValueError, UnicodeError) as error:
674
+ raise CliError("响应不是预期的 UTF-8 / JSON 内容", 3,
675
+ outcome="unknown" if plan.method == "POST" else "unavailable") from error
676
+
677
+
678
+ def save_attachment_response(response, destination):
679
+ """Validate a complete bounded response before creating an exclusive local file."""
680
+ data = response.read(MAX_ATTACHMENT_BYTES + 1)
681
+ size_header = response.headers.get("Content-Length")
682
+ if len(data) > MAX_ATTACHMENT_BYTES or (size_header and len(data) != int(size_header)):
683
+ raise CliError("附件下载不完整或超过 20 MiB;未保存文件", 3, outcome="unavailable")
684
+ digest = hashlib.sha256(data).hexdigest()
685
+ if response.headers.get("X-Content-SHA256", digest) != digest:
686
+ raise CliError("附件校验失败;未保存文件", 3, outcome="unavailable")
687
+ path = Path(destination)
688
+ created = False
689
+ try:
690
+ with path.open("xb") as target:
691
+ created = True
692
+ target.write(data)
693
+ except OSError as error:
694
+ if created:
695
+ path.unlink(missing_ok=True)
696
+ raise CliError(f"无法保存附件;不会覆盖已有文件: {error}") from error
697
+ return {"path": str(path.resolve()), "size": len(data), "sha256": digest}
698
+
699
+
700
+ def request_data(plan):
701
+ """Load a bounded file for binary upload, or encode ordinary JSON requests."""
702
+ if plan.upload_file is not None:
703
+ try:
704
+ with Path(plan.upload_file).open("rb") as source:
705
+ data = source.read(MAX_ATTACHMENT_BYTES + 1)
706
+ except OSError as error:
707
+ raise CliError(f"无法读取上传文件: {error}") from error
708
+ if len(data) > MAX_ATTACHMENT_BYTES:
709
+ raise CliError("单个附件最多 20 MiB")
710
+ return data
711
+ return json.dumps(plan.body, ensure_ascii=False).encode("utf-8") if plan.body is not None else None
712
+
713
+
714
+ def http_error(error, plan):
715
+ """Retain useful API errors and distinguish rejected from uncertain writes."""
716
+ try:
717
+ body = error.read().decode("utf-8")
718
+ value = json.loads(body)
719
+ message = value.get("error", body) if isinstance(value, dict) else body
720
+ except (ValueError, UnicodeError, OSError, HTTPException):
721
+ message = f"HTTP {error.code}: {error.reason}"
722
+ uncertain = plan.method == "POST" and error.code >= 500
723
+ return CliError(str(message), 1, http_status=error.code,
724
+ outcome="unknown" if uncertain else "rejected")
725
+
726
+
727
+ def send_request(plan, url, timeout, headers=None):
728
+ """Send exactly once, preserving TLS verification and bounded waiting."""
729
+ data = request_data(plan)
730
+ content_type = "application/octet-stream" if plan.upload_file is not None else "application/json"
731
+ request = Request(url, data=data, method=plan.method,
732
+ headers={"Content-Type": content_type, "Accept": "*/*" if plan.download_file else "application/json, text/plain",
733
+ "User-Agent": f"AI-CoDiscuss-CLI/{VERSION}", **(headers or {})})
734
+ try:
735
+ with build_opener(NoRedirect()).open(request, timeout=timeout) as response:
736
+ return decode_response(response, plan)
737
+ except HTTPError as error:
738
+ with error:
739
+ raise http_error(error, plan) from error
740
+ except (URLError, OSError, HTTPException) as error:
741
+ raise CliError(f"网络请求失败: {error}", 3,
742
+ outcome="unknown" if plan.method == "POST" else "unavailable") from error
743
+
744
+
745
+ def select_fields(value, names):
746
+ """Project explicitly requested fields without expanding additional context."""
747
+ return {name: value[name] for name in names if name in value}
748
+
749
+
750
+ def project_result(value, plan, site):
751
+ """Keep summaries, briefing material, host state, and original text separate."""
752
+ if plan.view == "room_list":
753
+ return {"rooms": [select_fields(room, ROOM_LIST_FIELDS) for room in value["rooms"]]}
754
+ if plan.view in ("overview", "brief", "state", "current"):
755
+ fields = {"overview": OVERVIEW_FIELDS, "brief": BRIEF_FIELDS,
756
+ "state": ("id", "state_text", "state_until", "host_brief", "latest_receipts", "answered_turns"),
757
+ "current": ("id", "phase", "current_turn", "queued_turns", "held_since", "floor", "answered_turns")}[plan.view]
758
+ result = select_fields(value, fields)
759
+ if plan.view in ("overview", "current"):
760
+ result["guidance"] = GUIDANCE.get(value["phase"], "按本次主持或专家职责,结合最新阶段、安排和必要原文决定下一步。")
761
+ result["url"] = f"{site}/r/{value['id']}"
762
+ return result
763
+ if plan.view == "prompt":
764
+ entry = f"{site}/r/{value['room_id']}?participant={value['expert']}"
765
+ prompt = f"请 {value['expert']} 回应:{value['question']}\n安排编号:{value['id']}\n按需阅读:{entry}\n回答附简短摘要及完整正文,保留关键条件和不确定性。"
766
+ return {"turn_id": value["id"], "status": value["status"], "prompt": prompt,
767
+ "delivery": "未发送;仅生成文字,实际通知后使用 turn mark-notified"}
768
+ return value
769
+
770
+
771
+ def command_schema(args, registry):
772
+ """Describe available commands and fields from the live argparse registry."""
773
+ name = " ".join(args.command)
774
+ if not name:
775
+ return {"version": VERSION, "commands": {key: item.description for key, item in registry.items()},
776
+ "exit_codes": EXIT_CODES, "site": "--site > DISCUSS_URL > 凭据(--access-file / DISCUSS_ACCESS_FILE / DISCUSS_ACCESS)中的 site;无内置默认站点",
777
+ "output": "默认 JSON;成功 stdout,错误 stderr;无交互提示、无自动重试"}
778
+ if name not in registry:
779
+ raise CliError(f"未知命令 {name};运行 discuss schema 查看可用命令")
780
+ parser = registry[name]
781
+ arguments = []
782
+ for action in parser._actions:
783
+ if action.dest == "help":
784
+ continue
785
+ item = {"name": action.dest, "flags": action.option_strings,
786
+ "required": action.required, "help": action.help}
787
+ if action.dest == "actor" and name in ("room create", "room update", "message post",
788
+ "turn create", "turn mark-notified", "turn cancel", "state set", "attachment upload"):
789
+ item["required"] = True
790
+ if action.choices is not None:
791
+ item["choices"] = list(action.choices)
792
+ if action.default not in (argparse.SUPPRESS, None):
793
+ item["default"] = action.default
794
+ arguments.append(item)
795
+ groups = [{"one_of": [item.dest for item in group._group_actions], "required": group.required}
796
+ for group in parser._mutually_exclusive_groups]
797
+ return {"command": name, "description": parser.description, "arguments": arguments,
798
+ "exclusive_groups": groups, "scope": "CLI 参数约定;服务端协议见 room protocol"}
799
+
800
+
801
+ def text_output(value):
802
+ """Render readable text while retaining complete message bodies and IDs."""
803
+ if isinstance(value, str):
804
+ return value
805
+ if isinstance(value, dict):
806
+ lines = []
807
+ for key, item in value.items():
808
+ if isinstance(item, (dict, list)):
809
+ lines.append(f"{key}:\n{text_output(item)}")
810
+ else:
811
+ lines.append(f"{key}: {item if item is not None else '—'}")
812
+ return "\n".join(lines)
813
+ if isinstance(value, list):
814
+ return "\n\n".join(text_output(item) for item in value) or "(空)"
815
+ return str(value)
816
+
817
+
818
+ def emit(value, output="json", error=False):
819
+ """Write the stable JSON envelope or human text to the correct stream."""
820
+ stream = sys.stderr if error else sys.stdout
821
+ if output == "json":
822
+ rendered = json.dumps(value, ensure_ascii=False, indent=2)
823
+ else:
824
+ rendered = text_output(value if error else value["data"])
825
+ stream.write(rendered + "\n")
826
+ stream.flush()
827
+
828
+
829
+ def save_access(path, value):
830
+ """Persist private capabilities before sending requests, without printing their content."""
831
+ target = Path(path)
832
+ with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=target.parent, delete=False) as stream:
833
+ temporary = Path(stream.name)
834
+ os.chmod(temporary, 0o600)
835
+ json.dump(value, stream, ensure_ascii=False, indent=2)
836
+ stream.flush()
837
+ os.fsync(stream.fileno())
838
+ try:
839
+ os.replace(temporary, target)
840
+ finally:
841
+ temporary.unlink(missing_ok=True)
842
+
843
+
844
+ def prepare_creation_access(args, site, plan):
845
+ """Create or reuse one explicit private credential bundle for a stable creation id."""
846
+ if getattr(args, "operation", "") != "room create":
847
+ return
848
+ if not getattr(args, "access_file", None):
849
+ raise CliError("建房须用 --access-file 指定私有凭据文件路径;首次自动生成,重试沿用同一文件")
850
+ path = Path(args.access_file)
851
+ payload = plan.body
852
+ access = read_object(path) if path.exists() else {"site": site, "room": "", "token": secrets.token_urlsafe(48),
853
+ "submission_id": payload.get("submission_id"), "members": {}}
854
+ if access.get("site") != site or access.get("submission_id") != payload.get("submission_id"):
855
+ raise CliError("建房凭据文件与本次站点或 submission_id 不符")
856
+ experts = payload.setdefault("experts", {name: "cli" if name == "Codex" else "online" for name in PARTICIPANTS[1:]})
857
+ members = []
858
+ for name, capability in experts.items():
859
+ member = access["members"].setdefault(name, {"token": secrets.token_urlsafe(48)})
860
+ members.append({"name": name, "token": member["token"], "access": "online" if capability == "online" else "manual"})
861
+ payload["members"] = members
862
+ save_access(path, access)
863
+
864
+
865
+ def finish_creation_access(args, site, plan, value):
866
+ """Save the confirmed room and separate member files; return paths without secrets."""
867
+ if getattr(args, "operation", "") != "room create":
868
+ return {}
869
+ path = Path(args.access_file).resolve()
870
+ access = read_object(path)
871
+ access["room"] = value["id"]
872
+ save_access(path, access)
873
+ paths = {}
874
+ for member in plan.body["members"]:
875
+ if member["access"] == "online":
876
+ continue
877
+ member_path = path.with_name(f"{path.stem}.{member['name']}.json")
878
+ save_access(member_path, {"site": site, "room": value["id"], "token": member["token"]})
879
+ paths[member["name"]] = str(member_path)
880
+ return {"owner_access_file": str(path), "member_access_files": paths}
881
+
882
+
883
+ def access_headers(args, site, path):
884
+ """Load a room-scoped secret only for its exact site and room."""
885
+ access = load_access(args)
886
+ if not access:
887
+ return {}
888
+ expected = f"/api/rooms/{access.get('room', '')}"
889
+ target = urlsplit(path).path
890
+ creation = target == "/api/rooms" and bool(access.get("submission_id"))
891
+ reading = f"/r/{access.get('room', '')}"
892
+ if access.get("site", "").rstrip("/") != site or not (creation or target == expected or target.startswith(expected + "/") or target == reading or target.startswith(reading + "/")):
893
+ raise CliError("凭据文件不属于本次站点和房间")
894
+ token = access.get("token")
895
+ if not isinstance(token, str) or not 32 <= len(token) <= 256:
896
+ raise CliError("凭据文件中的 token 无效")
897
+ return {"X-Discussion-Token": token}
898
+
899
+
900
+ def preview_body(value):
901
+ """Keep private capability material out of offline request previews."""
902
+ if isinstance(value, dict):
903
+ return {key: "[redacted]" if key in ("token", "execution_token", "control_hash", "token_hash") else preview_body(item)
904
+ for key, item in value.items()}
905
+ if isinstance(value, list):
906
+ return [preview_body(item) for item in value]
907
+ return value
908
+
909
+
910
+ def run(args, registry):
911
+ """Execute a parsed command, with schema and previews remaining offline."""
912
+ if args.resource == "schema":
913
+ return {"ok": True, "data": command_schema(args, registry)}
914
+ plan = build_plan(args)
915
+ site = resolve_site(args)
916
+ timeout = getattr(args, "timeout", DEFAULT_TIMEOUT)
917
+ if not math.isfinite(timeout) or timeout <= 0:
918
+ raise CliError("--timeout 必须为有限正数")
919
+ url = site + plan.path
920
+ if plan.params:
921
+ url += "?" + urlencode(plan.params)
922
+ meta = {"site": site, "method": plan.method, "path": plan.path}
923
+ if getattr(args, "dry_run", False):
924
+ return {"ok": True, "data": {"method": plan.method, "url": url, "body": preview_body(plan.body),
925
+ **({"file": plan.upload_file} if plan.upload_file else {}),
926
+ **({"destination": plan.download_file} if plan.download_file else {})},
927
+ "meta": {**meta, "dry_run": True, "validation": "仅本地参数;未验证房间、身份或服务端状态"}}
928
+ try:
929
+ prepare_creation_access(args, site, plan)
930
+ headers = access_headers(args, site, plan.path)
931
+ value = send_request(plan, url, timeout, headers) if headers else send_request(plan, url, timeout)
932
+ result = project_result(value, plan, site)
933
+ if getattr(args, "operation", "") == "room create":
934
+ result.update(finish_creation_access(args, site, plan, value))
935
+ except CliError as error:
936
+ error.details.update(meta)
937
+ if plan.method == "POST":
938
+ error.details["submission_id"] = (plan.body or plan.params or {}).get("submission_id")
939
+ error.details["hint"] = "未自动重试。创建结果未知时用对应 list --submission-id 查询;确认需要重试才沿用原编号和原始内容。"
940
+ raise
941
+ except (KeyError, TypeError) as error:
942
+ raise CliError("响应结构与 CLI 不兼容,请用 api 命令检查服务版本", 3,
943
+ **meta, outcome="unknown" if plan.method == "POST" else "unavailable") from error
944
+ return {"ok": True, "data": result, "meta": meta}
945
+
946
+
947
+ def main(argv=None):
948
+ """Run the CLI without prompts; return documented process exit codes."""
949
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
950
+ if hasattr(stream, "reconfigure"):
951
+ stream.reconfigure(encoding="utf-8")
952
+ args = None
953
+ try:
954
+ parser, registry = build_parser(prog=os.getenv("DISCUSS_CLI_NAME", "discuss"))
955
+ args = parser.parse_args(argv)
956
+ emit(run(args, registry), getattr(args, "output", "json"))
957
+ return 0
958
+ except CliError as error:
959
+ output = getattr(args, "output", "json")
960
+ emit({"ok": False, "error": {"message": str(error), "exit_code": error.code, **error.details}},
961
+ output, error=True)
962
+ return error.code
963
+ except (ValueError, UnicodeError, OSError) as error:
964
+ emit({"ok": False, "error": {"message": str(error), "exit_code": 2}}, error=True)
965
+ return 2
966
+ except KeyboardInterrupt:
967
+ emit({"ok": False, "error": {"message": "已中断;若正在写入,请先读取记录核对结果。",
968
+ "exit_code": 130, "outcome": "unknown"}}, error=True)
969
+ return 130
970
+
971
+
972
+ if __name__ == "__main__":
973
+ sys.exit(main())