ai_co_discuss 0.1.0 → 0.1.2

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  1. 使用已安装的 `codiscuss`;未安装时用本仓库 `node runner/codiscuss.mjs`。`codiscuss launch settings` 只读本机软件默认配置,不启动服务或 AI。缺少配置时按仓库 `docs/runner.md` 的“两种讨论入口与启动配置”补齐;用户已提供的软件、目录和上限直接沿用,只询问无法确定的缺项。
8
8
  2. 从当前软件的实际会话信息取得会话 ID,不自造编号。启动文件中 `entry` 使用 `conversation`,`current_session_id` 使用该实际 ID;主持人的 `participants` 覆盖配置必须对应当前实际 CLI / App 客户端,不能仅根据模型名称选适配器。当前会话继续主持,不另建主持聊天。
9
- 3. 站点始终明确指定。每场讨论选择主持人、专家、目标、用户原文、背景和上限;软件路径来自本机默认配置,本次可用 `participants` 覆盖。`cwd` / `discussion_project` 可省略,单个参与者可覆盖。已有会话仍在原目录,设置 cwd 不会迁移它。用户要一起查看某个项目时,把该项目目录设为 `cwd`;项目文件默认要求只读,仅当用户明确要求 AI 修改项目时才设 `"project_access": "write"`,也可在 `participants.NAME.project_access` 单独指定。只读不通过更换项目目录实现;agy 新会话由 runner 设置原生项目权限,已有会话(包括当前主持)仍由提示词约束。答案草稿只使用任务给出的 `draft_directory`,不写到项目或存放凭据的运行目录。
9
+ 3. 站点始终明确指定。每场讨论选择主持人、专家、目标、用户原文、背景和上限;软件路径来自本机默认配置,本次可用 `participants` 覆盖。`cwd` / `discussion_project` 可省略,单个参与者可覆盖。已有会话仍在原目录,设置 cwd 不会迁移它。用户要一起查看某个项目时,把该项目目录设为 `cwd`;项目文件默认要求只读,仅当用户明确要求 AI 修改项目时才设 `"project_access": "write"`,也可在 `participants.NAME.project_access` 单独指定。只读不通过更换项目目录实现;agy 只在讨论服务新建的会话中运行并由原生项目权限限制,不能绑定已有会话或从当前 agy 对话主持全自动讨论。答案草稿只使用任务给出的 `draft_directory`,不写到项目或存放凭据的运行目录。
10
10
 
11
11
  新讨论的 UTF-8 启动文件示例(替换示例内容;软件已在本机配置):
12
12
 
package/README.md CHANGED
@@ -17,10 +17,10 @@
17
17
 
18
18
  ### 安装包用户
19
19
 
