ai_co_discuss 0.1.0 → 0.1.1

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/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、保存本机软件配置,启动网站与讨论服务并打开浏览器。安装包已带运行代码和构建好的网页,无需另行克隆仓库或构建前端;安装本身不创建讨论。
@@ -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
33
  安装器按 [技能配置](skills.md#自动安装完整技能) 复制完整技能。`--skills-home DIR` 可覆盖技能用户根目录;默认当前用户 home。重复安装只更新由安装器管理且未被编辑的版本;冲突保留本地文件,输出实际路径并标记待处理。核对后可移走需替换的旧副本再重跑 `setup`。安装器不调整登录状态;权限只在选中 Gemini 时经确认写入讨论命令与草稿目录规则,见 [单次执行权限](#单次执行权限)。
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
 
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.1",
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,9 +1,8 @@
1
1
  /** npm bootstrap and per-user setup; never logs in or starts an AI conversation. */
2
- import { spawnSync } from 'node:child_process';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
3
  import { accessSync, constants, copyFileSync, existsSync, mkdirSync, 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
 
@@ -11,9 +10,12 @@ import { agy_rule, 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';
13
+ import { ask, checkbox_prompt, intro, outro, pad, short_path, spinner, step, styled, text_width, tone } from './discussion_tui.mjs';
14
14
  export { agy_rule } from './discussion_adapters.mjs';
15
+ export { checkbox_prompt } from './discussion_tui.mjs';
15
16
 
16
17
  const ROOT = fileURLToPath(new URL('../', import.meta.url));
18
+ const PACKAGE = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
17
19
  const PROBE_TIMEOUT = 10000;
18
20
  const MIN_NODE_MAJOR = 24;
19
21
  const DEFAULT_PORT = 8765;
@@ -30,7 +32,7 @@ const CLIENTS = {
30
32
  const OPTIONS = { yes: { type: 'boolean' }, clients: { type: 'string' }, python: { type: 'string' },
31
33
  codex: { type: 'string' }, claude: { type: 'string' }, antigravity: { type: 'string' }, cursor: { type: 'string' },
32
34
  port: { type: 'string' }, prefix: { type: 'string' }, 'skills-home': { type: 'string' },
33
- 'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
35
+ 'no-start': { type: 'boolean' }, 'no-open': { type: 'boolean' }, json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
34
36
  const HELP = `用法:codiscuss install [选项] / codiscuss setup [选项]
35
37
  install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置已有安装。
36
38
  --yes 使用已检测且支持自动接入的软件,冲突保留并报告
@@ -44,7 +46,7 @@ install 将当前 npm 包安装为持久命令,再运行 setup;setup 配置
44
46
  Gemini 使用 agy;Grok 使用 Cursor CLI(agent / cursor-agent)。仅检测 CLI。
45
47
  Cursor 当前只安装技能,可手动参与;全自动接入尚未提供。已有软件设置保留。
46
48
  显式软件路径可更新已支持的 CLI 配置。安装不会开始讨论。
47
- 结果输出 JSON,中文摘要写入 stderr;退出码 0=完成,2=配置待处理,1=失败。
49
+ 中文摘要写入 stderr;输出被重定向或加 --json 时结果 JSON 写入 stdout;退出码 0=完成,2=配置待处理,1=失败。
48
50
  `;
49
51
 
50
52
  /** Read local settings stored outside the npm installation. */
@@ -64,6 +66,13 @@ function setup_environment(options) {
64
66
  return { home, previous, python, port };
65
67
  }
66
68
 
69
+ /** Summarise the checked runtime in one short line for the styled installer. */
70
+ function environment_line(python, port) {
71
+ const probe = spawnSync(python, ['-c', 'import sys; print(".".join(map(str, sys.version_info[:3])))'],
72
+ { encoding: 'utf8', windowsHide: true, timeout: PROBE_TIMEOUT });
73
+ return `Node ${process.version.slice(1)} · Python ${probe.stdout?.trim() || short_path(python)} · 端口 ${port}`;
74
+ }
75
+
67
76
  /** Find executable PATH entries, explicitly allowing Cursor's Windows .cmd launcher. */
68
77
  function path_program(name, search_path = process.env.PATH || '', allow_cmd = false) {
69
78
  const suffixes = process.platform === 'win32' ? (allow_cmd ? ['.exe', '.cmd'] : ['.exe']) : [''];
@@ -140,15 +149,13 @@ export function detect_clients(options = {}, search_path = process.env.PATH || '
140
149
  /** Select detected clients interactively or through explicit noninteractive options. */
141
150
  async function selected_clients(options, detected) {
142
151
  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
152
  if (choice === undefined && !options.yes) {
147
153
  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
- }
154
+ const items = detected.map(item => ({ label: item.name, hint: item.executable ? short_path(item.executable) : '未检测到 CLI',
155
+ checked: Boolean(item.executable && item.adapter), disabled: !item.executable }));
156
+ const checked = await checkbox_prompt('选择要接入的软件', items);
157
+ choice = detected.filter((_, index) => checked[index]).map(item => item.key).join(',') || 'none';
158
+ } else if (!styled) for (const item of detected) process.stderr.write(`${item.name}:${item.executable || '未检测到 CLI'}\n`);
152
159
  return requested_clients(choice, detected);
153
160
  }
154
161
 
@@ -181,12 +188,9 @@ async function choose_profile(client, current, options) {
181
188
  const differs = current && (current.adapter !== client.adapter || !same_executable(current.executable, client.executable));
182
189
  let update = Boolean(options[client.key]), acknowledged = false;
183
190
  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(); }
191
+ const choice = await ask(`${client.name} 已有配置:${current.adapter || '未设置类型'} / ${current.executable || '未设置路径'}\n检测到 CLI:${client.executable}\n保留已有配置或更新为此 CLI?`, '[保留/更新,默认保留]');
192
+ update = ['更新', 'update', 'u'].includes(choice.trim().toLowerCase());
193
+ acknowledged = !choice.trim() || ['保留', 'keep', 'k'].includes(choice.trim().toLowerCase());
190
194
  }
191
195
  if (differs && !update) return { client: client.name, status: acknowledged ? 'retained' : 'pending',
192
196
  effective: { adapter: current.adapter, executable: current.executable }, detected,
@@ -205,11 +209,8 @@ export function agy_rules(home) {
205
209
  async function agy_consent(path, rules, options) {
206
210
  if (options.yes) return true;
207
211
  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(); }
212
+ const answer = await ask(`允许 Gemini(agy)在全自动讨论中执行讨论命令并写讨论草稿?将向 ${path} 的 permissions.allow 追加 ${rules.length} 条只匹配讨论命令与草稿目录的规则`, ' [Y/n]');
213
+ return !['n', 'no', '否'].includes(answer.trim().toLowerCase());
213
214
  }
214
215
 
215
216
  /** Keep the installer-managed allow rules current, never touching other settings or user rules. */
@@ -300,10 +301,19 @@ function setup_issues(result) {
300
301
 
301
302
  /** Configure Python, selected clients and skills, returning partial results on startup failure. */
302
303
  async function setup(options) {
304
+ intro('配置 AI CoDiscuss', PACKAGE.version, PACKAGE.description);
303
305
  const { home, previous, python, port } = setup_environment(options);
304
- const selected = await selected_clients(options, detect_clients(options));
306
+ if (styled && !process.env.CODISCUSS_TUI_NESTED) step('运行环境就绪', [environment_line(python, port)]);
307
+ const scan = spinner('检测已安装的 AI 软件');
308
+ const detected = detect_clients(options);
309
+ const width = Math.max(...detected.map(item => text_width(item.name)));
310
+ const listed = options.clients !== undefined || options.yes
311
+ ? detected.map(item => `${pad(item.name, width)} ${tone.dim(item.executable ? short_path(item.executable) : '未检测到 CLI')}`) : [];
312
+ scan.done(`检测到 ${detected.filter(item => item.executable).length} 个可接入的 CLI`, listed);
313
+ const selected = await selected_clients(options, detected);
305
314
  const configured = await configure_clients(home, selected, options, launch_settings(home).participants);
306
315
  atomic_json(join(home, 'local-settings.json'), { ...previous, python, port });
316
+ step('已写入软件配置与技能', configured.skills.map(group => `${pad(group.client, width)} ${tone.dim(short_path(dirname(group.results[0].path)))}`));
307
317
  const result = { home, python, clients: selected.map(item => item.name), ...configured,
308
318
  command: check_command(), ai_verified: false };
309
319
  result.issues = setup_issues(result);
@@ -311,10 +321,13 @@ async function setup(options) {
311
321
  result.status = result.configured ? 'configured' : 'needs_attention';
312
322
  if (!options['no-start']) {
313
323
  const { local_main } = await import('./discussion_local.mjs');
324
+ const launch = spinner('启动本机网站与讨论服务');
314
325
  try {
315
326
  result.local = await local_main([options['no-open'] ? 'start' : 'open'], () => {});
327
+ launch.done('网站与讨论服务已在后台运行');
316
328
  if (result.configured) result.status = 'ready';
317
329
  } catch (error) {
330
+ launch.fail(error.message);
318
331
  result.status = 'start_failed';
319
332
  result.issues.push(`本机启动失败:${error.message};配置已保存,修复后重新运行 setup。`);
320
333
  }
@@ -323,7 +336,8 @@ async function setup(options) {
323
336
  }
324
337
 
325
338
  /** Print a concise human receipt while keeping stdout machine-readable. */
326
- function report_setup(result) {
339
+ function report_setup(result, options = {}) {
340
+ if (styled) return report_styled(result, options);
327
341
  const labels = { ready: '安装配置完成,网站与讨论服务已就绪', configured: '配置完成,本次未启动网站',
328
342
  needs_attention: '配置待处理', start_failed: '本机启动失败' };
329
343
  const lines = [labels[result.status]];
@@ -335,12 +349,38 @@ function report_setup(result) {
335
349
  }
336
350
  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
351
  if (result.command.path) lines.push(`持久命令:${result.command.path}(${result.command.status})`);
338
- if (result.local?.running) lines.push(`本机网站:${result.local.url}`);
352
+ if (result.local?.running) {
353
+ lines.push(`本机网站:${result.local.url}${options['no-open'] ? '' : '(已在浏览器打开)'}`,
354
+ '网站与讨论服务在后台运行,关闭此终端不影响;查看状态 codiscuss local status,停止 codiscuss local stop;电脑重启后运行 codiscuss local open 重新启动。');
355
+ }
339
356
  lines.push(...result.issues, '真实 AI 执行尚未验证;可从网页创建讨论,或在新会话调用 Skill。');
340
357
  if (result.skills.length) lines.push('若新会话未发现技能,请让 AI 读取上述目录内的 discuss-host/SKILL.md 和 discuss-participant/SKILL.md。');
341
358
  process.stderr.write(`${lines.join('\n')}\n`);
342
359
  }
343
360
 
361
+ /** Close the styled installer with aligned results, open issues and the commands to manage the site. */
362
+ function report_styled(result, options) {
363
+ const titles = { ready: ['安装完成', tone.green('◆')], configured: ['配置完成,本次未启动网站', tone.green('◆')],
364
+ needs_attention: ['配置完成,仍有待处理项', tone.yellow('▲')], start_failed: ['本机启动失败', tone.red('■')] };
365
+ const [title, mark] = titles[result.status];
366
+ const rows = [];
367
+ if (result.local?.running) rows.push(['网站', tone.cyan(result.local.url), options['no-open'] ? '' : '已在浏览器打开']);
368
+ for (const profile of result.profiles) rows.push(profile.status === 'manual' ? [profile.client, '仅安装技能,可手动参与', '尚不支持全自动接入']
369
+ : [profile.client, `${profile.status === 'configured' ? 'CLI 已配置' : '沿用已有配置'} · ${profile.effective.adapter}`, short_path(profile.effective.executable) || '未设置路径']);
370
+ for (const permission of result.permissions) {
371
+ if (['allowed', 'unchanged'].includes(permission.status)) rows.push([permission.client, '已放行讨论命令', short_path(permission.path)]);
372
+ }
373
+ 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']);
374
+ if (result.command.path) rows.push(['命令', `codiscuss ${tone.dim(`(${result.command.status})`)}`, short_path(result.command.path)]);
375
+ const notes = result.issues.map(issue => tone.yellow(`▲ ${issue}`));
376
+ if (result.local?.running) notes.push(tone.dim('网站与讨论服务在后台运行,关闭此终端不影响'),
377
+ `${tone.cyan('codiscuss local status')} ${tone.dim('查看状态')}`, `${tone.cyan('codiscuss local stop')} ${tone.dim('停止')}`,
378
+ `${tone.cyan('codiscuss local open')} ${tone.dim('重启电脑后重新启动')}`);
379
+ const closing = result.local?.running ? `在网页新建讨论,或在 AI 会话里调用 ${tone.cyan('discuss-host')} ${tone.dim('· 真实 AI 执行尚未验证')}`
380
+ : `运行 ${tone.cyan('codiscuss local open')} 启动网站`;
381
+ outro(title, rows, notes, closing, mark);
382
+ }
383
+
344
384
  /** Locate npm's JavaScript entrypoint so paths with spaces never need shell quoting. */
345
385
  function npm_entry() {
346
386
  const candidates = [process.env.npm_execpath, join(dirname(process.execPath), 'node_modules/npm/bin/npm-cli.js'),
@@ -352,31 +392,44 @@ function npm_entry() {
352
392
  return entry;
353
393
  }
354
394
 
355
- /** Execute npm without lifecycle scripts; surface actionable npm errors. */
395
+ /** Execute npm without lifecycle scripts asynchronously, so a progress spinner can animate. */
356
396
  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();
397
+ return new Promise((done, fail) => {
398
+ const child = spawn(process.execPath, [entry, ...args], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
399
+ let stdout = '', stderr = '';
400
+ child.stdout.on('data', data => { stdout += data; });
401
+ child.stderr.on('data', data => { stderr += data; });
402
+ child.on('error', fail);
403
+ child.on('close', code => (code === 0 ? done(stdout.trim()) : fail(new Error(stderr || 'npm 执行失败'))));
404
+ });
360
405
  }
361
406
 
362
407
  /** Install a real package copy, never a symlink into a disposable npx cache. */
363
- function install(args, options) {
364
- setup_environment(options);
408
+ async function install(args, options) {
409
+ intro('安装 AI CoDiscuss', PACKAGE.version, PACKAGE.description);
410
+ const { python, port } = setup_environment(options);
365
411
  requested_clients(options.clients, detect_clients(options));
412
+ if (styled) step('运行环境就绪', [environment_line(python, port)]);
366
413
  const entry = npm_entry();
367
414
  const temporary = mkdtempSync(join(tmpdir(), 'codiscuss-install-'));
368
415
  const prefix = options.prefix ? ['--prefix', resolve(options.prefix)] : [];
416
+ const progress = spinner('安装 codiscuss 命令');
369
417
  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`);
418
+ const packed = JSON.parse(await npm_run(entry, ['pack', ROOT, '--ignore-scripts', '--json', '--pack-destination', temporary]));
419
+ await npm_run(entry, ['install', '--global', ...prefix, '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund', join(temporary, packed[0].filename)]);
420
+ // npm masks UUID-like path segments as *** in its output, so an explicit prefix is resolved locally.
421
+ const install_prefix = options.prefix ? resolve(options.prefix) : await npm_run(entry, ['prefix', '--global']);
422
+ const global_root = process.platform === 'win32' ? join(install_prefix, 'node_modules') : join(install_prefix, 'lib', 'node_modules');
423
+ const installed = join(global_root, 'ai_co_discuss', 'runner', 'codiscuss.mjs');
424
+ const directory = process.platform === 'win32' ? install_prefix : join(install_prefix, 'bin');
425
+ progress.done('已安装 codiscuss 命令', [tone.dim(short_path(directory))]);
426
+ if (!styled) process.stderr.write(`命令目录:${directory}\n`);
375
427
  const result = spawnSync(process.execPath, [installed, 'setup', ...args], { stdio: 'inherit', windowsHide: true,
376
- env: { ...process.env, PATH: persistent_search_path() } });
428
+ env: { ...process.env, PATH: persistent_search_path(), CODISCUSS_TUI_NESTED: '1' } });
377
429
  process.exitCode = result.status ?? 1;
378
430
  if (result.error) throw result.error;
379
- } finally { rmSync(temporary, { recursive: true, force: true }); }
431
+ } catch (error) { progress.fail(error.message); throw error; }
432
+ finally { rmSync(temporary, { recursive: true, force: true }); }
380
433
  }
381
434
 
382
435
  /** Dispatch bootstrap or setup, leaving website and launch commands untouched. */
@@ -385,8 +438,8 @@ export async function setup_main(command, args) {
385
438
  if (values.help) return process.stdout.write(HELP);
386
439
  if (command === 'install') return install(args, values);
387
440
  const result = await setup(values);
388
- report_setup(result);
389
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
441
+ report_setup(result, values);
442
+ if (values.json || !process.stdout.isTTY) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
390
443
  process.exitCode = result.status === 'start_failed' ? 1 : result.status === 'needs_attention' ? ATTENTION_EXIT_CODE : 0;
391
444
  return result;
392
445
  }
@@ -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
+ }