20
- 需要 **Node.js 24+、npm、Python 3.10+**,所选 AI CLI 须预先安装并登录;安装器不会代装这些运行环境或 AI 软件。当前尚无公开安装包下载或 npm registry 发布,`.tgz` 由维护者提供;尚未取得包时,使用下方的[源码运行方式](#源码运行方式)。在安装包所在目录运行,文件名以实际版本为准:
20
+ 需要 **Node.js 24+、npm、Python 3.10+**,所选 AI CLI 须预先安装并登录;安装器不会代装这些运行环境或 AI 软件。安装包已发布到 npm registry,在任意目录运行:
21
21
 
22
22
  ```sh
23
- npx --yes --package ./ai_co_discuss-0.1.0.tgz codiscuss install
23
+ npx --yes --package ai_co_discuss codiscuss install
24
24
  ```
25
25
 
26
26
  按安装提示选择已检测到的软件并核对配置。安装器会安装持久的 `codiscuss` 命令、复制三份完整讨论 Skill、保存本机软件配置,启动网站与讨论服务并打开浏览器。安装包已带运行代码和构建好的网页,无需另行克隆仓库或构建前端;安装本身不创建讨论。
@@ -83,7 +83,7 @@ codiscuss local connect https://你的站点
83
83
 
84
84
  #### 项目与日常使用
85
85
 
86
- 两个入口都可按场指定项目目录、项目文件只读 / 可写及参与者覆盖配置;不需要项目资料时可省略。CLI 保持指定工作目录,回答草稿与运行数据分开。项目默认要求只读,但限制强度取决于软件:Codex 使用原生沙箱,agy 新会话使用原生项目权限并禁用 MCP;Claude 的已有用户授权仍生效,agy 已有外部会话保留上下文并使用提示词约束。权限冲突处理与实测边界见 [单次执行权限](docs/runner.md#单次执行权限)。
86
+ 两个入口都可按场指定项目目录、项目文件只读 / 可写及参与者覆盖配置;不需要项目资料时可省略。CLI 保持指定工作目录,回答草稿与运行数据分开。项目默认要求只读,但限制强度取决于软件:Codex 使用原生沙箱,agy 只在讨论服务新建的会话中运行并由其独立项目权限限制、禁用 MCP(不绑定已有 agy 会话);Claude 的已有用户授权仍生效。权限冲突处理与实测边界见 [单次执行权限](docs/runner.md#单次执行权限)。
87
87
 
88
88
  | 日常操作 | 命令 |
89
89
  | --- | --- |
@@ -28,7 +28,7 @@ Python 与 Cloudflare 是两种部署实现,运行时选其一,不是请求
28
28
  | `backend/worker.mjs` | Pages Request、D1 `DB` binding | 与 Python 相同的业务响应;静态请求交由 Pages assets |
29
29
  | `runner/codiscuss.mjs` | 统一 `codiscuss` 命令与原样参数 | 无子命令时启动本机服务,`service` 管理进程;网站命令转交 Python 客户端,保留 stdin、相对路径与退出码 |
30
30
  | `runner/discussion_launch.mjs` | 本机默认软件配置、单场启动配置、对话 / 网页入口 | 校验后建房、保存私有配置、启用运行并登记接入服务;对话入口将首个主持任务交回当前会话;暂停后可凭原控制凭据和版本修正本场软件路径,不改变会话绑定或自动重启 |
31
- | `runner/discussion_setup.mjs` / `discussion_skills.mjs` | npm 包、已有 Python / CLI、选定软件 | 持久安装统一命令,合并本机软件配置,复制完整技能并保留本地修改;不创建讨论 |
31
+ | `runner/discussion_setup.mjs` / `discussion_skills.mjs` / `discussion_tui.mjs` | npm 包、已有 Python / CLI、选定软件 | 持久安装统一命令,合并本机软件配置,复制完整技能并保留本地修改;`discussion_tui.mjs` 只负责安装过程的终端呈现;不创建讨论 |
32
32
  | `runner/discussion_local.mjs` | 用户服务目录、Python 和端口配置 | 后台网站及包外 SQLite,复用讨论服务、检查就绪、仅停止拥有的进程 |
33
33
  | `runner/discuss.py` | 命令、站点、房间、声明身份、UTF-8 文件 | 网站客户端实现;单次 HTTP 请求、稳定 JSON / 文本、退出码,帮助和预览可离线,可直接用 Python 执行 |
34
34
  | `web/app.js` | 页面事件和 API 返回状态 | 共享讨论控制器、读写操作、主题与界面同步 |
package/docs/runner.md CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  ## npm 安装与本机配置
6
6
 
7
- 需要 Node.js 24+、npm 与 Python 3.10+。包内带已构建网页、Python 后端、接入程序及三份完整技能;运行时不安装前端依赖。当前尚未发布 npm registry,使用本项目生成的 `.tgz` 文件:
7
+ 需要 Node.js 24+、npm 与 Python 3.10+。包内带已构建网页、Python 后端、接入程序及三份完整技能;运行时不安装前端依赖。从 npm registry 安装:
8
8
 
9
9
  ```sh
10
- npx --yes --package ./ai_co_discuss-0.1.0.tgz codiscuss install
10
+ npx --yes --package ai_co_discuss codiscuss install
11
11
  ```
12
12
 
13
- `install` 在安装包或写配置前检查 Node、Python、端口参数及显式选择的软件路径,再将当前包安装到 npm 的全局 prefix,由持久安装运行 `setup`;不依赖 npx 缓存长期存在。已有源码时可直接在仓库执行 `node runner/codiscuss.mjs install`。普通 `npm install --global 包路径.tgz` 只安装命令,随后运行 `codiscuss setup`;没有 postinstall 配置或启动副作用。开发者仍可使用下文的 `npm link`。
13
+ `install` 在安装包或写配置前检查 Node、Python、端口参数及显式选择的软件路径,再将当前包安装到 npm 的全局 prefix,由持久安装运行 `setup`;不依赖 npx 缓存长期存在。已有源码时可直接在仓库执行 `node runner/codiscuss.mjs install`。维护者生成的 `.tgz` 可将包名换成文件路径。普通 `npm install --global ai_co_discuss` 只安装命令,随后运行 `codiscuss setup`;没有 postinstall 配置或启动副作用。开发者仍可使用下文的 `npm link`。
14
14
 
15
15
  `setup` 检测 Python 和固定四类 CLI:Claude(`claude`)、Codex(`codex`)、Gemini 使用的 Antigravity(`agy`)、Grok 使用的 Cursor(`agent` / `cursor-agent`)。按显式路径或 PATH 查找并执行有超时的 `--version`;Windows 读取实际 `.exe`(PATH 上只有 npm 包装脚本时,到 `node_modules/@openai` 包内查找 Codex 原生程序,跳过 npm 升级残留的 `.` 开头目录),Cursor 也支持官方 `.cmd` 启动入口。仅检查 CLI 能否启动,不扫描 App、账号登录或模型状态;已有 App 配置不会被当成检测成功。
16
16
 
@@ -24,17 +24,17 @@ codiscuss local status
24
24
  codiscuss local stop
25
25
  ```
26
26
 
27
- 交互回车及 `--yes` 默认选择检测到且已有自动适配器的 CLI;`--clients codex,claude,antigravity,cursor` 明确选择,`none` 只配置网站,Cursor 需要显式选择。`--no-start` 只配置,`--no-open` 启动网站但不打开浏览器;无交互输入时须提供 `--yes` 或 `--clients`。软件路径可用 `--codex PATH`、`--claude PATH`、`--antigravity PATH`、`--cursor PATH` 指定。`install --prefix DIR` 可使用可写的 npm 安装前缀;Windows 命令目录是 prefix 本身,macOS/Linux 是其 `bin` 子目录。
27
+ 交互终端显示多选列表(方向键移动、空格勾选、回车确认,Ctrl+C 取消),默认勾选检测到且已有自动适配器的 CLI,未检测到的软件不可勾选;`--yes` 同样默认选择这些 CLI;`--clients codex,claude,antigravity,cursor` 明确选择,`none` 只配置网站,Cursor 需要显式选择。`--no-start` 只配置,`--no-open` 启动网站但不打开浏览器;无交互输入时须提供 `--yes` 或 `--clients`。软件路径可用 `--codex PATH`、`--claude PATH`、`--antigravity PATH`、`--cursor PATH` 指定。`install --prefix DIR` 可使用可写的 npm 安装前缀;Windows 命令目录是 prefix 本身,macOS/Linux 是其 `bin` 子目录。
28
28
 
29
29
  配置结束时忽略 npx 临时入口,核对当前 PATH 中的持久 `codiscuss` 是否指向本次安装,并实际执行 `--help`。命令缺失、旧入口优先或无法运行均报告待处理及处理方式;自定义 prefix 未在 PATH 时,输出需要加入的实际目录。安装器不修改系统 PATH;修正后重新打开终端运行 `codiscuss setup`。
30
30
 
31
- `install/setup` 保留 stdout JSON,并在 stderr 输出中文摘要,包括实际软件配置、Skill 文件就绪数、命令和网站地址。退出码与 JSON `status` 对应:`0` 为 `ready`(网站与讨论服务已就绪)或 `configured`(显式 `--no-start`);`2` 为 `needs_attention`(配置差异未确认、技能冲突或命令不可用);`1` 为失败。网站启动失败时返回 `start_failed` 与已保存的配置结果,修复后重跑 `setup` 即可。环境预检失败发生在写入前,仅输出错误。配置待处理不撤销已完成部分,也不妨碍启动可用的网站;`configured` 布尔值为 false,不能按成功处理。`ai_verified: false` 表示安装器没有执行真实模型,不证明账号、技能发现或多轮讨论已验收。
31
+ `install/setup` 在 stderr 输出中文摘要,包括实际软件配置、Skill 文件就绪数、命令和网站地址;交互终端显示分步进度、多选列表与对齐的结果汇总(遵循 `NO_COLOR`),输出被重定向时保持逐行纯文本。结果 JSON 在 stdout 被重定向或加 `--json` 时写入 stdout,交互终端默认不打印。退出码与 JSON `status` 对应:`0` 为 `ready`(网站与讨论服务已就绪)或 `configured`(显式 `--no-start`);`2` 为 `needs_attention`(配置差异未确认、技能冲突或命令不可用);`1` 为失败。网站启动失败时返回 `start_failed` 与已保存的配置结果,修复后重跑 `setup` 即可。环境预检失败发生在写入前,仅输出错误。配置待处理不撤销已完成部分,也不妨碍启动可用的网站;`configured` 布尔值为 false,不能按成功处理。`ai_verified: false` 表示安装器没有执行真实模型,不证明账号、技能发现或多轮讨论已验收。
32
32
 
33
- 安装器按 [技能配置](skills.md#自动安装完整技能) 复制完整技能。`--skills-home DIR` 可覆盖技能用户根目录;默认当前用户 home。重复安装只更新由安装器管理且未被编辑的版本;冲突保留本地文件,输出实际路径并标记待处理。核对后可移走需替换的旧副本再重跑 `setup`。安装器不调整登录状态;权限只在选中 Gemini 时经确认写入讨论命令与草稿目录规则,见 [单次执行权限](#单次执行权限)。
33
+ 安装器按 [技能配置](skills.md#自动安装完整技能) 复制完整技能。`--skills-home DIR` 可覆盖技能用户根目录;默认当前用户 home。重复安装只更新由安装器管理且未被编辑的版本;冲突保留本地文件,输出实际路径并标记待处理。核对后可移走需替换的旧副本再重跑 `setup`。安装器不调整登录状态,也不向 agy 全局设置写入权限规则;只清理早期版本写入的规则,见 [单次执行权限](#单次执行权限)。
34
34
 
35
35
  安装实现保持固定软件表和本地文件复制。路径查找参考 [npm/node-which](https://github.com/npm/node-which/blob/main/lib/index.js) 的 PATH 顺序与执行权限检查,只覆盖所需命令和后缀;技能复制参考 [OpenAI 单文件安装脚本](https://github.com/openai/skills/blob/main/skills/.system/skill-installer/scripts/install-skill-from-github.py) 的整目录复制与同名保护思路,保留本项目的内容哈希更新判断。不引入这些项目作为依赖,也不增加在线技能下载、插件注册或后台更新。
36
36
 
37
- 默认 `setup` 最后执行 `local open`:后台启动 Python 回环网站(`--local-launch --no-demo`)与讨论服务,再打开浏览器;首次为空库。网站地址默认为 `http://127.0.0.1:8765`。`local start` 具有相同启动行为但不打开浏览器,重复启动复用原进程;真正绑定成功并通过健康检查后才返回就绪。端口占用会失败,不接管或停止占用者。改端口或升级程序前先 `local stop`,再重新启动。
37
+ 默认 `setup` 最后执行 `local open`:后台启动 Python 回环网站(`--local-launch --no-demo`)与讨论服务,再打开浏览器;首次为空库。网站地址默认为 `http://127.0.0.1:8765`。`local start` 具有相同启动行为但不打开浏览器,重复启动复用原进程;真正绑定成功并通过健康检查后才返回就绪。端口占用会失败,不接管或停止占用者。网站管理进程脱离启动它的终端在后台运行,关闭终端不会停止网站或讨论服务;本机网站不设登录自启,重启电脑后运行 `local open` 重新启动。安装摘要会同时提示这些状态与停止命令。改端口或升级程序前先 `local stop`,再重新启动。
38
38
 
39
39
  网站数据库为服务目录下 `local/discuss.sqlite3`,配置、登记表和讨论执行数据也位于包外;服务目录位置及 `DISCUSS_SERVICE_HOME` 见下文。源码运行的 `data/discuss.sqlite3` 不自动迁移。网站仍在而讨论服务已停止时,显式 `local open/start` 会要求网站管理进程恢复服务;不会自动恢复已暂停的房间。`local status` 的 `running` 要求网站和讨论服务同时运行,`website_running` 单独表示网站状态。`local stop` 关闭它创建的网站,并停止它启动或恢复且仍由相同 PID 持有的讨论服务;预先运行或后来独立启动的服务会保留。停止服务会暂停其托管中的活动讨论,数据库保留。日志在 `local/website.log`、`local/supervisor.log` 与原 `service.log`。
40
40
 
@@ -202,7 +202,7 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
202
202
  | --- | --- | --- |
203
203
  | `codex_cli` | `codex exec --json`,从 `thread.started` 读取实际 ID;后续 `exec resume ID`,不使用 `--last` | 首次与续接均显式 `--cd` 到用户指定项目;省略 cwd 时使用本场独立草稿目录。允许非 Git 目录;项目与草稿的写入权限由本次 permission profile 区分。项目名仅作元数据,不关联 App 项目分组 |
204
204
  | `claude` | 现有 `claude -p --output-format json`;后续 `--resume ID` | 沿用上表中的 Claude 规则 |
205
- | `antigravity_cli` | `agy -p --output-format stream-json`,从 `init.conversation_id` 读取实际 ID;后续 `--conversation ID`,不使用 `--continue` | 保持指定 cwd;省略 cwd 时使用本场独立草稿目录。新会话创建独立的原生讨论项目,沿用所选项目的资源目录并设置权限,续接复用该项目;外部已有会话保留原项目和上下文 |
205
+ | `antigravity_cli` | `agy -p --output-format stream-json`,从 `init.conversation_id` 读取实际 ID;后续 `--conversation ID`,不使用 `--continue` | 保持指定 cwd;省略 cwd 时使用本场独立草稿目录。只在讨论服务新建的会话中执行:每位参与者一个独立原生讨论项目,续接复用该项目;不绑定外部已有会话 |
206
206
 
207
207
  两种新适配器都不需要 `source_thread_id` 或 `ipc_directory`。实际 ID 在输出流到达时写入本机绑定和本次执行凭据,随后 AI 按任务文件自行 `execution enter` 并通过讨论 CLI 保存业务结果;程序不把终端回答直接代发。已有 App 绑定不能通过改适配器直接复用,须先暂停并使用独立 `data_directory` 明确重新接入。此路径不承诺 CLI 会话同步到桌面 App。
208
208
 
@@ -210,21 +210,26 @@ Windows 的 `executable` 填原生 `.exe`,不填 npm 的 `.cmd` / `.ps1` 包
210
210
 
211
211
  每次执行只开放本场讨论所需的最小权限,不使用全部放行或绕过审批参数。任务文件的 `cli` 为 `codiscuss`,`subcommands` 列出唯一放行的网站子命令:`room`、`message`、`turn`、`state`、`floor`、`execution`、`health`、`schema`;`service`、`launch`、`run`、`api`、`setup`、`install`、`local` 不放行。提示词要求命令以 `codiscuss` 开头,不用 `python discuss.py`、`cd` 或管道。规则不含路径,重装或更换 Python 无需更新;子进程 PATH 中的 `codiscuss` 须指向本次安装,setup 的持久命令检查会核对。
212
212
 
213
- 项目文件权限 `project_access` 为 `read`(默认)或 `write`:启动文件顶层设本场值,`participants.NAME.project_access` 覆盖单个 AI,空字符串表示沿用本场值。它只随本次启动保存,不能存入本机默认配置;网页新建讨论在“启动”区直接提供项目目录与项目文件选项,单个 AI 的覆盖在“调整启动配置”中。三种 CLI 都在用户指定的 cwd 启动;省略 cwd 时才使用独立草稿目录。agy 新建会话使用原生项目权限;已有外部会话继续使用提示词约束,保留原项目与历史。App 适配器的写权限由 App 自身决定,任务文件始终写明本场要求。
213
+ 项目文件权限 `project_access` 为 `read`(默认)或 `write`:启动文件顶层设本场值,`participants.NAME.project_access` 覆盖单个 AI,空字符串表示沿用本场值。它只随本次启动保存,不能存入本机默认配置;网页新建讨论在“启动”区直接提供项目目录与项目文件选项,单个 AI 的覆盖在“调整启动配置”中。三种 CLI 都在用户指定的 cwd 启动;省略 cwd 时才使用独立草稿目录。agy 的权限全部由其独立讨论项目执行,见下文。App 适配器的写权限由 App 自身决定,任务文件始终写明本场要求。
214
214
 
215
215
  | 适配器 | 本次追加 | 只读 / 可写差异 |
216
216
  | --- | --- | --- |
217
217
  | `claude` | `--add-dir` 运行、草稿与技能目录;每个子命令各一条 `Bash(codiscuss room *)` 与 `PowerShell(codiscuss room *)`,仅对独立草稿增加 `Edit(//<草稿目录>/**)`,再追加用户的 `allowed_tools` | 可写时再加 `Edit(//<项目目录>/**)`;不再对运行目录新增 Edit 放行,已有用户规则仍生效;Windows 路径按 `//c/...` 匹配 |
218
218
  | `codex_cli` | 每次创建唯一名称的 permission profile,通过 `-c default_permissions=...` 与 `permissions.<名称>=...` 传入,`approval_policy="never"`;运行目录只读,独立草稿目录可写 | 无项目或项目只读时继承 `:read-only`,项目可写时继承 `:workspace`;维持原有广泛读取范围和本次联网能力,未启用域名过滤 |
219
- | `antigravity_cli` | 新会话使用独立 `--project UUID` 与 `--mode accept-edits`;项目规则允许讨论命令和草稿写入,禁止运行目录、技能目录、`~/.gemini` 写入及 MCP 工具调用;每次启动检查全局命令权限 | `read` 为项目目录添加 `write_file(目录)` deny,`write` 改为 allow;运行数据禁写不随项目授权放开。旧外部绑定只传原会话 ID,沿用提示词约束,可写时才额外传 `accept-edits` |
219
+ | `antigravity_cli` | 始终使用独立 `--project UUID` 与 `--mode accept-edits`;项目规则允许讨论命令和草稿写入,禁止运行目录、技能目录、`~/.gemini` 写入及 MCP 工具调用;每次交付前检查全局设置 | `read` 为项目目录添加 `write_file(目录)` deny,`write` 改为 allow;运行数据禁写不随项目授权放开 |
220
220
 
221
- agy 的规则按目录前缀递归匹配,不使用 `/**`;Deny > Ask > Allow,因此草稿不得放在只读项目、运行目录、技能或 `.gemini` 中,路径冲突会在交付前报告。原生工具权限不是操作系统沙箱:runner 同时要求 CLI 的 `toolPermission` 为默认 `request-review`,检查 CLI 和共享全局 allow,存在本工具讨论规则以外的 `command(...)` / `unsandboxed_command(...)` 放行时拒绝交付;未授权 shell 在非交互模式下被拒绝。MCP 工具调用统一 deny,避免经 MCP 写入项目。[原生权限文档](https://antigravity.google/docs/permissions?tab=cli)、[CLI 设置](https://antigravity.google/docs/settings?tab=cli)。
221
+ agy 只用一套方法控制权限:
222
222
 
223
- 独立讨论项目保存在 `~/.gemini/config/projects/<UUID>.json`,运行目录的 `agy-project-<参与者摘要>.json` 保存项目与实际会话绑定。所选原项目及用户全局配置不被改写;新项目只沿用资源目录,不继承原项目的命令放行。会话继续使用时保留这两份记录,不将生成的讨论会话手动迁入其他项目。agy 续接已有会话会恢复其原项目,传入另一 `--project` 不能替换它;因此历史绑定和从当前 agy 对话发起的主持保留提示词约束,交付回执以 `permission_enforcement: prompt` 标识,新管理会话为 `native`。缺失已登记的原生项目或会话绑定不匹配时拒绝交付。[项目权限继承](https://antigravity.google/docs/projects?tab=cli)。
223
+ 1. **只在本工具新建的会话中执行**:每位参与者对应一个独立原生讨论项目,权限只写在该项目里。外部已有会话(包括从当前 agy 对话发起主持、在启动配置中填写 `session_id`)会恢复其原项目,无法套用讨论项目权限,因此启动时即拒绝,交付时也再次拒绝。
224
+ 2. **项目内规则**:allow 为受限的讨论命令规则与草稿目录写入(`write` 时加项目目录写入);deny 为项目目录写入(`read` 时)、运行目录、技能目录、`~/.gemini` 写入与 `mcp(*)`。规则按目录前缀递归匹配,不使用 `/**`;Deny > Ask > Allow,因此草稿不得位于受保护目录中,冲突在交付前报告。
225
+ 3. **交付前检查全局设置**:CLI 的 `toolPermission` 须未设置或为 `request-review`;CLI 与共享全局 allow 中不得有任何 `command(...)`、`unsandboxed(...)`、`unsandboxed_command(...)` 或 `*` 放行,否则拒绝交付,因为全局放行会让 shell 绕过项目的文件规则。未授权 shell 在非交互模式下被拒绝。
226
+ 4. **不改用户全局设置**:本工具不向 agy 全局设置写入任何规则;`setup` 只移除早期版本记录在 `agy-permission.json` 中的全局规则(先备份到服务目录 `agy-settings.backup.json`),其他设置与用户规则不变。
224
227
 
225
- Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地 `app-server config/read`。配置检查先核对初始化回执中的版本;不支持或无法识别的版本拒绝交付。每次交付前只启动本地配置检查进程,不创建会话、不调用模型;发现已加载的旧 `sandbox_mode` / `sandbox_workspace_write` 或新配置未被采用就拒绝交付,避免旧设置覆盖本次限制。报错后需更新不支持的 CLI 或移除冲突的旧配置;接入程序不自动改写用户全局设置。首次与续接使用同一组权限参数,配置检查进程也随暂停或停止清理。[Codex 权限配置](https://learn.chatgpt.com/docs/permissions)
228
+ 原生工具权限不是操作系统沙箱;工具被拒绝时 agy 仍可能退出码为 0,验收以实际文件和网站结果为准。[原生权限文档](https://antigravity.google/docs/permissions?tab=cli)、[CLI 设置](https://antigravity.google/docs/settings?tab=cli)。
229
+
230
+ 独立讨论项目保存在 `~/.gemini/config/projects/<UUID>.json`,运行目录的 `agy-project-<参与者摘要>.json` 保存项目与实际会话绑定。所选原项目及用户全局配置不被改写;新项目只沿用资源目录,不继承原项目的命令放行。项目名为 `CoDiscuss · 讨论标题 · 参与者`,便于在 Antigravity 项目列表中区分。会话继续使用时保留这两份记录,不将生成的讨论会话手动迁入其他项目;讨论收束或存档后仍保留,以便之后在同一会话中继续讨论。只有网站确认房间已被删除(`房间不存在`)时,接入程序才删除该房间建立的讨论项目与记录,不触碰用户自己的项目。缺失已登记的原生项目或会话绑定不匹配时拒绝交付。[项目权限继承](https://antigravity.google/docs/projects?tab=cli)。
226
231
 
227
- agy 没有单次命令放行参数,新管理会话由上述项目规则放行。兼容旧会话和手动接入的 `setup` 仍可向 `~/.gemini/antigravity-cli/settings.json` 的 `permissions.allow` 写入规则:选中 Gemini 时询问一次(回车同意,`--yes` 视为同意),追加两条规则:只匹配 `codiscuss` 或 `codiscuss.cmd`、可选一个 `--site` 网址及上述子命令的 `command(regex:...)`,参数不得含分号、`&`、`|`、`<`、`>`、美元符号、反引号与圆括号,避免附加命令或 PowerShell 括号表达式;以及 `write_file(<服务目录>/drafts)`(Windows 保留盘符、使用正斜杠)。写入前备份原文件到服务目录 `agy-settings.backup.json`,`agy-permission.json` 记录已写规则;升级后重跑 setup 会替换本工具记录的旧命令规则,并将宽泛 `launches` 写规则替换为 `drafts`,不改其他设置与用户规则。旧会话通过单独 runner 运行时须按任务中的实际草稿目录配置 agy。文件无法解析时不写入并报告待处理;非交互且未加 `--yes` 只报告。
232
+ Codex 要求 CLI 0.163.0 或更新版本,使用 permission profiles 和本地 `app-server config/read`。配置检查先核对初始化回执中的版本;不支持或无法识别的版本拒绝交付。每次交付前只启动本地配置检查进程,不创建会话、不调用模型;发现已加载的旧 `sandbox_mode` / `sandbox_workspace_write` 或新配置未被采用就拒绝交付,避免旧设置覆盖本次限制。报错后需更新不支持的 CLI 或移除冲突的旧配置;接入程序不自动改写用户全局设置。首次与续接使用同一组权限参数,配置检查进程也随暂停或停止清理。[Codex 权限配置](https://learn.chatgpt.com/docs/permissions)
228
233
 
229
234
  2026-10-18 本机实测(本轮权限调整前):Claude 主持、Codex 与 Gemini 专家完成一场最小讨论。Windows 的 Codex / agy 经 PowerShell 调用时优先执行 npm 生成的 `codiscuss.ps1`;Codex elevated 沙箱内即使当前用户为 RemoteSigned 也拒绝 `.ps1`,Codex 自行改用 `cmd.exe` 调用 `codiscuss.cmd` 后成功。`agy` 非交互模式下被拒绝的工具也可能伴随退出码 0,最终仍以网站业务保存为准。`agy` 显式使用 `--print-timeout 0`,由房间的单次执行时限和整场截止控制停止,避免额外的 CLI 默认时限提前截断。
230
235
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai_co_discuss",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "bin": {
5
5
  "codiscuss": "runner/codiscuss.mjs"
6
6
  },
@@ -8,7 +8,7 @@
8
8
  "dev": "npm run build && wrangler pages dev --ip 127.0.0.1 --port 8787",
9
9
  "db:local": "wrangler d1 execute DB --local --file backend/schema.sql",
10
10
  "db:remote": "wrangler d1 execute DB --remote --file backend/schema.sql",
11
- "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_local.mjs",
11
+ "check": "node --check backend/worker.mjs && node --check web/app.js && node --check runner/discussion_runner.mjs && node --check runner/discussion_adapters.mjs && node --check runner/discussion_sidecar.mjs && node --check runner/codex_app_client.mjs && node --check runner/discussion_service.mjs && node --check runner/codiscuss.mjs && node --check runner/discussion_launch.mjs && node --check runner/discussion_setup.mjs && node --check runner/discussion_skills.mjs && node --check runner/discussion_tui.mjs && node --check runner/discussion_local.mjs",
12
12
  "runner": "node runner/discussion_runner.mjs",
13
13
  "service": "node runner/discussion_service.mjs",
14
14
  "build:ui": "vite build --config frontend/vite.config.mjs --mode markdown && vite build --config frontend/vite.config.mjs --mode backup && vite build --config frontend/vite.config.mjs",
@@ -37,7 +37,15 @@
37
37
  "vue": "3.5.44"
38
38
  },
39
39
  "description": "让不同 AI 在各自软件中协作的共享讨论空间",
40
- "keywords": ["ai", "multi-agent", "discussion", "cli", "codex", "claude", "gemini"],
40
+ "keywords": [
41
+ "ai",
42
+ "multi-agent",
43
+ "discussion",
44
+ "cli",
45
+ "codex",
46
+ "claude",
47
+ "gemini"
48
+ ],
41
49
  "homepage": "https://github.com/huyan1458/ai_co_discuss#readme",
42
50
  "repository": {
43
51
  "type": "git",
@@ -1,7 +1,7 @@
1
1
  /** Actual software entrypoints; never call model service APIs. */
2
2
  import { spawn, spawnSync } from 'node:child_process';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { existsSync, readFileSync, readdirSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
4
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
5
5
  import { isAbsolute, join, relative, resolve } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { createInterface } from 'node:readline';
@@ -144,14 +144,14 @@ export function agy_rule() {
144
144
  /** Read optional native settings; malformed settings must not silently disable a permission check. */
145
145
  function read_json(path) { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : {}; }
146
146
 
147
- /** Native headless review blocks unapproved shell commands; inherited command grants could bypass it. */
147
+ /** Native headless review blocks unapproved shell commands; any global command grant could bypass the project rules. */
148
148
  function check_agy_shell(user_home) {
149
149
  const cli = read_json(join(user_home, '.gemini', 'antigravity-cli', 'settings.json'));
150
150
  const shared = read_json(join(user_home, '.gemini', 'config', 'config.json'));
151
151
  const grants = [...(cli.permissions?.allow || []), ...(shared.userSettings?.globalPermissionGrants?.allow || [])];
152
152
  if ((cli.toolPermission && cli.toolPermission !== 'request-review')
153
- || grants.some(rule => rule !== agy_rule() && (/^(?:unsandboxed_)?command\(/.test(rule) || rule === '*'))) {
154
- throw new Error('agy 全局命令放行或 toolPermission 会绕过本场文件限制;请保留 request-review,重跑 setup 更新旧管理规则并移除额外命令放行,未交付讨论任务');
153
+ || grants.some(rule => rule === '*' || /^(?:command|unsandboxed|unsandboxed_command)\(/.test(rule))) {
154
+ throw new Error('agy 全局设置放行了命令或改了 toolPermission,会绕过讨论项目的权限;请移除全局 command / unsandboxed 放行并保留 request-review(本工具旧版写入的规则可运行 codiscuss setup 清理),未交付讨论任务');
155
155
  }
156
156
  }
157
157
 
@@ -183,12 +183,14 @@ function agy_project_grants(config, task, resources, user_home) {
183
183
  deny: [...new Set(protected_paths.map(write_rule)), 'mcp(*)'] };
184
184
  }
185
185
 
186
- /** Keep a private native project per participant; existing external conversations retain their original project. */
186
+ /** Run agy only inside a private native project this tool created; external conversations keep their own project, so they are refused. */
187
187
  export function prepare_agy_project(config, binding, task, user_home = homedir()) {
188
188
  const key = createHash('sha256').update(task.participant).digest('hex');
189
189
  const record_path = join(task.directory, `agy-project-${key}.json`);
190
190
  const record = read_json(record_path);
191
- if (binding.session_id && !record.project_id) return null;
191
+ if (binding.session_id && !record.project_id) {
192
+ throw new Error('agy 只在本工具新建的讨论会话中自动执行;该会话不是本工具创建的,无法套用讨论项目权限,未交付讨论任务');
193
+ }
192
194
  if (record.session_id && record.session_id !== binding.session_id) throw new Error('agy 项目记录与会话绑定不符,未交付讨论任务');
193
195
  check_agy_shell(user_home);
194
196
  const root = join(user_home, '.gemini', 'config');
@@ -200,13 +202,30 @@ export function prepare_agy_project(config, binding, task, user_home = homedir()
200
202
  const current = read_json(path);
201
203
  if (record.project_id && current.id !== project_id) throw new Error('agy 讨论项目已丢失或不匹配,未交付讨论任务');
202
204
  mkdirSync(join(root, 'projects'), { recursive: true });
203
- atomic_json(path, { ...current, id: project_id, name: `CoDiscuss · ${task.participant}`,
205
+ const name = ['CoDiscuss', task.room_title, task.participant].filter(Boolean).join(' · ');
206
+ atomic_json(path, { ...current, id: project_id, name,
204
207
  projectResources: { resources }, settings: {}, permissionGrants: { permissionGrants: grants, v2Migrated: true } });
205
208
  const saved = { project_id, session_id: record.session_id || '' };
206
209
  atomic_json(record_path, saved);
207
210
  return { project_id, bind: session_id => atomic_json(record_path, { ...saved, session_id }) };
208
211
  }
209
212
 
213
+ /**
214
+ * Delete the agy projects this room created after the room itself is deleted; the user's own projects are never touched.
215
+ * @returns {number} How many project records were cleared.
216
+ */
217
+ export function remove_agy_projects(directory, user_home = homedir()) {
218
+ if (!existsSync(directory)) return 0;
219
+ const records = readdirSync(directory).filter(name => /^agy-project-[a-f0-9]{64}\.json$/.test(name));
220
+ for (const file of records) {
221
+ const { project_id } = read_json(join(directory, file));
222
+ const path = join(user_home, '.gemini', 'config', 'projects', `${project_id}.json`);
223
+ if (/^[a-f0-9-]{36}$/.test(project_id || '') && read_json(path).id === project_id) rmSync(path);
224
+ rmSync(join(directory, file));
225
+ }
226
+ return records.length;
227
+ }
228
+
210
229
  /**
211
230
  * Allow only listed codiscuss website subcommands and draft writes for this run; user rules are added after.
212
231
  * Claude Code on Windows may run commands through its PowerShell tool instead of Bash, so both are listed.
@@ -328,11 +347,10 @@ function cli_invocation(config, binding, task, user_home) {
328
347
  const project = prepare_agy_project(config, binding, task, user_home);
329
348
  const args = ['--output-format', 'stream-json', '--print-timeout', '0', '--add-dir', task.directory,
330
349
  '--add-dir', task.draft_directory, '--add-dir', task.skills_directory];
331
- if (project || (project_directory(config) && config.project_access === 'write')) args.push('--mode', 'accept-edits');
332
- if (project) args.push('--project', project.project_id);
350
+ args.push('--mode', 'accept-edits', '--project', project.project_id);
333
351
  if (binding.session_id) args.push('--conversation', binding.session_id);
334
352
  args.push('-p', task.prompt);
335
- return { args, project, permission_enforcement: project ? 'native' : 'prompt' };
353
+ return { args, project };
336
354
  }
337
355
 
338
356
  /** Persist an emitted identity before the agent reads its execution credential file. */
@@ -428,8 +446,7 @@ async function cli_delivery(config, binding, task, spawn_process, user_home) {
428
446
  });
429
447
  child.stdin.on('error', error => { if (error.code !== 'EPIPE') task.on_failure(error.message); });
430
448
  child.stdin.end(invocation.input);
431
- return { session_id: state.session_id, status: 'accepted',
432
- ...(invocation.permission_enforcement ? { permission_enforcement: invocation.permission_enforcement } : {}) };
449
+ return { session_id: state.session_id, status: 'accepted' };
433
450
  }
434
451
 
435
452
  /** Queue one immutable request for the official App-managed Sidecar. */
@@ -111,6 +111,8 @@ function launch_config(home, input, room, directory, options) {
111
111
  const cwd = profile.cwd || input.cwd;
112
112
  if (cwd && (!existsSync(cwd) || !statSync(cwd).isDirectory())) throw new Error(`${name}:工作目录不存在或不是文件夹,请核对本次目录配置`);
113
113
  if (input.entry === 'conversation' && name === room.host) profile.session_id = input.current_session_id;
114
+ // agy runs only in sessions this tool creates, so its native discussion project always applies.
115
+ if (profile.adapter === 'antigravity_cli' && profile.session_id) throw new Error(`${name}:agy 不能绑定已有会话或从当前 agy 对话主持全自动讨论;请从网页或其他软件发起,agy 会由讨论服务新建会话`);
114
116
  participants[name] = profile;
115
117
  }
116
118
  return validate_config({ site: input.site, room: room.id || 'preview', token: options.token, participants,
@@ -6,7 +6,7 @@ import { DatabaseSync } from 'node:sqlite';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { setTimeout as sleep } from 'node:timers/promises';
9
- import { atomic_json, deliver_task, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
9
+ import { atomic_json, deliver_task, remove_agy_projects, setup_sidecar, sidecar_receipt, TASK_SUBCOMMANDS } from './discussion_adapters.mjs';
10
10
 
11
11
  const POLL_MS = 2000;
12
12
  const DELIVERY_RETRY_DELAYS_MS = [2000, 4000];
@@ -189,7 +189,7 @@ export class DiscussionRunner {
189
189
  try {
190
190
  const task = task_files(this.config, event, row.token, binding);
191
191
  invoked = true;
192
- const result = await this.deliver(adapter, binding, { ...task,
192
+ const result = await this.deliver(adapter, binding, { ...task, room_title: this.room_title,
193
193
  on_binding: session => this.store.bind(event.participant, session),
194
194
  on_process: child => { this.children.add(child); child.once('close', () => this.children.delete(child)); },
195
195
  on_output: (path, chunk) => appendFileSync(path, chunk, { mode: 0o600 }),
@@ -256,12 +256,19 @@ export class DiscussionRunner {
256
256
  /** Process one bounded iteration without waking models for empty checks. */
257
257
  async tick() {
258
258
  this.store.context(this.config);
259
- let room = await api(this.config, '');
259
+ let room;
260
+ try { room = await api(this.config, ''); }
261
+ catch (error) {
262
+ // Only a permanently deleted room drops its private agy projects; concluded or archived rooms keep them for later continuation.
263
+ if (/^HTTP 404: 房间不存在/.test(error.message)) remove_agy_projects(this.config.directory);
264
+ throw error;
265
+ }
260
266
  if (room.limits?.reached && (!room.limits.closing || room.limits.reason === 'time_limit') && room.runtime.status === 'running') {
261
267
  const generation = room.runtime.generation;
262
268
  room = await api(this.config, '/limits', { actor: '用户' });
263
269
  if (room.runtime.generation !== generation) this.stop_children();
264
270
  }
271
+ this.room_title = room.title;
265
272
  const run = room.runtime;
266
273
  if (run.run_id) this.store.scope(this.config, run.run_id);
267
274
  if (run.mode !== 'automatic' || run.status !== 'running' || run.expired) {
@@ -1,19 +1,20 @@
1
1
  /** npm bootstrap and per-user setup; never logs in or starts an AI conversation. */
2
- import { spawnSync } from 'node:child_process';
3
- import { accessSync, constants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
4
4
  import { homedir, tmpdir } from 'node:os';
5
5
  import { basename, delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
6
- import { createInterface } from 'node:readline/promises';
7
6
  import { fileURLToPath } from 'node:url';
8
7
  import { parseArgs } from 'node:util';
9
8
 
10
- import { agy_rule, atomic_json } from './discussion_adapters.mjs';
9
+ import { atomic_json } from './discussion_adapters.mjs';
11
10
  import { launch_settings, save_launch_settings } from './discussion_launch.mjs';
12
11
  import { service_home } from './discussion_service.mjs';
13
12
  import { install_skills } from './discussion_skills.mjs';
14
- export { agy_rule } from './discussion_adapters.mjs';
13
+ import { ask, checkbox_prompt, intro, outro, pad, short_path, spinner, step, styled, text_width, tone } from './discussion_tui.mjs';
14
+ export { checkbox_prompt } from './discussion_tui.mjs';
15
15
 
16
16
  const ROOT = fileURLToPath(new URL('../', import.meta.url));
17
+ const PACKAGE = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
17
18
  const PROBE_TIMEOUT = 10000;
18
19
  const MIN_NODE_MAJOR = 24;
19
20
  const DEFAULT_PORT = 8765;
@@ -30,7 +31,7 @@ const CLIENTS = {
30
31
  const OPTIONS = { yes: { type: 'boolean' }, clients: { type: 'string' }, python: { type: 'string' },
31
32
  codex: { type: 'string' }, claude: { type: 'string' }, antigravity: { type: 'string' }, cursor: { type: 'string' },
32
33
  port: { type: 'string' }, prefix: { type: 'string' }, 'skills-home': { type: 'string' },
33
- 'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
34
+ 'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
34
35
  const HELP = `用法:codiscuss install [选项] / codiscuss setup [选项]
35
36
  install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置已有安装。
36
37
  --yes 使用已检测且支持自动接入的软件,冲突保留并报告
@@ -44,7 +45,7 @@ install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置
44
45
  Gemini 使用 agy;Grok 使用 Cursor CLI(agent / cursor-agent)。仅检测 CLI。
45
46
  Cursor 当前只安装技能,可手动参与;全自动接入尚未提供。已有软件设置保留。
46
47
  显式软件路径可更新已支持的 CLI 配置。安装不会开始讨论。
47
- 结果输出 JSON,中文摘要写入 stderr;退出码 0=完成,2=配置待处理,1=失败。
48
+ 中文摘要写入 stderr;输出被重定向或加 --json 时结果 JSON 写入 stdout;退出码 0=完成,2=配置待处理,1=失败。
48
49
  `;
49
50
 
50
51
  /** Read local settings stored outside the npm installation. */
@@ -64,6 +65,13 @@ function setup_environment(options) {
64
65
  return { home, previous, python, port };
65
66
  }
66
67
 
68
+ /** Summarise the checked runtime in one short line for the styled installer. */
69
+ function environment_line(python, port) {
70
+ const probe = spawnSync(python, ['-c', 'import sys; print(".".join(map(str, sys.version_info[:3])))'],
71
+ { encoding: 'utf8', windowsHide: true, timeout: PROBE_TIMEOUT });
72
+ return `Node ${process.version.slice(1)} · Python ${probe.stdout?.trim() || short_path(python)} · 端口 ${port}`;
73
+ }
74
+
67
75
  /** Find executable PATH entries, explicitly allowing Cursor's Windows .cmd launcher. */
68
76
  function path_program(name, search_path = process.env.PATH || '', allow_cmd = false) {
69
77
  const suffixes = process.platform === 'win32' ? (allow_cmd ? ['.exe', '.cmd'] : ['.exe']) : [''];
@@ -140,15 +148,13 @@ export function detect_clients(options = {}, search_path = process.env.PATH || '
140
148
  /** Select detected clients interactively or through explicit noninteractive options. */
141
149
  async function selected_clients(options, detected) {
142
150
  let choice = options.clients;
143
- const available = detected.filter(item => item.executable);
144
- const defaults = available.filter(item => item.adapter);
145
- for (const item of detected) process.stderr.write(`${item.name}:${item.executable || '未检测到 CLI'}\n`);
146
151
  if (choice === undefined && !options.yes) {
147
152
  if (!process.stdin.isTTY) throw new Error('非交互终端请使用 --yes 或 --clients;只配置网站可用 --clients none。');
148
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
149
- try { choice = await prompt.question(`选择软件(逗号分隔,回车选择 ${defaults.map(item => item.key).join(',') || 'none'}):`); }
150
- finally { prompt.close(); }
151
- }
153
+ const items = detected.map(item => ({ label: item.name, hint: item.executable ? short_path(item.executable) : '未检测到 CLI',
154
+ checked: Boolean(item.executable && item.adapter), disabled: !item.executable }));
155
+ const checked = await checkbox_prompt('选择要接入的软件', items);
156
+ choice = detected.filter((_, index) => checked[index]).map(item => item.key).join(',') || 'none';
157
+ } else if (!styled) for (const item of detected) process.stderr.write(`${item.name}:${item.executable || '未检测到 CLI'}\n`);
152
158
  return requested_clients(choice, detected);
153
159
  }
154
160
 
@@ -181,12 +187,9 @@ async function choose_profile(client, current, options) {
181
187
  const differs = current && (current.adapter !== client.adapter || !same_executable(current.executable, client.executable));
182
188
  let update = Boolean(options[client.key]), acknowledged = false;
183
189
  if (differs && !update && !options.yes && process.stdin.isTTY) {
184
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
185
- try {
186
- const choice = await prompt.question(`${client.name} 已有配置:${current.adapter || '未设置类型'} / ${current.executable || '未设置路径'}\n检测到 CLI:${client.executable}\n保留已有配置或更新为此 CLI?[保留/更新,默认保留]:`);
187
- update = ['更新', 'update', 'u'].includes(choice.trim().toLowerCase());
188
- acknowledged = !choice.trim() || ['保留', 'keep', 'k'].includes(choice.trim().toLowerCase());
189
- } finally { prompt.close(); }
190
+ const choice = await ask(`${client.name} 已有配置:${current.adapter || '未设置类型'} / ${current.executable || '未设置路径'}\n检测到 CLI:${client.executable}\n保留已有配置或更新为此 CLI?`, '[保留/更新,默认保留]');
191
+ update = ['更新', 'update', 'u'].includes(choice.trim().toLowerCase());
192
+ acknowledged = !choice.trim() || ['保留', 'keep', 'k'].includes(choice.trim().toLowerCase());
190
193
  }
191
194
  if (differs && !update) return { client: client.name, status: acknowledged ? 'retained' : 'pending',
192
195
  effective: { adapter: current.adapter, executable: current.executable }, detected,
@@ -194,65 +197,44 @@ async function choose_profile(client, current, options) {
194
197
  return { client: client.name, status: 'configured', effective: detected };
195
198
  }
196
199
 
197
- /** Allow answer files only; service runtime data never belongs to the managed write rule. */
198
- export function agy_rules(home) {
199
- // Verified with agy 1.4.2 on Windows: the rule matches forward slashes with the drive letter kept.
200
- const drafts = join(resolve(home), 'drafts').replaceAll('\\', '/');
201
- return [agy_rule(), `write_file(${drafts})`];
202
- }
203
-
204
- /** Ask once before adding the rules; --yes counts as consent, other noninteractive runs only report. */
205
- async function agy_consent(path, rules, options) {
206
- if (options.yes) return true;
207
- if (!process.stdin.isTTY) return false;
208
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
209
- try {
210
- const answer = await prompt.question(`允许 Gemini(agy)在全自动讨论中执行讨论命令并写讨论草稿?将向 ${path} 的 permissions.allow 追加 ${rules.length} 条只匹配讨论命令与草稿目录的规则 [Y/n]:`);
211
- return !['n', 'no', '否'].includes(answer.trim().toLowerCase());
212
- } finally { prompt.close(); }
213
- }
214
-
215
- /** Keep the installer-managed allow rules current, never touching other settings or user rules. */
216
- export async function allow_agy_command(home, user_home, options) {
217
- const path = join(user_home, ...AGY_SETTINGS);
200
+ /**
201
+ * Remove global agy rules that earlier versions added; agy permissions now live only in each discussion project.
202
+ * Only rules recorded by the installer are removed, after a backup; other settings and user rules stay untouched.
203
+ */
204
+ export function remove_legacy_agy_rules(home, user_home) {
218
205
  const record = join(home, AGY_RULE_RECORD);
219
- const rules = agy_rules(home);
206
+ if (!existsSync(record)) return null;
207
+ const path = join(user_home, ...AGY_SETTINGS);
208
+ const saved = JSON.parse(readFileSync(record, 'utf8'));
209
+ const legacy = saved.rules || (saved.rule ? [saved.rule] : []);
220
210
  let settings = {};
221
211
  try { if (existsSync(path)) settings = JSON.parse(readFileSync(path, 'utf8')); }
222
- catch { return { status: 'invalid', path, rules }; }
212
+ catch { return { client: 'Gemini', status: 'invalid', path }; }
223
213
  const allow = settings.permissions?.allow ?? [];
224
- if (!Array.isArray(allow)) return { status: 'invalid', path, rules };
225
- const saved = existsSync(record) ? JSON.parse(readFileSync(record, 'utf8')) : {};
226
- const previous = saved.rules || (saved.rule ? [saved.rule] : []);
227
- const stale = previous.some(rule => !rules.includes(rule) && allow.includes(rule));
228
- mkdirSync(home, { recursive: true });
229
- if (rules.every(rule => allow.includes(rule)) && !stale) {
230
- if (previous.join('\n') !== rules.join('\n')) atomic_json(record, { rules });
231
- return { status: 'unchanged', path, rules };
214
+ if (!Array.isArray(allow)) return { client: 'Gemini', status: 'invalid', path };
215
+ const next = allow.filter(rule => !legacy.includes(rule));
216
+ if (next.length !== allow.length) {
217
+ copyFileSync(path, join(home, AGY_SETTINGS_BACKUP));
218
+ atomic_json(path, { ...settings, permissions: { ...settings.permissions, allow: next } });
232
219
  }
233
- if (!await agy_consent(path, rules, options)) return { status: options.yes || process.stdin.isTTY ? 'declined' : 'pending', path, rules };
234
- mkdirSync(dirname(path), { recursive: true });
235
- if (existsSync(path)) copyFileSync(path, join(home, AGY_SETTINGS_BACKUP));
236
- const next = [...allow.filter(item => !previous.includes(item) && !rules.includes(item)), ...rules];
237
- atomic_json(path, { ...settings, permissions: { ...settings.permissions, allow: next } });
238
- atomic_json(record, { rules });
239
- return { status: 'allowed', path, rules };
220
+ rmSync(record);
221
+ return { client: 'Gemini', status: next.length !== allow.length ? 'removed' : 'unchanged', path };
240
222
  }
241
223
 
242
224
  /** Save the selected effective profiles and copy complete skills without overwriting edits. */
243
225
  async function configure_clients(home, selected, options, existing) {
244
226
  const participants = { ...existing };
245
- const skills = [], profiles = [], permissions = [];
227
+ const skills = [], profiles = [];
246
228
  const user_home = resolve(options['skills-home'] || homedir());
247
229
  for (const client of selected) {
248
230
  const profile = await choose_profile(client, participants[client.name], options);
249
231
  profiles.push(profile);
250
232
  if (profile.status === 'configured') participants[client.name] = { ...participants[client.name], ...profile.effective };
251
233
  skills.push({ client: client.name, results: install_skills(join(user_home, ...client.skills)) });
252
- if (client.key === 'antigravity') permissions.push({ client: client.name, ...await allow_agy_command(home, user_home, options) });
253
234
  }
254
235
  save_launch_settings(home, { participants });
255
- return { skills, profiles, permissions };
236
+ const legacy = remove_legacy_agy_rules(home, user_home);
237
+ return { skills, profiles, permissions: legacy ? [legacy] : [] };
256
238
  }
257
239
 
258
240
  /** Exclude npm exec's temporary command wrappers when checking persistent availability. */
@@ -291,8 +273,7 @@ function setup_issues(result) {
291
273
  if (skill.status === 'conflict') issues.push(`${group.client} 技能冲突,已保留 ${skill.path};请核对并移走需替换的旧副本,再运行 setup。`);
292
274
  }
293
275
  for (const permission of result.permissions) {
294
- if (permission.status === 'invalid') issues.push(`${permission.client} 设置文件无法解析,未写入讨论命令规则:${permission.path};修复后重新运行 setup。`);
295
- if (['declined', 'pending'].includes(permission.status)) issues.push(`${permission.client} 未放行讨论命令,全自动讨论会被拦截;运行 codiscuss setup --clients antigravity --yes 授权。`);
276
+ if (permission.status === 'invalid') issues.push(`${permission.client} 设置文件无法解析,未能移除旧版全局放行规则:${permission.path};修复后重新运行 setup,否则全自动讨论会拒绝交付给 agy。`);
296
277
  }
297
278
  if (result.command.status !== 'ready') issues.push(result.command.action);
298
279
  return issues;
@@ -300,10 +281,19 @@ function setup_issues(result) {
300
281
 
301
282
  /** Configure Python, selected clients and skills, returning partial results on startup failure. */
302
283
  async function setup(options) {
284
+ intro('配置 AI CoDiscuss', PACKAGE.version, PACKAGE.description);
303
285
  const { home, previous, python, port } = setup_environment(options);
304
- const selected = await selected_clients(options, detect_clients(options));
286
+ if (styled && !process.env.CODISCUSS_TUI_NESTED) step('运行环境就绪', [environment_line(python, port)]);
287
+ const scan = spinner('检测已安装的 AI 软件');
288
+ const detected = detect_clients(options);
289
+ const width = Math.max(...detected.map(item => text_width(item.name)));
290
+ const listed = options.clients !== undefined || options.yes
291
+ ? detected.map(item => `${pad(item.name, width)} ${tone.dim(item.executable ? short_path(item.executable) : '未检测到 CLI')}`) : [];
292
+ scan.done(`检测到 ${detected.filter(item => item.executable).length} 个可接入的 CLI`, listed);
293
+ const selected = await selected_clients(options, detected);
305
294
  const configured = await configure_clients(home, selected, options, launch_settings(home).participants);
306
295
  atomic_json(join(home, 'local-settings.json'), { ...previous, python, port });
296
+ step('已写入软件配置与技能', configured.skills.map(group => `${pad(group.client, width)} ${tone.dim(short_path(dirname(group.results[0].path)))}`));
307
297
  const result = { home, python, clients: selected.map(item => item.name), ...configured,
308
298
  command: check_command(), ai_verified: false };
309
299
  result.issues = setup_issues(result);
@@ -311,10 +301,13 @@ async function setup(options) {
311
301
  result.status = result.configured ? 'configured' : 'needs_attention';
312
302
  if (!options['no-start']) {
313
303
  const { local_main } = await import('./discussion_local.mjs');
304
+ const launch = spinner('启动本机网站与讨论服务');
314
305
  try {
315
306
  result.local = await local_main([options['no-open'] ? 'start' : 'open'], () => {});
307
+ launch.done('网站与讨论服务已在后台运行');
316
308
  if (result.configured) result.status = 'ready';
317
309
  } catch (error) {
310
+ launch.fail(error.message);
318
311
  result.status = 'start_failed';
319
312
  result.issues.push(`本机启动失败:${error.message};配置已保存,修复后重新运行 setup。`);
320
313
  }
@@ -323,7 +316,8 @@ async function setup(options) {
323
316
  }
324
317
 
325
318
  /** Print a concise human receipt while keeping stdout machine-readable. */
326
- function report_setup(result) {
319
+ function report_setup(result, options = {}) {
320
+ if (styled) return report_styled(result, options);
327
321
  const labels = { ready: '安装配置完成,网站与讨论服务已就绪', configured: '配置完成,本次未启动网站',
328
322
  needs_attention: '配置待处理', start_failed: '本机启动失败' };
329
323
  const lines = [labels[result.status]];
@@ -331,16 +325,42 @@ function report_setup(result) {
331
325
  ? `${profile.client}:仅安装技能,可手动参与,尚不支持全自动接入。`
332
326
  : `${profile.client}:${profile.status === 'configured' ? 'CLI 已配置' : '沿用已有配置'}(${profile.effective.adapter} / ${profile.effective.executable || '未设置路径'})`);
333
327
  for (const permission of result.permissions) {
334
- if (['allowed', 'unchanged'].includes(permission.status)) lines.push(`${permission.client}:已放行讨论命令(${permission.path})`);
328
+ if (permission.status === 'removed') lines.push(`${permission.client}:已移除旧版全局放行规则(${permission.path})`);
335
329
  }
336
330
  for (const group of result.skills) lines.push(`${group.client} Skill:${group.results.filter(skill => skill.status !== 'conflict').length}/${group.results.length} 份文件就绪,目录 ${dirname(group.results[0].path)}`);
337
331
  if (result.command.path) lines.push(`持久命令:${result.command.path}(${result.command.status})`);
338
- if (result.local?.running) lines.push(`本机网站:${result.local.url}`);
332
+ if (result.local?.running) {
333
+ lines.push(`本机网站:${result.local.url}${options['no-open'] ? '' : '(已在浏览器打开)'}`,
334
+ '网站与讨论服务在后台运行,关闭此终端不影响;查看状态 codiscuss local status,停止 codiscuss local stop;电脑重启后运行 codiscuss local open 重新启动。');
335
+ }
339
336
  lines.push(...result.issues, '真实 AI 执行尚未验证;可从网页创建讨论,或在新会话调用 Skill。');
340
337
  if (result.skills.length) lines.push('若新会话未发现技能,请让 AI 读取上述目录内的 discuss-host/SKILL.md 和 discuss-participant/SKILL.md。');
341
338
  process.stderr.write(`${lines.join('\n')}\n`);
342
339
  }
343
340
 
341
+ /** Close the styled installer with aligned results, open issues and the commands to manage the site. */
342
+ function report_styled(result, options) {
343
+ const titles = { ready: ['安装完成', tone.green('◆')], configured: ['配置完成,本次未启动网站', tone.green('◆')],
344
+ needs_attention: ['配置完成,仍有待处理项', tone.yellow('▲')], start_failed: ['本机启动失败', tone.red('■')] };
345
+ const [title, mark] = titles[result.status];
346
+ const rows = [];
347
+ if (result.local?.running) rows.push(['网站', tone.cyan(result.local.url), options['no-open'] ? '' : '已在浏览器打开']);
348
+ for (const profile of result.profiles) rows.push(profile.status === 'manual' ? [profile.client, '仅安装技能,可手动参与', '尚不支持全自动接入']
349
+ : [profile.client, `${profile.status === 'configured' ? 'CLI 已配置' : '沿用已有配置'} · ${profile.effective.adapter}`, short_path(profile.effective.executable) || '未设置路径']);
350
+ for (const permission of result.permissions) {
351
+ if (permission.status === 'removed') rows.push([permission.client, '已移除旧版全局放行规则', short_path(permission.path)]);
352
+ }
353
+ if (result.skills.length) rows.push(['技能', result.skills.map(group => `${group.client} ${group.results.filter(skill => skill.status !== 'conflict').length}/${group.results.length}`).join(' · '), 'discuss-host · discuss-participant · discuss-report']);
354
+ if (result.command.path) rows.push(['命令', `codiscuss ${tone.dim(`(${result.command.status})`)}`, short_path(result.command.path)]);
355
+ const notes = result.issues.map(issue => tone.yellow(`▲ ${issue}`));
356
+ if (result.local?.running) notes.push(tone.dim('网站与讨论服务在后台运行,关闭此终端不影响'),
357
+ `${tone.cyan('codiscuss local status')} ${tone.dim('查看状态')}`, `${tone.cyan('codiscuss local stop')} ${tone.dim('停止')}`,
358
+ `${tone.cyan('codiscuss local open')} ${tone.dim('重启电脑后重新启动')}`);
359
+ const closing = result.local?.running ? `在网页新建讨论,或在 AI 会话里调用 ${tone.cyan('discuss-host')} ${tone.dim('· 真实 AI 执行尚未验证')}`
360
+ : `运行 ${tone.cyan('codiscuss local open')} 启动网站`;
361
+ outro(title, rows, notes, closing, mark);
362
+ }
363
+
344
364
  /** Locate npm's JavaScript entrypoint so paths with spaces never need shell quoting. */
345
365
  function npm_entry() {
346
366
  const candidates = [process.env.npm_execpath, join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'),
@@ -352,31 +372,44 @@ function npm_entry() {
352
372
  return entry;
353
373
  }
354
374
 
355
- /** Execute npm without lifecycle scripts; surface actionable npm errors. */
375
+ /** Execute npm without lifecycle scripts asynchronously, so a progress spinner can animate. */
356
376
  function npm_run(entry, args) {
357
- const result = spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8', windowsHide: true });
358
- if (result.status !== 0) throw new Error(result.stderr || result.error?.message || 'npm 执行失败');
359
- return result.stdout.trim();
377
+ return new Promise((done, fail) => {
378
+ const child = spawn(process.execPath, [entry, ...args], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
379
+ let stdout = '', stderr = '';
380
+ child.stdout.on('data', data => { stdout += data; });
381
+ child.stderr.on('data', data => { stderr += data; });
382
+ child.on('error', fail);
383
+ child.on('close', code => (code === 0 ? done(stdout.trim()) : fail(new Error(stderr || 'npm 执行失败'))));
384
+ });
360
385
  }
361
386
 
362
387
  /** Install a real package copy, never a symlink into a disposable npx cache. */
363
- function install(args, options) {
364
- setup_environment(options);
388
+ async function install(args, options) {
389
+ intro('安装 AI CoDiscuss', PACKAGE.version, PACKAGE.description);
390
+ const { python, port } = setup_environment(options);
365
391
  requested_clients(options.clients, detect_clients(options));
392
+ if (styled) step('运行环境就绪', [environment_line(python, port)]);
366
393
  const entry = npm_entry();
367
394
  const temporary = mkdtempSync(join(tmpdir(), 'codiscuss-install-'));
368
395
  const prefix = options.prefix ? ['--prefix', resolve(options.prefix)] : [];
396
+ const progress = spinner('安装 codiscuss 命令');
369
397
  try {
370
- const packed = JSON.parse(npm_run(entry, ['pack', ROOT, '--ignore-scripts', '--json', '--pack-destination', temporary]));
371
- npm_run(entry, ['install', '--global', ...prefix, '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', join(temporary, packed[0].filename)]);
372
- const installed = join(npm_run(entry, ['root', '--global', ...prefix]), 'ai_co_discuss', 'runner', 'codiscuss.mjs');
373
- const install_prefix = npm_run(entry, ['prefix', '--global', ...prefix]);
374
- process.stderr.write(`命令目录:${process.platform === 'win32' ? install_prefix : join(install_prefix, 'bin')}\n`);
398
+ const packed = JSON.parse(await npm_run(entry, ['pack', ROOT, '--ignore-scripts', '--json', '--pack-destination', temporary]));
399
+ await npm_run(entry, ['install', '--global', ...prefix, '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', join(temporary, packed[0].filename)]);
400
+ // npm masks UUID-like path segments as *** in its output, so an explicit prefix is resolved locally.
401
+ const install_prefix = options.prefix ? resolve(options.prefix) : await npm_run(entry, ['prefix', '--global']);
402
+ const global_root = process.platform === 'win32' ? join(install_prefix, 'node_modules') : join(install_prefix, 'lib', 'node_modules');
403
+ const installed = join(global_root, 'ai_co_discuss', 'runner', 'codiscuss.mjs');
404
+ const directory = process.platform === 'win32' ? install_prefix : join(install_prefix, 'bin');
405
+ progress.done('已安装 codiscuss 命令', [tone.dim(short_path(directory))]);
406
+ if (!styled) process.stderr.write(`命令目录:${directory}\n`);
375
407
  const result = spawnSync(process.execPath, [installed, 'setup', ...args], { stdio: 'inherit', windowsHide: true,
376
- env: { ...process.env, PATH: persistent_search_path() } });
408
+ env: { ...process.env, PATH: persistent_search_path(), CODISCUSS_TUI_NESTED: '1' } });
377
409
  process.exitCode = result.status ?? 1;
378
410
  if (result.error) throw result.error;
379
- } finally { rmSync(temporary, { recursive: true, force: true }); }
411
+ } catch (error) { progress.fail(error.message); throw error; }
412
+ finally { rmSync(temporary, { recursive: true, force: true }); }
380
413
  }
381
414
 
382
415
  /** Dispatch bootstrap or setup, leaving website and launch commands untouched. */
@@ -385,8 +418,8 @@ export async function setup_main(command, args) {
385
418
  if (values.help) return process.stdout.write(HELP);
386
419
  if (command === 'install') return install(args, values);
387
420
  const result = await setup(values);
388
- report_setup(result);
389
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
421
+ report_setup(result, values);
422
+ if (values.json || !process.stdout.isTTY) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
390
423
  process.exitCode = result.status === 'start_failed' ? 1 : result.status === 'needs_attention' ? ATTENTION_EXIT_CODE : 0;
391
424
  return result;
392
425
  }
@@ -0,0 +1,171 @@
1
+ /** Installer presentation: a styled gutter on an interactive terminal, the original plain lines otherwise. */
2
+ import { homedir } from 'node:os';
3
+ import { sep } from 'node:path';
4
+ import { emitKeypressEvents } from 'node:readline';
5
+ import { createInterface } from 'node:readline/promises';
6
+
7
+ const PATH_WIDTH = 56;
8
+ const SPIN_FRAMES = ['◒', '◐', '◓', '◑'];
9
+ const SPIN_MS = 80;
10
+ const LOGO = [
11
+ ['█▀▀', '█▀█', '█▀▄', '█', '█▀▀', '█▀▀', '█ █', '█▀▀', '█▀▀'],
12
+ ['█ ', '█ █', '█ █', '█', '▀▀█', '█ ', '█ █', '▀▀█', '▀▀█'],
13
+ ['▀▀▀', '▀▀▀', '▀▀ ', '▀', '▀▀▀', '▀▀▀', '▀▀▀', '▀▀▀', '▀▀▀'],
14
+ ];
15
+ const PREFIX_LETTERS = 2;
16
+ const GRADIENT = [[91, 140, 255], [167, 139, 250], [240, 171, 252]];
17
+
18
+ export const styled = Boolean(process.stderr.isTTY) && !process.env.NO_COLOR;
19
+ const paint = (code, text) => (styled ? `\x1b[${code}m${text}\x1b[0m` : text);
20
+ export const tone = {
21
+ dim: text => paint('2', text), bold: text => paint('1', text), cyan: text => paint('36', text),
22
+ green: text => paint('32', text), yellow: text => paint('33', text), red: text => paint('31', text),
23
+ gray: text => paint('90', text),
24
+ };
25
+ const bar = () => tone.gray('│');
26
+ const write = text => process.stderr.write(text);
27
+
28
+ /** Display width that counts CJK and fullwidth characters as two terminal columns. */
29
+ export function text_width(text) {
30
+ let width = 0;
31
+ for (const char of text.replace(/\x1b\[[0-9;]*m/g, '')) {
32
+ const code = char.codePointAt(0);
33
+ width += (code >= 0x1100 && code <= 0x115f) || (code >= 0x2e80 && code <= 0xa4cf)
34
+ || (code >= 0xac00 && code <= 0xd7a3) || (code >= 0xf900 && code <= 0xfaff)
35
+ || (code >= 0xfe30 && code <= 0xfe4f) || (code >= 0xff00 && code <= 0xff60) || (code >= 0xffe0 && code <= 0xffe6) ? 2 : 1;
36
+ }
37
+ return width;
38
+ }
39
+
40
+ /** Shorten a path for display: home becomes ~ and long middles collapse to …, keeping both ends readable. */
41
+ export function short_path(path, max = PATH_WIDTH) {
42
+ const home = homedir();
43
+ const value = path && path.toLowerCase().startsWith(home.toLowerCase()) ? `~${path.slice(home.length)}` : path || '';
44
+ if (value.length <= max) return value;
45
+ const parts = value.split(/([\\/])/);
46
+ const separator = parts[1] || sep;
47
+ const names = parts.filter((_, index) => index % 2 === 0);
48
+ return [...names.slice(0, 2), '…', ...names.slice(-2)].join(separator);
49
+ }
50
+
51
+ /** Pad a label to a display width so mixed Chinese and Latin columns line up. */
52
+ export const pad = (text, width) => text + ' '.repeat(Math.max(0, width - text_width(text)));
53
+
54
+ /** Colour one wordmark row along a left-to-right gradient; the "co" prefix stays neutral. */
55
+ function logo_row(letters) {
56
+ const prefix = letters.slice(0, PREFIX_LETTERS).join(' ');
57
+ const rest = `${letters.slice(PREFIX_LETTERS).join(' ')}`;
58
+ const chars = [...rest];
59
+ const shade = index => {
60
+ const position = (index / Math.max(1, chars.length - 1)) * (GRADIENT.length - 1);
61
+ const [from, to] = [GRADIENT[Math.floor(position)], GRADIENT[Math.ceil(position)]];
62
+ const mix = position - Math.floor(position);
63
+ return from.map((value, channel) => Math.round(value + (to[channel] - value) * mix)).join(';');
64
+ };
65
+ return `${paint('38;2;122;127;138', prefix)} ${chars.map((char, index) => (char === ' ' ? char : paint(`38;2;${shade(index)}`, char))).join('')}`;
66
+ }
67
+
68
+ /** Print the wordmark and open the gutter; a nested setup run skips it. */
69
+ export function intro(title, version, tagline) {
70
+ if (!styled || process.env.CODISCUSS_TUI_NESTED) return;
71
+ write(`\n${LOGO.map(row => ` ${logo_row(row)}`).join('\n')}\n`);
72
+ write(` ${tone.dim(`${tagline} · v${version}`)}\n\n${tone.gray('┌')} ${tone.bold(title)}\n${bar()}\n`);
73
+ }
74
+
75
+ /** Print a finished step with optional detail lines; plain mode leaves output to the caller. */
76
+ export function step(title, lines = [], mark = tone.green('◇')) {
77
+ if (!styled) return;
78
+ write(`${mark} ${title}\n${lines.map(line => `${bar()} ${line}\n`).join('')}${bar()}\n`);
79
+ }
80
+
81
+ /** Animate a step while asynchronous work runs; synchronous work simply shows the first frame. */
82
+ export function spinner(title) {
83
+ if (!styled) return { done: () => {}, fail: () => {} };
84
+ let frame = 0;
85
+ const render = () => write(`\r\x1b[2K${tone.cyan(SPIN_FRAMES[frame++ % SPIN_FRAMES.length])} ${title}${tone.dim('…')}`);
86
+ render();
87
+ const timer = setInterval(render, SPIN_MS);
88
+ const clear = () => { clearInterval(timer); write('\r\x1b[2K'); };
89
+ return {
90
+ done: (next = title, lines = []) => { clear(); step(next, lines); },
91
+ fail: message => { clear(); step(tone.red(title), [tone.red(message)], tone.red('■')); },
92
+ };
93
+ }
94
+
95
+ /** Ask one free-text question inside the gutter, falling back to a plain prompt. */
96
+ export async function ask(question, hint = '') {
97
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
98
+ try {
99
+ if (!styled) return await prompt.question(`${question}${hint}:`);
100
+ write(`${tone.cyan('◆')} ${question.split('\n').join(`\n${bar()} `)}\n`);
101
+ const answer = await prompt.question(`${bar()} ${tone.dim(hint)} ${tone.cyan('›')} `);
102
+ write(`${bar()}\n`);
103
+ return answer;
104
+ } finally { prompt.close(); }
105
+ }
106
+
107
+ /** Draw one checkbox row; disabled rows are dimmed and cannot be focused. */
108
+ function checkbox_row(item, focused, checked) {
109
+ const pointer = focused ? tone.cyan('›') : ' ';
110
+ if (item.disabled) return `${bar()} ${pointer} ${tone.gray(`○ ${item.label}`)} ${tone.gray(item.hint)}`;
111
+ const box = checked ? tone.green('◼') : tone.dim('◻');
112
+ return `${bar()} ${pointer} ${box} ${focused ? tone.bold(item.label) : item.label} ${tone.dim(item.hint)}`;
113
+ }
114
+
115
+ /**
116
+ * Render a terminal checkbox list: arrows move, space toggles, enter confirms, Ctrl+C cancels.
117
+ * @param {{label: string, hint: string, checked: boolean, disabled: boolean}[]} items
118
+ * @returns {Promise<boolean[]>} The checked state of every item.
119
+ */
120
+ export function checkbox_prompt(message, items, input = process.stdin, output = process.stderr) {
121
+ const checked = items.map(item => item.checked && !item.disabled);
122
+ const width = Math.max(...items.map(item => text_width(item.label)));
123
+ const rows = items.map(item => ({ ...item, label: pad(item.label, width) }));
124
+ let cursor = Math.max(0, items.findIndex(item => !item.disabled)), drawn = 0;
125
+ const move = offset => {
126
+ for (let next = cursor + offset; next >= 0 && next < items.length; next += offset) {
127
+ if (!items[next].disabled) { cursor = next; return; }
128
+ }
129
+ };
130
+ const draw = (lines) => {
131
+ if (drawn) output.write(`\x1b[${drawn}A`);
132
+ output.write(`${lines.map(line => `\x1b[2K${line}`).join('\n')}\n`);
133
+ if (lines.length < drawn) output.write(`\x1b[J`);
134
+ drawn = lines.length;
135
+ };
136
+ const active = () => [`${tone.cyan('◆')} ${message}`, ...rows.map((item, index) => checkbox_row(item, index === cursor, checked[index])),
137
+ `${tone.gray('└')} ${tone.dim('↑↓ 移动 · 空格 勾选 · 回车 确认')}`];
138
+ return new Promise((done, fail) => {
139
+ const finish = error => {
140
+ input.off('keypress', on_key);
141
+ input.setRawMode(false);
142
+ input.pause();
143
+ const chosen = items.filter((_, index) => checked[index]).map(item => item.label).join('、') || '不接入软件,只配置网站';
144
+ draw(error ? [`${tone.red('■')} ${message}`, `${bar()} ${tone.dim('已取消')}`] : [`${tone.green('◇')} ${message}`, `${bar()} ${tone.dim(chosen)}`, bar()]);
145
+ output.write('\x1b[?25h');
146
+ if (error) fail(error); else done(checked);
147
+ };
148
+ const on_key = (_text, key = {}) => {
149
+ if (key.ctrl && key.name === 'c') return finish(new Error('已取消安装选择'));
150
+ if (key.name === 'up' || key.name === 'k') move(-1);
151
+ else if (key.name === 'down' || key.name === 'j') move(1);
152
+ else if (key.name === 'space' && !items[cursor]?.disabled) checked[cursor] = !checked[cursor];
153
+ else if (key.name === 'return' || key.name === 'enter') return finish();
154
+ draw(active());
155
+ };
156
+ emitKeypressEvents(input);
157
+ input.setRawMode(true);
158
+ input.resume();
159
+ input.on('keypress', on_key);
160
+ output.write('\x1b[?25l');
161
+ draw(active());
162
+ });
163
+ }
164
+
165
+ /** Close the gutter with a titled block of aligned rows, notes and a final line. */
166
+ export function outro(title, rows, notes, closing, mark = tone.green('◆')) {
167
+ const width = Math.max(0, ...rows.map(([label]) => text_width(label)));
168
+ const body = [...rows.map(([label, value, detail]) => [`${tone.dim(pad(label, width))} ${value}`,
169
+ ...(detail ? [`${' '.repeat(width)} ${tone.dim(detail)}`] : [])]).flat(), ...(notes.length ? ['', ...notes] : [])];
170
+ write(`${mark} ${tone.bold(title)}\n${bar()}\n${body.map(line => `${bar()} ${line}\n`).join('')}${bar()}\n${tone.gray('└')} ${closing}\n\n`);
171
+ }