diff --git a/.github/agents/pr-review-orchestrator.agent.md b/.github/agents/pr-review-orchestrator.agent.md new file mode 100644 index 0000000..8175102 --- /dev/null +++ b/.github/agents/pr-review-orchestrator.agent.md @@ -0,0 +1,118 @@ +--- +name: PR 分轮审查代理 +description: "Use when reviewing GitHub pull requests with iterative round-by-round checks based on a user-defined rubric, metrics, and evidence-first reporting." +tools: [vscode, execute, read, agent, edit, search, web, 'codereview/*', 'github/*', todo] +user-invocable: true +--- +你是一个“PR 分轮审查代理”。你的唯一目标是:按用户指定的审查文档与指标,逐轮检查 PR,减少遗漏。 + +在未收到任何额外输入时,默认进入“自动全量审查模式”:自动识别当前分支对应 PR,执行完整多轮审查,覆盖所有模块与所有问题分类。 + +## 强约束 + +- 必须按轮次执行:Round 0 -> Round 1 -> Round 2 -> Round 3 -> Round 4。 +- 每轮只做当前轮检查,不提前输出最终结论。 +- 每轮必须引用证据(文件路径、符号、上下文片段)。 +- 若信息不足,先自动补充上下文(读取 PR diff、相关文档与关键文件);仅在无法继续时再输出“所缺信息清单”。 +- 严格区分问题等级:Blocker / Major / Minor / Nit。 +- 禁止无证据结论;禁止跨轮跳步。 +- 审查覆盖必须完整: + - 模块范围:PR 变更涉及的所有模块(如 command / args / env_preload / help / completion / docs 等) + - 分类范围:`REQ LOGI SEC AUTH DSN RBST TRANS CONC PERF CPT IDE MAIN CPL READ SIMPL CONS DUP NAM DOCS COMM LOGG ERR FOR GRAM PRAC PR` + +## 输入优先级 + +1. 用户当前消息中给出的规则与指标。 +2. 仓库文档 `docs/review/PR_REVIEW_RUBRIC.md`。 +3. 仓库已有指令文件(`.github/instructions/*.instructions.md`),其中审查场景优先遵循: + - `.github/instructions/pr-review.instructions.md` + - `.github/instructions/pr-review-golang.instructions.md` + - `.github/instructions/pr-review-javascript.instructions.md` + - `.github/instructions/pr-review-shell.instructions.md` + +## 默认自动运行策略(零输入) + +- 无需用户提供 PR 编号、轮次或指标。 +- 默认审查模式为 `full-review`:覆盖整个 PR 变更涉及的所有模块。 +- 仅当用户明确指定“只审本次增量/仅看最新提交”时,才切换为 `incremental-review`。 +- 默认自动执行 Round 0~4 全流程,并在 Round 4 后输出最终结论。 +- 默认审查指标为“全量指标”:正确性 + 安全 + 性能 + 可维护性 + 兼容性 + 测试覆盖 + 文档一致性。 +- 若用户提供了额外约束(如只看某模块/某轮次),再在自动全量基础上收敛范围。 + +## PR 自动识别(当前分支) + +- 如果用户未提供 PR 编号或链接,先获取当前分支名。 +- 基于当前分支自动查找对应 PR(允许借助 `gh` 或 GitHub 接口)。 +- 若匹配到 0 个 PR:优先给出“自动创建 Draft PR”选项;在用户同意后可自动创建并继续审查。 +- 若匹配到多个 PR:按最近更新时间排序后让用户确认目标 PR。 +- 仅在确认目标 PR 后继续分轮审查。 + +在自动模式下,若仅匹配到 1 个 PR,则无需再次询问,直接进入 Round 0。 + +## GitHub 行级评论发布(默认开启,可关闭) + +- 默认将本轮发现的问题发布到 PR 行级评论;若用户明确要求“不发布评论/仅聊天输出”,则不发布。 +- 发布时: + 1) 仅发布有证据的问题项; + 2) 使用行级评论(path + line)写入 PR; + 3) 回传每条评论链接; + 4) 若无法定位行号,改为普通 PR 评论并注明原因。 +- 评论语言默认使用中文。 +- 发布前必须执行去重:以 `path + line + 分类 + 模块 + 等级 + 问题摘要` 作为唯一键检查已有评论;若已存在同键评论,则不重复发布,直接复用并回传已有评论链接。 +- 每条评论必须使用统一模板,且包含以下六部分: + - 分类:必须使用分类代码标签与名称(如 `[LOGI] 逻辑问题`、`[SEC] 安全问题`),分类集合以 `docs/review/CODE_REVIEW_GUIDE_CN.md` 为准。 + - 模块:问题所属模块(如 command / args / env_preload / help / completion / docs)。 + - 等级:Blocker / Major / Minor / Nit。 + - 问题:一句话描述发现的问题。 + - 原因:说明为何这是问题(语义风险/兼容性/可维护性/测试缺口)。 + - 修改意见:给出可执行的最小修复建议。 +- 聊天中给出的逐条问题建议,也必须带 `[分类]` 前缀(如 `[LOGI]`、`[SEC]`)。 +- 仅当用户明确要求“不发布评论/仅聊天输出”时,才跳过 GitHub 评论发布。 + +### 统一评论模板(必须) + +- 模板以 `docs/review/PR_COMMENT_TEMPLATE.md` 为准(单一事实来源,禁止自定义变体)。 +- 最小必填字段:分类 / 模块 / 等级 / 问题 / 原因 / 修改意见。 +- 分类格式必须为:`[分类代码] 分类名称`。 + +## 防遗漏硬门禁(必须) + +- Round 0 必须输出“模块覆盖矩阵”:模块名 / 变更文件数 / 审查状态(已检查/未检查)/ 证据。 +- 每个模块至少提供 1 条证据;高风险模块(command/args/env_preload/web/webtty/webui/mcp/completion)至少 2 条证据。 +- 模块若结论为“无问题”,仍需给出“低风险依据”(如测试覆盖、边界防护、输入校验)。 +- 只要存在“未检查模块”,禁止进入 Round 4。 +- Round 4 前必须满足:`modules_total == modules_checked`,否则仅允许输出“未完成审查 + 所缺清单”。 + +## 每轮输出模板 + +- 轮次: +- 本轮检查范围: +- 结论: +- 证据: +- 问题列表(按等级): +- 未决问题: +- 下一轮所需输入: + +在 Round 4(最终结论)输出中,必须追加“全分类勾选清单(26 类)”,并为每类标注:`已检查 / N/A`(必要时附一句证据)。 + +### Round 4 门禁自检(必须追加) + +- `modules_total`: +- `modules_checked`: +- `missing_modules`: +- `categories_total`: +- `categories_checked`: +- `unresolved_blockers`: +- `unresolved_majors`: + +若 `modules_total != modules_checked`,禁止输出最终审查结论(Approve/Request changes/Comment),仅允许输出“未完成审查 + 所缺清单”。 + +默认情况下“下一轮所需输入”应为“无”;仅在阻塞时列出缺失项。 + +## 结束条件 + +仅当 Round 4 完成后,才允许输出最终结论: +- 审查结论:Approve / Request changes / Comment +- 合入前必须完成事项(最多 5 条) +- 可延后改进项(最多 5 条) +- 全分类勾选清单(26 类) diff --git a/.github/copilot-code-review.yml b/.github/copilot-code-review.yml new file mode 100644 index 0000000..4380fbb --- /dev/null +++ b/.github/copilot-code-review.yml @@ -0,0 +1,54 @@ +# GitHub Copilot Code Review Configuration +# Documentation: https://docs.github.com/en/copilot/using-github-copilot/code-review + +reviews: + # Enable automatic code review on pull requests + auto_review: true + + # High-level focus areas for reviews + high_level_summary: true + + # Review scope configuration + review_scope: + # Focus on these aspects during review + focus_areas: + - correctness + - security + - performance + - maintainability + - best_practices + + # Path filters - which files to review + path_filters: + include: + - "**/*.go" + - "**/*.java" + - "**/*.py" + - "**/*.js" + - "**/*.ts" + - "**/*.jsx" + - "**/*.tsx" + - "**/*.rs" + - "**/*.c" + - "**/*.cpp" + - "**/*.cs" + - "**/*.rb" + - "**/*.php" + - "**/*.swift" + - "**/*.kt" + - "**/*.scala" + exclude: + - "**/*.test.*" + - "**/*.spec.*" + - "**/test/**" + - "**/tests/**" + - "**/__tests__/**" + - "**/node_modules/**" + - "**/vendor/**" + - "**/dist/**" + - "**/build/**" + - "**/*.min.js" + - "**/*.generated.*" + + # Custom instructions file (automatically read by Copilot) + instructions_file: .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 855423d..39f9b69 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -5,6 +5,15 @@ - 本文件是仓库级 always-on 指引,适用于整个 `redant` 工作区。 - 不再额外创建 `AGENTS.md`,避免两套同类指引并存。 +## 代码审查模式入口 + +- 当任务是 PR 审查 / review comment 处理 / 审查结论整理时,优先进入审查模式并遵循: + - `.github/instructions/pr-review.instructions.md` + - `.github/instructions/pr-review-golang.instructions.md` + - `.github/instructions/pr-review-javascript.instructions.md` + - `.github/instructions/pr-review-shell.instructions.md` +- 审查模式下输出要求以审查规则文件为准(分类标签、证据链、Review Conclusion、评论模板与去重规则)。 + ## 技术栈与目标 - 语言:Go(见 `go.mod`,当前 `go 1.23`)。 @@ -43,7 +52,7 @@ - 文档入口:`docs/INDEX.md`。 - 涉及架构或流程变化时,先更新 `docs/DESIGN.md`,再补示例/说明文档。 -- 行为变更需同步 `docs/CHANGELOG.md`,必要时更新 `docs/EVALUATION.md`。 +- 行为变更需同步 `.version/changelog/Unreleased.md`,必要时更新 `docs/EVALUATION.md`。 - 文档默认使用中文,流程图优先 Mermaid。 ## 实施原则(对 AI 代理) diff --git a/.github/instructions/changelog.instructions.md b/.github/instructions/changelog.instructions.md index 916cea3..a0383d3 100644 --- a/.github/instructions/changelog.instructions.md +++ b/.github/instructions/changelog.instructions.md @@ -1,17 +1,16 @@ --- name: Changelog 专项规范 -description: 仅用于维护 docs/CHANGELOG.md,保证 Unreleased 与版本落版结构稳定、分类一致、条目可追溯 -applyTo: "docs/CHANGELOG.md" +description: 仅用于维护 .version/changelog,保证 Unreleased 与版本文件结构稳定、分类一致、条目可追溯 +applyTo: ".version/changelog/*.md" --- # Redant Changelog 维护规范 -本规则仅适用于 `docs/CHANGELOG.md`。 +本规则仅适用于 `.version/changelog/*.md`。 ## 结构约束 -- 保持顶层结构稳定:`[Unreleased]` 在前,历史版本按既有顺序保留。 -- `Unreleased` 推荐分类:`新增` / `修复` / `变更` / `文档`。 +- `Unreleased.md` 推荐分类:`新增` / `修复` / `变更` / `文档`。 - 若某分类暂无内容,写“暂无”。 ## 内容约束 @@ -19,13 +18,15 @@ applyTo: "docs/CHANGELOG.md" - 仅基于可见改动编写条目,不杜撰能力或影响。 - 单条应简洁、可读、可追溯,尽量以动词开头。 - 重复事项需合并去重,避免同义重复。 -- 不改写历史版本块语义,不重排已发布版本。 +- 不改写历史版本文件语义,不重排已发布版本。 ## 落版约束(release) - 版本号来源于 `.version/VERSION`。 -- 落版格式:`## [] - `。 -- 落版后需在顶部重建新的 `[Unreleased]` 模板(四个分类)。 +- 落版文件:`.version/changelog/.md`。 +- 文件头格式:`# [] - `。 +- 落版后需重建 `.version/changelog/Unreleased.md` 模板(四个分类)。 +- 落版后需同步更新 `.version/changelog/README.md` 索引。 ## 协同建议 diff --git a/.github/instructions/coding.instructions.md b/.github/instructions/coding.instructions.md new file mode 100644 index 0000000..f209ea7 --- /dev/null +++ b/.github/instructions/coding.instructions.md @@ -0,0 +1,48 @@ +--- +name: Go 编码开发约束 +description: "Use when modifying Go source code, command dispatch, flags/options parsing, args parsing, env preload, middleware chain, help rendering, completion integration, or related tests in redant." +applyTo: "**/*.go" +--- + +# Redant 编码开发规则(Go) + +仅在修改 Go 代码时生效,目标是保证 CLI 行为与公开 API 兼容,避免无关重构。 + +## 兼容性与改动边界 + +- 默认做最小改动:仅修改与当前任务直接相关的代码路径。 +- 不随意变更公开 API、命令语义、输出语义;若必须变更,需同步测试与文档说明。 +- 避免顺手重命名、移动目录、跨模块重构(除非任务明确要求)。 + +## 关键语义保护(不得破坏) + +- 子命令解析同时支持空格路径与冒号路径。 +- 分发优先级保持:显式子命令 > `argv0` 分发 > 根命令。 +- 子命令继承父标志;重名时深层标志覆盖浅层标志。 +- `--list-commands` 与 `--list-flags` 在 Handler 前短路。 +- Required 选项校验保持现有判定来源(显式 flag、默认值、env 列表)。 + +## 文件落点约定 + +- 命令分发/执行流程改动:优先落在 `command.go`,并补 `command_test.go`。 +- flag/env/default 语义改动:优先改 `option.go` / `env_preload.go`,并补对应测试。 +- 参数格式与解析改动:优先改 `args.go`,并验证示例或测试覆盖。 +- 帮助与补全体验改动:改 `help.go`/`help.tpl` 或 `cmds/completioncmd/`,并验证输出。 + +## 测试与质量门槛 + +- 新增或修改行为时,优先补表驱动测试与子测试。 +- 测试优先覆盖边界语义,不只覆盖 happy path。 +- 变更后至少执行相关测试;条件允许时执行完整回归(`task test`)。 +- 保持现有代码风格与命名习惯,不引入不必要的格式漂移。 + +## 文档与变更同步 + +- 行为变更需同步 `.version/changelog/Unreleased.md`。 +- 涉及架构/流程变化时,先更新 `docs/DESIGN.md`,再补其他文档。 + +## 禁止项 + +- 不杜撰未实现能力或测试结果。 +- 不为“看起来更整洁”而改变既有行为。 +- 不以临时绕过方式(跳过测试、删除校验)替代根因修复。 diff --git a/.github/instructions/documentation.instructions.md b/.github/instructions/documentation.instructions.md index 634452e..2007d6b 100644 --- a/.github/instructions/documentation.instructions.md +++ b/.github/instructions/documentation.instructions.md @@ -19,7 +19,7 @@ applyTo: "**/*.md" - 文档入口为 `docs/INDEX.md`,新增文档时需补充索引关系(如适用)。 - 涉及架构或流程变化时,先更新 `docs/DESIGN.md`,再补示例/说明文档。 -- 行为变更需同步 `docs/CHANGELOG.md`;必要时同步 `docs/EVALUATION.md`。 +- 行为变更需同步 `.version/changelog/Unreleased.md`;必要时同步 `docs/EVALUATION.md`。 - 术语遵循 `docs/INDEX.md`,明确区分“参数(Args)”与“标志(Flag)”。 ## 写作与更新策略 @@ -31,6 +31,6 @@ applyTo: "**/*.md" ## Changelog 联动 -- 变更日志遵循 `docs/CHANGELOG.md` 现有结构:`新增 / 修复 / 变更 / 文档`。 +- 变更日志遵循 `.version/changelog/` 现有结构:`新增 / 修复 / 变更 / 文档`。 - 自动维护建议优先参考 `docs/CHANGELOG_LLM_PROMPT.md`。 - 发布前落版建议通过 agent 提示词执行:`/changelog-maintenance draft|release`。 diff --git a/.github/instructions/pr-review-golang.instructions.md b/.github/instructions/pr-review-golang.instructions.md new file mode 100644 index 0000000..d0e2ca5 --- /dev/null +++ b/.github/instructions/pr-review-golang.instructions.md @@ -0,0 +1,22 @@ +--- +name: Go PR 审查补充规范 +description: Use when reviewing Go code in pull requests. Apply together with pr-review.instructions.md. +--- + +# Go PR 审查补充规范 + +仅在 Go 代码审查时生效,需与通用 PR 审查规范一起使用。 + +## 重点检查项 + +- `[CONC]` 并发安全:goroutine 泄漏、竞态、channel 关闭与阻塞。 +- `[RBST]` 错误处理:error 不可静默忽略,优先 `%w` 包装。 +- `[PERF]` 性能:切片/map 预分配、避免不必要分配与重复计算。 +- `[PRAC]` Go 惯例:命名、接口设计、defer 资源释放。 + +## 审查基线 + +- 禁止以 `_` 忽略关键 error。 +- 长耗时/I/O 路径优先检查 `context.Context` 传递链。 +- 共享可变状态必须有并发保护(锁、channel 或原子操作)。 +- 库层避免 `panic` 作为常规错误处理。 diff --git a/.github/instructions/pr-review-javascript.instructions.md b/.github/instructions/pr-review-javascript.instructions.md new file mode 100644 index 0000000..90eb1a6 --- /dev/null +++ b/.github/instructions/pr-review-javascript.instructions.md @@ -0,0 +1,21 @@ +--- +name: JS/TS PR 审查补充规范 +description: Use when reviewing JavaScript or TypeScript code in pull requests. Apply together with pr-review.instructions.md. +--- + +# JS/TS PR 审查补充规范 + +仅在 JavaScript/TypeScript 审查时生效。 + +## 重点检查项 + +- `[SEC]` 安全:XSS/注入、`eval`/`Function`/危险 `innerHTML` 使用。 +- `[RBST]` 健壮性:空值保护、异步错误传播、输入校验。 +- `[PERF]` 性能:重复渲染/重复计算、$O(n^2)$ 循环、深拷贝开销。 +- `[PRAC]` 最佳实践:TypeScript 类型约束、模块化、风格一致。 + +## 审查基线 + +- 外部输入(API/表单/环境变量)必须有类型与范围校验。 +- Promise/async 路径必须有清晰错误处理。 +- 尽量避免 `any`,公开接口参数/返回类型应可推断且稳定。 diff --git a/.github/instructions/pr-review-shell.instructions.md b/.github/instructions/pr-review-shell.instructions.md new file mode 100644 index 0000000..91e97ef --- /dev/null +++ b/.github/instructions/pr-review-shell.instructions.md @@ -0,0 +1,20 @@ +--- +name: Shell PR 审查补充规范 +description: Use when reviewing shell scripts in pull requests. Apply together with pr-review.instructions.md. +--- + +# Shell PR 审查补充规范 + +仅在 Shell/Bash/Zsh 脚本审查时生效。 + +## 重点检查项 + +- `[RBST]` 健壮性:严格模式、变量引用、失败路径处理。 +- `[SEC]` 安全:命令注入、临时文件安全、权限最小化。 +- `[PRAC]` 最佳实践:shebang 明确、函数封装、可维护日志。 + +## 审查基线 + +- 建议使用 `set -euo pipefail`(视脚本语义可做例外说明)。 +- 变量引用默认使用双引号,避免单词分裂与 glob 意外扩展。 +- 避免 `eval`;如必须使用,应给出严格输入约束与注释说明。 diff --git a/.github/instructions/pr-review.instructions.md b/.github/instructions/pr-review.instructions.md new file mode 100644 index 0000000..55195b8 --- /dev/null +++ b/.github/instructions/pr-review.instructions.md @@ -0,0 +1,67 @@ +--- +name: PR 审查专项规范 +description: Use when handling pull request review, code review feedback, or PR comment resolution tasks in redant. In review mode, provide analysis/comments only and do not modify repository files. +--- + +# Redant PR 审查专项规范 + +仅在“代码审查模式”生效(如:审查 PR、回复 review comment、整理审查结论)。 + +## 默认运行模式(零输入自动全量) + +- 当用户未提供 PR、轮次或指标时,默认自动识别当前分支对应 PR。 +- 默认自动执行完整轮次:Round 0 -> Round 1 -> Round 2 -> Round 3 -> Round 4。 +- 默认覆盖 PR 变更涉及的所有模块与所有问题分类。 +- 若用户提供了轮次/指标/模块等限制条件,再在默认全量基线上收敛范围。 + +## 审查模式硬约束 + +- 审查模式下仅输出分析与建议,不直接修改仓库文件。 +- 若用户明确切换为“请直接修复/落地修改”,先确认一次再进入实现模式。 +- 结论必须证据优先:给出文件路径、符号、上下文片段(必要时附行号)。 +- 默认使用全量审查(full-review);仅在用户明确指定时切换增量审查(incremental-review)。 +- Round 0 必须输出“模块覆盖矩阵”(模块 / 文件数 / 状态 / 证据)。 +- 每模块至少 1 条证据;高风险模块至少 2 条证据。 +- 存在未检查模块时,禁止输出 Round 4 最终结论。 + +## 审查意见格式(必须) + +- 每条问题必须带 `[分类]` 前缀(如 `[LOGI]`、`[SEC]`、`[PERF]`)。 +- 若无法准确归类,优先使用最接近分类,或使用 `[PRAC]` / `[LOGI]` 兜底。 +- 严禁输出不带分类标签的审查建议。 +- 一条评论只描述一个问题,避免把多个问题揉在一起。 +- 优先提供“最小可执行修复建议”。 + +## 问题分类(速查) + +- 关键:`REQ` `LOGI` `SEC` `AUTH` +- 高:`DSN` `RBST` `TRANS` `CONC` `PERF` +- 中:`CPT` `IDE` `MAIN` `CPL` +- 普通:`READ` `SIMPL` `CONS` `DUP` `NAM` `DOCS` +- 低:`COMM` `LOGG` `ERR` `FOR` `GRAM` `PRAC` `PR` + +> 分类完整定义与检查项来源:`docs/review/CODE_REVIEW_GUIDE_CN.md` + +## 审查输出建议结构 + +1. 结论概览(先讲风险与结论) +2. 逐条问题(带 `[分类]` 前缀 + 证据 + 建议) +3. Review Conclusion(必须) + - 统计:按严重等级统计问题数量 + - 风险:当前最大技术/业务风险 + - 决策:`Approve / Request changes / Comment`(可附中文注释:批准 / 需要修改 / 仅评论) +4. 门禁自检(Round 4 必须) + - `modules_total` / `modules_checked` / `missing_modules` + - `categories_total` / `categories_checked` + - `unresolved_blockers` / `unresolved_majors` + +> 最终结论前必须满足:`modules_total == modules_checked`,且全分类清单完整。 + +> 推荐在统计中使用严重程度分桶(🔴/🟠/🟡/🟢/🔵),便于轮次横向比较与汇总。 + +## 发布到 GitHub PR 评论时 + +- 默认发布到 GitHub PR 评论;仅当用户明确要求“不发布评论/仅聊天输出”时跳过发布。 +- 行级评论优先;无法定位行号时再使用普通评论并说明原因。 +- 发布前必须去重,唯一键:`path + line + 分类 + 模块 + 等级 + 问题摘要`。 +- 保持与 `docs/review/PR_COMMENT_TEMPLATE.md` 的字段一致:分类 / 模块 / 等级 / 问题 / 原因 / 修改意见。 diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md new file mode 100644 index 0000000..0e170c4 --- /dev/null +++ b/.github/instructions/release.instructions.md @@ -0,0 +1,26 @@ +--- +name: 发布前变更核对约束 +description: "Use when preparing a release or completing behavior-impacting changes in redant, including changelog updates, docs synchronization, and regression checks." +--- + +# Redant 发布前核对规则 + +用于“准备发布”或“完成具备行为影响的改动”时的统一核对。 + +## 发布前检查清单 + +- 变更说明已写入 `.version/changelog/Unreleased.md`,分类正确(新增/修复/变更/文档)。 +- 涉及架构或流程的改动,已同步更新 `docs/DESIGN.md`。 +- 用户可见行为变化,已同步示例或说明文档(如 `README.md`、`docs/USAGE_AT_A_GLANCE.md`)。 + +## 质量门槛 + +- 至少完成相关范围测试;条件允许时执行完整测试回归。 +- 不以“暂时跳过测试/校验”作为发布前状态。 +- 仅基于真实改动与真实测试结果编写发布说明,不杜撰。 + +## 落版约束提示 + +- 若进入正式落版,版本号来源于 `.version/VERSION`。 +- 落版后应重建 `Unreleased` 模板并更新 changelog 索引。 +- changelog 结构与落版细节以 `.github/instructions/changelog.instructions.md` 为准。 diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md new file mode 100644 index 0000000..fd9b3c9 --- /dev/null +++ b/.github/instructions/testing.instructions.md @@ -0,0 +1,35 @@ +--- +name: Go 测试开发约束 +description: "Use when adding or modifying Go tests in redant, especially table-driven tests, subtests, command dispatch behavior, flags/options parsing, args parsing, env preload, and completion/help behavior." +applyTo: "**/*_test.go" +--- + +# Redant 测试开发规则(Go) + +仅在修改 Go 测试文件时生效,目标是让测试覆盖真实行为边界并具备可维护性。 + +## 测试设计 + +- 优先使用表驱动测试,并通过子测试(`t.Run`)表达场景。 +- 每个用例名称应描述“输入/前置条件 + 预期行为”。 +- 新增行为必须新增测试;行为变更必须更新旧测试并解释预期变化。 + +## 覆盖重点(按项目语义) + +- 命令分发:显式子命令、`argv0` 分发、根命令回退。 +- 子命令路径:空格路径与冒号路径。 +- 标志行为:继承、重名覆盖、默认值/env 回退、required 判定。 +- 参数解析:位置参数、query、form、JSON 及边界输入。 +- 特殊开关:`--list-commands` / `--list-flags` 的短路行为。 +- env 预加载:`--env` / `-e` / `--env-file` 与恢复逻辑。 + +## 质量约束 + +- 避免只测 happy path,至少补一个失败或边界场景。 +- 断言应稳定、聚焦语义,不依赖脆弱格式细节(除非格式就是需求)。 +- 保持测试可重复执行,不依赖外部不稳定状态。 + +## 变更联动 + +- 如果测试变更源自行为变更,同步更新 `.version/changelog/Unreleased.md`。 +- 如涉及架构/流程语义,联动更新 `docs/DESIGN.md`。 diff --git a/.github/prompts/changelog-maintenance.prompt.md b/.github/prompts/changelog-maintenance.prompt.md index 7b1307c..eb0d369 100644 --- a/.github/prompts/changelog-maintenance.prompt.md +++ b/.github/prompts/changelog-maintenance.prompt.md @@ -1,6 +1,6 @@ --- name: changelog-maintenance -description: 维护 docs/CHANGELOG.md(更新 Unreleased 或执行版本落版) +description: 维护 .version/changelog(更新 Unreleased 或执行版本落版) argument-hint: "模式:draft(更新 Unreleased)或 release(按 .version/VERSION 落版)" agent: agent --- @@ -9,15 +9,16 @@ agent: agent ## 目标 -- `draft` 模式:根据当前改动更新 `docs/CHANGELOG.md` 的 `[Unreleased]`。 -- `release` 模式:将 `[Unreleased]` 落版为 `.version/VERSION` 对应版本,并重建空的 `[Unreleased]` 模板。 +- `draft` 模式:根据当前改动更新 `.version/changelog/Unreleased.md`。 +- `release` 模式:将 `Unreleased.md` 落版为 `.version/VERSION` 对应版本文件,并重建空模板。 ## 必读上下文 在开始前先读取并遵循: - `.github/copilot-instructions.md` -- `docs/CHANGELOG.md` +- `.version/changelog/README.md` +- `.version/changelog/Unreleased.md` - `docs/CHANGELOG_LLM_PROMPT.md` - `.version/VERSION` - 当前工作区 diff(如可获取) @@ -33,20 +34,21 @@ agent: agent ### draft -- 仅更新 `[Unreleased]` 区域。 +- 仅更新 `.version/changelog/Unreleased.md`。 - 若缺少分类小节则补齐;无内容的小节写“暂无”。 - 直接基于当前工作区改动与提交语义生成草稿,不依赖本地脚本输出。 ### release - 读取 `.version/VERSION` 作为目标版本号(如 `v0.0.6`)。 -- 将 `[Unreleased]` 内容迁移为新版本块:`## [] - `。 -- 在顶部重建新的 `[Unreleased]`,包含四个小节且初始值为“暂无”。 -- 直接在文档中完成落版,不依赖本地 task 或脚本。 +- 将 `Unreleased.md` 内容迁移到新版本文件:`.version/changelog/.md`。 +- 版本文件标题格式:`# [] - `。 +- 重建 `.version/changelog/Unreleased.md` 空模板(四个分类且初始值为“暂无”)。 +- 同步更新 `.version/changelog/README.md` 中的版本索引。 ## 输出要求 -- 直接给出对 `docs/CHANGELOG.md` 的修改(补丁或已应用结果)。 +- 直接给出对 `.version/changelog/` 相关文件的修改(补丁或已应用结果)。 - 末尾附一段简短自检: - 是否仅改动允许范围; - 是否完成分类与去重; diff --git a/.github/prompts/pr-review-round.prompt.md b/.github/prompts/pr-review-round.prompt.md new file mode 100644 index 0000000..a352abf --- /dev/null +++ b/.github/prompts/pr-review-round.prompt.md @@ -0,0 +1,52 @@ +--- +name: PR 分轮审查(按文档与指标) +description: "Run a round-by-round PR review using the project rubric and user metrics; enforce evidence-first output to reduce omissions." +argument-hint: "可留空;默认自动识别当前分支 PR 并执行完整 Round 0~4 全量审查" +agent: "PR 分轮审查代理" +--- +请按以下规则执行 PR 审查: + +- 审查基线文档:[`docs/review/PR_REVIEW_RUBRIC.md`](../../docs/review/PR_REVIEW_RUBRIC.md) +- 评论模板文档:[`docs/review/PR_COMMENT_TEMPLATE.md`](../../docs/review/PR_COMMENT_TEMPLATE.md) +- 审查指令文档: + - [`.github/instructions/pr-review.instructions.md`](../instructions/pr-review.instructions.md) + - [`.github/instructions/pr-review-golang.instructions.md`](../instructions/pr-review-golang.instructions.md) + - [`.github/instructions/pr-review-javascript.instructions.md`](../instructions/pr-review-javascript.instructions.md) + - [`.github/instructions/pr-review-shell.instructions.md`](../instructions/pr-review-shell.instructions.md) +- 输出必须使用“每轮输出模板” +- 本轮只做指定轮次,不跨轮 +- 结论必须给证据(文件路径 + 关键片段) +- 问题建议必须使用 `[分类]` 前缀(如 `[LOGI]`、`[SEC]`、`[PERF]`) +- Round 0 必须输出“模块覆盖矩阵”(模块 / 变更文件数 / 状态 / 证据) +- 每个模块至少 1 条证据;高风险模块(command/args/env_preload/web/webtty/webui/mcp/completion)至少 2 条证据 +- 模块即使“无问题”也必须给出低风险依据 +- 若存在未检查模块,禁止进入 Round 4 +- Round 4 最终结论必须包含“全分类勾选清单(26 类)”,并标注每类 `已检查 / N/A` +- Round 4 结尾必须追加“门禁自检字段”:`modules_total` / `modules_checked` / `missing_modules` / `categories_total` / `categories_checked` / `unresolved_blockers` / `unresolved_majors` +- 若 `modules_total != modules_checked`,禁止输出最终审查结论,仅输出“未完成审查 + 所缺清单” +- 默认将问题发布到 PR 行级评论,并返回评论链接;若用户明确要求“不发布评论/仅聊天输出”,则仅在聊天中输出 +- 发布到 GitHub 的评论默认使用中文,且每条必须使用统一模板:分类 / 模块 / 等级 / 问题 / 原因 / 修改意见 +- 评论中的“分类”字段必须使用代码标签格式:`[分类代码] 分类名称`(如 `[LOGI] 逻辑问题`) +- 发布前先去重:按 `path + line + 分类 + 模块 + 等级 + 问题摘要` 检查已发布评论;若已存在则复用链接,不重复发同类评论 +- 信息不足时,先列“所缺信息清单”,不要硬判 + +默认运行策略(零输入): +- 无需用户输入 PR、轮次、指标。 +- 自动识别当前分支对应 PR。 +- 默认审查模式为 `full-review`(全 PR 模块覆盖);仅当用户明确指定时才切换 `incremental-review`。 +- 默认执行完整 Round 0 -> Round 1 -> Round 2 -> Round 3 -> Round 4。 +- 默认覆盖 PR 变更涉及的所有模块与所有问题分类。 +- 默认审查指标:正确性 + 安全 + 性能 + 可维护性 + 兼容性 + 测试覆盖 + 文档一致性。 + +用户输入参数: +- PR:{{input}}(可为空) +- 当前轮次:可选 +- 关注指标:可选 + +若当前分支没有 PR: +- 先提示“可自动创建 Draft PR 后继续审查”。 +- 用户同意时,创建 Draft PR 并继续本轮审查。 + +如果用户给了轮次:按用户指定轮次执行。 +如果用户给了指标:按用户指定指标收敛范围。 +如果用户未给任何输入:执行默认自动全量审查策略。 diff --git a/.gitignore b/.gitignore index ed20272..8bca83a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ go.work.sum # Editor/IDE .idea/ .vscode/ +.local/ diff --git a/.version/VERSION b/.version/VERSION index 77cada2..41a2819 100644 --- a/.version/VERSION +++ b/.version/VERSION @@ -1 +1 @@ -v0.0.6 +v0.0.7 diff --git a/.version/changelog/README.md b/.version/changelog/README.md new file mode 100644 index 0000000..3051840 --- /dev/null +++ b/.version/changelog/README.md @@ -0,0 +1,22 @@ +# Changelog 索引 + +本目录保存项目变更记录,采用“一个版本一个文件”的方式维护。 + +## 文件约定 + +- `Unreleased.md`:当前开发中变更(待发布)。 +- `vX.Y.Z.md`:已发布版本变更(例如 `v0.0.5.md`)。 + +## 当前版本文件 + +- [`Unreleased.md`](Unreleased.md) +- [`v0.0.6.md`](v0.0.6.md) +- [`v0.0.5.md`](v0.0.5.md) +- [`v0.0.4.md`](v0.0.4.md) + +## 维护约定 + +- 分类保持:`新增` / `修复` / `变更` / `文档`。 +- 发布时将 `Unreleased.md` 内容迁移到新版本文件,并重建空模板。 +- 历史版本文件只做勘误,不改写语义与顺序。 + diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md new file mode 100644 index 0000000..a714c42 --- /dev/null +++ b/.version/changelog/Unreleased.md @@ -0,0 +1,91 @@ +# [Unreleased] + +> 推荐维护方式: +> +> - 使用 LLM 提示词自动更新:[`docs/CHANGELOG_LLM_PROMPT.md`](../../docs/CHANGELOG_LLM_PROMPT.md) +> - 建议通过 agent 提示词执行:`/changelog-maintenance draft|release` + +## 新增 + +- 新增隐藏全局标志 `--args`:支持以重复参数或 CSV 形式覆盖命令位置参数,用于在需要时通过 flag 直接注入/替代 `args`。 +- 新增 MCP 能力:提供 `cmds/mcpcmd` 与 `internal/mcpserver`,支持将命令树映射为 MCP Tools,并通过 `mcp serve --transport stdio` 对外服务。 +- 新增 Web 控制台能力:提供 `cmds/webcmd` 与 `internal/webui`,支持可视化选择命令、填写 flags/args、执行并查看调用过程与结果。 +- 新增 `cmds/readlinecmd`:基于 `github.com/chzyer/readline` 提供多轮交互 REPL,支持命令/子命令、flag、参数与枚举值补全;`example/fastcommit` 已接入该命令。 +- 新增 `cmds/richlinecmd`:基于 `github.com/charmbracelet/bubbletea`(对比 `tcell` 后选型)实现独立交互命令,提供竖向补全候选列表与描述信息展示,且不影响现有 `readlinecmd`。 +- 新增 `cmds/agentlineapp`(阶段一 MVP):提供 Agent CLI 风格会话块视图(`system/user/assistant/tool/command/result`)、状态栏与输出滚动;支持 `/ask`、`/plan`、`/run` 等 slash 命令,并接入 `example/fastcommit` 便于体验。 +- 增强 `cmds/richlinecmd` 候选可读性:补全列表新增类型标签(`CMD/FLAG/ARG/ENUM`)与总数/区间信息,并按 `CMD → FLAG → ARG → ENUM` 分组排序;大结果集支持窗口滚动显示与 `PgUp/PgDn/Home/End` 快捷定位;同时为标签、描述、选中项与提示信息提供颜色分层展示。 +- 新增 `example/copilot-demo`:集成 `github.com/github/copilot-sdk/go`,提供新会话/恢复会话、会话管理、模型查询、状态检查、自定义 tool、hooks、ask_user 与流式输出示例,并支持通过 `agentline` 以 slash command 执行 `chat`、`resume`。 +- 增强 `example/copilot-demo`:集成 `cmds/webcmd`,支持通过 `copilot-demo web` 启动可视化命令执行页面。 +- 增强 `example/copilot-demo sessions`:新增 `--hydrate` / `--hydrate-timeout` / `--hydrate-max-events`,可在会话列表元信息缺失时尝试恢复会话并补充最近 assistant 摘要与消息统计。 +- 增强 `cmds/agentlineapp`:新增 `--resume-session-id` / `--resume-prompt`,可在启动交互模式时自动执行 `resume` 并附着到指定会话。 +- 调整 `example/copilot-demo` 交互可见命令:`sessions` 标记为 `agent.command=true`,可在 `agentline` 中通过 `/sessions`(含 `--hydrate`)直接执行。 +- 新增 `cmds/agentlineapp/acp` 最小 ACP 适配层:提供 `AgentBridge`、`CallbackClient`、`PermissionBroker` 与 `session/update` 渲染映射,打通 prompt turn 与权限请求闭环。 +- 增强 `cmds/agentlineapp` ACP 交互能力:新增 `/permissions`、`/allow`、`/deny`、`/acp-demo` slash 命令,支持运行中权限审批与演示回合。 +- 增强 `cmds/agentlineapp` ACP 观测能力:新增 `/acp-events`、`/acp-events-summary`、`/acp-events-export [path]`,支持在交互中回看事件时间线、统计摘要并导出 JSONL(默认 `.local/data.jsonl`)。 +- 新增 `cmds/agentlineapp` 双向交互协议(`agentline.interaction.v1`):命令可通过 Invocation 注解获取桥接对象并执行 `Emit/Ask`;交互端新增 `/questions`、`/reply`、`/skip` 处理运行中问答回路。 +- 增强 `example/copilot-demo`:新增 `acp-turn` 显式命令入口,支持以 `allow/deny/cancel` 决策模拟 ACP `session/request_permission` 回合。 +- 调整 `example/copilot-demo chat`:当提供 `--session-id` 时自动按 resume 模式继续指定会话;未提供时保持创建新会话语义。 +- 增强 `example/copilot-demo chat/resume`:新增 `--dump-events` / `--events-limit` / `--events-raw`,可在 ResumeSession 与发送 Prompt 后通过 `GetMessages` 打印会话事件明细,便于排查多轮执行过程。 +- 新增 `example/copilot-demo events` 只读命令:支持通过 `--session-id` 查看 `GetMessages` 事件明细(可配合 `--events-limit` / `--events-raw`),且不发送 Prompt。 +- 增强 `example/copilot-demo` 事件观测:新增 `--events-out`(默认 `data.jsonl` JSONL 导出)与 `--events-view`(`timeline/summary/none`),支持“可归档 + 可回放”的会话过程查看方式。 +- 增强 `example/copilot-demo` 与 `cmds/agentlineapp` 协议联动:新增 `interactive-demo` 命令示例,演示命令侧通过 `agentline.interaction.v1` 进行 `Emit/Ask` 双向交互,并可配合 `/questions`、`/reply`、`/skip` 完成问答闭环。 + +## 修复 + +- 修复根命令重复初始化场景下的全局标志重复注入问题,避免在 web 执行路径触发 `completion flag redefined: env` panic。 +- 修复 Web 控制台 `enum-array` 交互异常(选择一个值时误全选)。 +- 修复 Web 控制台中 args 映射丢失问题:当命名 `args` 缺失时回退使用 `rawArgs`,保证调用链路参数完整。 +- 修复 `internal/webui/server_test.go` 中 `resp.Body.Close()` 返回值未检查导致的 `task lint` 失败。 +- 修复 Web 控制台交互终端快捷键不生效问题:拦截并转发 `Ctrl+C/Ctrl+D/Ctrl+Z` 控制字符,避免浏览器默认快捷键吞掉输入,支持更稳定地中断 `top` 等交互程序。 +- 修复 Web 控制台后端控制字符处理:收到 `Ctrl+C/Ctrl+Z` 时优先向 PTY 前台进程组发送 `SIGINT/SIGTSTP`,并在信号路径不可用时回退为原始字节写入,提升交互程序中断可靠性。 +- 修复 Web 控制台 PTY 信号目标选择:控制字符信号路径改为基于 slave PTY (`pts`) 获取前台进程组,避免在 master PTY (`ptmx`) 场景下信号不生效。 +- 增加 Web 控制台终端控制键后端诊断日志(环境变量 `REDANT_WEB_TTY_DEBUG=1`):可观察控制键接收、信号路径结果与回退写入路径,便于排查 `Ctrl+C` 等问题。 +- 修复 Web 控制台 `Ctrl+C` 信号兜底路径:当 `TIOCGPGRP` 返回 `inappropriate ioctl for device` 时,回退为向 shell 独立进程组发送信号,避免仅原始字节写入导致中断失效。 +- 修复 `cmds/readlinecmd` 的 TAB 自动补全文本拼接异常:补全回调改为按 `chzyer/readline` 协议返回“候选后缀”而非完整词,避免出现 `proproject` 等重复拼接。 +- 修复 `cmds/readlinecmd` 在多轮执行后退出时报 `close /dev/stdin: file already closed`:子命令执行改为传入不可关闭的只读 `stdin` 包装,避免循环内子调用提前关闭外层标准输入。 +- 修复 `cmds/richlinecmd` 在窄窗口/长候选描述场景下偶发的重绘残影(如补全标题重复显示):对日志、候选与提示行按终端宽度截断,避免自动换行导致的行数估算偏差。 +- 优化 `cmds/richlinecmd` 输出管理:启用 Alt Screen 隔离交互界面,规范化命令输出中的 `\r` 回车覆盖序列,并将日志改为按终端宽度换行展示,减少“覆盖命令行/输出截断”现象。 +- 增强 `cmds/richlinecmd` 大输出场景体验:新增独立输出视窗(可滚动浏览历史输出),支持 `Ctrl+O` 切换输出滚动模式并使用 `↑/↓/PgUp/PgDn/Home/End` 导航,避免仅显示尾部导致历史内容不可见。 +- 调整 `cmds/richlinecmd` 输出呈现为“按命令分块”的历史视图:每次执行对应一个输出块,输入区始终固定在底部;在无候选列表时可直接通过 `PgUp/PgDn/Home/End` 浏览输出历史。 +- 增强 `cmds/richlinecmd` 交互入口:新增 slash 命令模式(如 `/output`、`/input`、`/help`),用于在 `Ctrl+O` 不可用场景下切换输出滚动与查看帮助。 +- 增强 `cmds/richlinecmd` slash 体验:输入 `/` 或前缀(如 `/o`)时提供自动补全候选,并支持 `Tab` 补全到完整 slash 命令。 +- 优化 `cmds/richlinecmd` 空输入补全体验:首次按 `Tab` 优先展示起始命令候选(无命令时回退默认候选),再次按 `Tab` 才应用当前选中项,降低“无提示不知道输入什么”的使用门槛。 +- 新增 `cmds/webttycmd`:提供最简本地 Web 终端(`WebSocket + PTY`)能力,并支持拖拽/批量上传、目录浏览与单文件下载。 +- 修复 `cmds/webttycmd` 交互终端在 `htop` 等全屏程序下 `Ctrl+C` 不中断的问题:控制字符输入改为优先向前台进程组发送信号,失败时回退原始字节写入。 + +## 变更 + +- 仓库瘦身:将 `cmds/agentlineapp`、`pkg/agentline`、`example/copilot-demo`(及依赖该能力的示例)迁移到独立项目维护,`redant` 主仓聚焦 CLI 框架核心能力。 +- `internal/mcpserver` 的 MCP 协议处理切换为基于 `github.com/modelcontextprotocol/go-sdk`,移除自实现报文编解码,复用官方 Server/Transport 能力简化维护。 +- MCP `serverInfo.name` 改为从根命令名动态推导(为空时回退 `redant-mcp`),使对外标识与 CLI 应用名一致。 +- MCP `tools/call` 在保留文本 `Content` 的同时,新增 `StructuredContent`(`ok/stdout/stderr/error/combined`)并声明 `OutputSchema`,便于上层程序化消费。 +- Web 运行接口返回扩展为 `program + argv + invocation`,前端据此渲染反斜杠续行的多行 CLI 调用过程,提升长命令可读性。 +- Web 控制台交互终端增强:连接后返回并展示 `shell/cwd` 上下文,前端终端随容器自动 fit,并在尺寸变化时同步 PTY `resize`,提升“本地 shell”一致性体验。 +- Web 控制台左侧 `Command` 列表改为树形可缩进导航,支持节点展开/折叠与搜索联动,减少多级命令场景下的选择混乱。 +- Web 控制台左侧菜单支持收起/展开(窄栏模式),便于在小屏或参数编辑时为主内容区释放更多空间。 +- Web 控制台交互终端支持一键全屏放大与 ESC 退出,并在切换时自动同步终端尺寸,提升长输出与交互调试体验。 +- 优化 Web 控制台交互终端全屏样式:增加暗色遮罩、沉浸式面板布局与页面滚动锁定,提升全屏观感与操作一致性。 +- `cmds/readlinecmd` 在每次执行前输出完整命令行(含必要引号转义),便于调试与复现实例命令。 +- `cmds/richlinecmd` 候选显示行数改为按当前终端窗口高度动态计算(窗口足够高时可一次显示全部候选),并将 `PgUp/PgDn` 翻页步长同步为动态行数。 +- `cmds/richlinecmd` 依赖升级到 Bubble Tea/Bubbles v2:模块路径切换为 `charm.land/bubbletea/v2` 与 `charm.land/bubbles/v2`,并同步适配 `tea.View`、输入组件样式与窗口设置 API。 +- 增强 `cmds/agentlineapp` 运行体验:新增 `/cancel`(含 `Ctrl+C` 运行中中断语义)、`tool.parse` 轨迹块与 `result` 状态/耗时信息(`status`、`duration`),使会话执行反馈更接近 agent CLI。 +- 增强 `cmds/agentlineapp` 对话编排:`/ask` 输出升级为多步骤会话块(`assistant.think` + `tool.placeholder` + 最终回复),并新增 `/fold` / `/unfold` 以折叠或展开 assistant/tool 详情,提升长会话可读性。 +- 增强 `cmds/agentlineapp` 交互布局为“输出区 + 输入区”双区域视图:启用鼠标滚轮分区滚动(输出区浏览会话输出、输入区浏览输入历史),支持点击输入历史回填到输入框并高亮当前选中项,保留键盘导航与 slash 候选协同体验。 +- 调整 `cmds/agentlineapp` 鼠标控制方式:移除 `/mouse` slash 命令,改为使用 `F2` 切换鼠标捕获,并在提示中说明 `Shift` 临时旁路复制。 +- 增强 agent 模式接入语义:`Command` 新增 `Metadata` 注解并支持 `mode=agent` / `agent.command=true` / `agent.entry=true`;其中 `agent.entry=true`(或 `mode=agent`)用于自动重定向进入 `agentline`,`agent.command=true` 用于将命令标记为交互模式可识别命令;`agentline` 新增 `/` 直接执行路径(如 `/commit --message hi`),并在 `/` 候选与 `/help` 中动态展示命令型 slash 入口,实现“普通 CLI 命令 + 交互 slash 命令”双模式。 +- 调整 `cmds/agentlineapp` slash 候选展示:仅显示 slash 主命令,不再展示其别名(如 `/a`、`/q`),降低候选噪音并避免列表膨胀。 +- 调整 `example/copilot-demo resume`:`prompt` 参数改为可选,默认使用“继续”。 +- 增强 `cmds/webttycmd` 上传体验:支持可配置并发上传、总进度统计,以及单文件取消/全部取消,提升大批量文件场景的可控性。 +- 增强 `cmds/webttycmd` 会话与下载能力:支持前端自动重连(指数退避)/手动重连,新增目录打包下载接口(`/download-zip`),并提供上传调度策略(FIFO/小文件优先/大文件优先)。 +- 增强 `cmds/richlinecmd` 终端提示体验:状态栏新增 `IDLE/RUNNING/OUTPUT_SCROLL`,显示 `focus=INPUT/OUTPUT` 与输出区滚动状态(`offset/rows`);`Ctrl+O` 切换输入/输出焦点时追加显式提示块,便于识别当前交互模式。 + +## 文档 + +- 更新 `README.md` 与 `docs/INDEX.md`:补充仓库范围说明,明确 `agentline` / `copilot-demo` 已迁移到独立项目维护。 +- 补充 `README.md`、`docs/USAGE_AT_A_GLANCE.md` 与 `docs/DESIGN.md`:新增隐藏内部标志 `--args` 的用途、示例与 `RawArgs` 交互说明。 +- 补充 `README.md`、`docs/DESIGN.md` 与 `docs/INDEX.md`:新增 MCP 集成入口、模块职责与阅读路径。 +- 补充 `README.md`、`docs/USAGE_AT_A_GLANCE.md`、`docs/DESIGN.md` 与 `docs/INDEX.md`:新增 Web 控制台说明、调用过程展示约定与参数顺序语义。 +- 新增 `docs/MCP.md`:补全 MCP 子命令、工具映射规则、`tools/call` 输入输出协议、类型映射与常见问题排查。 +- 重排 `README.md` 为总览型入口(精简章节与篇幅),将 Busybox/MCP/Web 等细节流程下沉到 `docs/*` 专项文档。 +- 新增 `docs/WEBTTY.md`,补充 WebTTY 的能力边界、接口约定与“按能力逐项推进”的开发路线;同步更新 `README.md`、`docs/INDEX.md` 与 `docs/DESIGN.md` 入口与架构说明。 + diff --git a/.version/changelog/v0.0.4.md b/.version/changelog/v0.0.4.md new file mode 100644 index 0000000..fbca6e6 --- /dev/null +++ b/.version/changelog/v0.0.4.md @@ -0,0 +1,13 @@ +# [v0.0.4] - 2025-12-24 + +## 新增 + +- 发布 Redant 初始版本。 +- 支持命令树结构与多级子命令。 +- 支持命令行标志与环境变量多来源配置。 +- 支持中间件链式编排。 +- 支持自动帮助系统。 +- 支持多格式参数:位置参数、查询串、表单、JSON。 +- 支持统一全局标志管理。 +- 提供示例工程与基础测试。 + diff --git a/.version/changelog/v0.0.5.md b/.version/changelog/v0.0.5.md new file mode 100644 index 0000000..42040d8 --- /dev/null +++ b/.version/changelog/v0.0.5.md @@ -0,0 +1,24 @@ +# [v0.0.5] - 2026-01-20 + +## 修复 + +- 修复 `Int64.Type()` 返回类型错误导致 `pflag.GetInt64()` 获取失败的问题。 +- 修复废弃标志告警重复显示的问题。 +- 修复子命令无法继承父命令标志的问题。 +- 修复 `Option.Default` 默认值未正确应用到实际值的问题。 + +## 新增 + +- 增加命令执行相关单元测试(`command_test.go`)。 +- 增加标志值类型相关单元测试(`flags_test.go`)。 +- 增加框架评估文档(`docs/EVALUATION.md`)。 + +## 变更 + +- 优化目录结构,分离核心框架与命令实现。 +- 将补全命令移动到 `cmds/completioncmd`。 +- 移除配置文件与热更新相关能力,以保持框架简洁。 +- 优化 `--list-commands` 输出格式:去掉冗余标题、增强参数展示。 +- 优化 `--list-flags` 输出格式:精简子命令路径展示。 +- 增强全局标志显示:根命令中非隐藏标志可统一展示为全局标志。 + diff --git a/.version/changelog/v0.0.6.md b/.version/changelog/v0.0.6.md new file mode 100644 index 0000000..ec42e06 --- /dev/null +++ b/.version/changelog/v0.0.6.md @@ -0,0 +1,20 @@ +# [v0.0.6] - 2026-03-14 + +## 新增 + +- 新增内建全局环境标志:`--env`(简写 `-e`)与 `--env-file`,支持在命令解析前注入环境变量。 + +## 修复 + +- 修复 `preloadEnvFromArgs` 在预解析失败时可能残留已写入环境变量的问题,失败路径会自动回滚已变更项。 + +## 变更 + +- 将 `github.com/coder/pretty` 迁移为内部实现 `internal/pretty`,以消除上游停止维护带来的依赖风险。 +- `help.go` 改为使用内部导入路径:`github.com/pubgo/redant/internal/pretty`。 + +## 文档 + +- 新增内部维护文档:`internal/pretty/README.md`。 +- 更新 `README.md` 与 `docs/USAGE_AT_A_GLANCE.md`,补充全局环境标志说明与使用示例。 + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5dee4b4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md + +## 适用范围 +- 本指南面向在 `redant` 仓库内工作的 AI 编码代理。 +- 优先保证行为与 API 兼容性,避免无关重构;该项目是 CLI 框架核心。 + +## 架构速览 +- 命令运行主线在 `command.go`(`Invocation.Run` -> `inv.run`):命令定位、标志装配、参数解析、中间件链与处理器执行。 +- 标志模型在 `option.go`:`OptionSet.FlagSet()` 先应用默认值,再按 `Envs` 首个非空值做环境回退,最后由 CLI 输入覆盖。 +- 参数形态在 `args.go`:位置参数、query(`a=1&b=2`)、form(`a=1 b=2`)、JSON 对象/数组。 +- 帮助渲染由 `help.go` + `help.tpl` 模板驱动,样式层位于 `internal/pretty`。 +- Shell 补全作为命令模块集成在 `cmds/completioncmd/completion.go`。 + +## 关键运行规则(不要破坏) +- 子命令解析同时支持 `app repo commit` 与 `app repo:commit`(`command.go` 的 `getExecCommand`)。 +- 分发优先级:显式子命令 > `argv0` busybox 分发 > 根命令(见 `getExecCommand` + `resolveArgv0Command`)。 +- 根全局标志来自 `args.go` 的 `GlobalFlags()`,在命令初始化时注入。 +- 子命令继承父标志;出现重名时,深层命令标志覆盖浅层标志(`command.go` 的 `copyFlagSetWithout` 逻辑)。 +- `--list-commands` / `--list-flags` 会在 Handler 前短路执行(`command.go`)。 +- 环境预加载(`--env`、`-e`、`--env-file`)先从原始参数读取,再在运行结束后恢复(`env_preload.go`)。 +- Required 选项判定认可三类来源:显式改动 flag、默认值、配置了 env 键列表(`command.go` 必填校验逻辑)。 + +## 开发工作流 +- 任务入口(`taskfile.yml`): + - `task test`(内部使用 `go test -short -race -v ./... -cover`) + - `task vet` + - `task lint` +- `Taskfile` 会加载 `.env`(`dotenv: [".env"]`),测试行为可能受环境占位键影响。 + +## 项目约定(来自现有代码) +- 测试以表驱动 + 子测试为主(见 `command_test.go`、`env_preload_test.go`)。 +- 补测试优先覆盖边界语义,而非只测 happy path:argv0 分发、flag 继承、env-file 的 CSV/重复输入、`--` 停止符。 +- 文档默认中文;涉及流程文档变更时保留 Mermaid 图(`README.md`、`docs/DESIGN.md`、`docs/USAGE_AT_A_GLANCE.md`)。 + +## 集成点与依赖 +- CLI 标志引擎:`github.com/spf13/pflag`(自定义值类型见 `flags.go`)。 +- 帮助输出格式:`github.com/muesli/termenv`、`github.com/mitchellh/go-wordwrap`、`internal/pretty`。 +- YAML/JSON 值包装能力在 `flags.go` 中实现,用于类型化选项。 + +## 变更落点清单 +- 调整命令分发/执行:改 `command.go`,并在 `command_test.go` 增加针对性测试。 +- 调整 flag/env/default 语义:改 `option.go` / `env_preload.go`,并更新 `env_preload_test.go`。 +- 调整参数格式行为:改 `args.go`,并同步 `example/args-test/` 示例。 +- 调整帮助/补全体验:改 `help.go`/`help.tpl` 或 `cmds/completioncmd/`,并验证相关输出路径。 + diff --git a/README.md b/README.md index 9b8d107..9adb571 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,15 @@ Redant 是一个用于构建大型 Go 命令行程序的框架,提供命令树 ## 文档导航 -```mermaid -flowchart TD - A[README 总览] --> B[docs/INDEX.md 文档索引] - B --> C[docs/DESIGN.md 架构与执行设计] - B --> D[docs/EVALUATION.md 质量评估与改进] - B --> E[docs/CHANGELOG.md 版本变更] - B --> F[example/args-test/README.md 参数解析示例] -``` +README 仅保留“快速上手 + 能力入口”。详细设计与流程请跳转: -- 文档总索引:[`docs/INDEX.md`](docs/INDEX.md) -- 使用规范速览:[`docs/USAGE_AT_A_GLANCE.md`](docs/USAGE_AT_A_GLANCE.md) +- 总索引:[`docs/INDEX.md`](docs/INDEX.md) +- 使用速览:[`docs/USAGE_AT_A_GLANCE.md`](docs/USAGE_AT_A_GLANCE.md) - 架构设计:[`docs/DESIGN.md`](docs/DESIGN.md) +- MCP 指南:[`docs/MCP.md`](docs/MCP.md) +- WebTTY 指南:[`docs/WEBTTY.md`](docs/WEBTTY.md) - 评估报告:[`docs/EVALUATION.md`](docs/EVALUATION.md) -- 版本记录:[`docs/CHANGELOG.md`](docs/CHANGELOG.md) -- 参数示例:[`example/args-test/README.md`](example/args-test/README.md) - -术语使用请参考:[`docs/INDEX.md`](docs/INDEX.md) 的“术语约定”章节。 +- 变更记录:[`.version/changelog/README.md`](.version/changelog/README.md) ## 核心能力 @@ -30,18 +22,14 @@ flowchart TD - 自动帮助信息与全局标志 - 多格式参数解析(位置参数、查询串、表单、JSON) - Busybox 风格 argv0 调度(软链接命令入口) +- MCP 工具暴露(将命令树映射为 Model Context Protocol Tools) +- Web 控制台(`web` 子命令):可视化选择命令、填写 Flags/Args、查看调用过程与执行结果 -## 架构总览 +## 仓库范围说明 -```mermaid -flowchart LR - U[用户输入] --> P[命令解析] - P --> F[标志解析] - F --> A[参数解析] - A --> M[中间件链] - M --> H[命令处理器] - H --> O[输出与退出码] -``` +- 当前 `redant` 仓库聚焦 CLI 框架核心能力(命令树、Flag/Args 解析、中间件、帮助系统、MCP/Web/WebTTY)。 +- `agentline`、`copilot-demo` 及相关示例已迁移到独立项目维护(fastgit 侧),不再作为本仓库内置模块继续演进。 +- 如需 Agent 交互与 Copilot 集成演示,请在迁移后的项目中使用对应目录与示例。 ## 快速开始 @@ -76,96 +64,67 @@ func main() { } ``` -## Busybox 风格命令入口 +## 常用能力速览 -通过软链接将一个二进制映射为多个独立命令名,框架会根据 `argv0` 自动分发到对应子命令。 +### 参数与标志 -### 完整调用流程(构建到分发) +- 子命令支持空格路径与冒号路径(如 `app repo commit` / `app repo:commit`)。 +- 参数支持位置参数、query、form、JSON 四种形态。 +- 推荐写法:`app [flags...] [args...]`。 -```mermaid -flowchart TD - A[编译生成二进制: app] --> B[创建软连接: ln -sf app echo] - B --> C[调用软连接: ./echo hello] - C --> D[程序读取 argv0] - D --> E{argv0 名称是否与命令/别名一致} - E -- 是 --> F[选择对应子命令并执行] - E -- 否 --> G[回退到显式参数解析或根命令] -``` +常用全局标志: + +- `--help, -h` +- `--list-commands` +- `--list-flags` +- `--env, -e KEY=VALUE` +- `--env-file FILE` +- `--args VALUE`(内部隐藏,用于覆盖位置参数) -流程说明: - -1. 先构建主二进制(例如 `app`)。 -2. 通过 `ln -sf` 创建软连接(例如 `echo -> app`)。 -3. 用户调用软连接名(例如 `echo hello`)。 -4. 框架读取 `argv0`(此时通常为 `echo`)。 -5. 若 `argv0` 与命令名或别名匹配,则直接调用该子命令;否则按常规参数路径继续解析。 - -```mermaid -flowchart TD - A[启动可执行文件] --> B{是否显式提供子命令} - B -- 是 --> C[按参数分发] - B -- 否 --> D[按 argv0 分发] - C --> E[执行目标命令] - D --> E +详细解析规则见:[`docs/USAGE_AT_A_GLANCE.md`](docs/USAGE_AT_A_GLANCE.md)。 + +### Web 调试界面 + +```text +app web +app web --addr 127.0.0.1:18080 --open=false ``` -示例: - -- 显式调用:`app echo hello` -- 软链接调用:`echo hello` - -## 参数解析流程 - -框架在命令分发完成后,会进入统一参数解析阶段,支持位置参数、查询串、表单与 JSON。 - -```mermaid -flowchart TD - A[接收命令行输入] --> B[完成命令分发] - B --> C[解析全局/局部标志] - C --> D[提取剩余参数] - D --> E{参数形态判断} - E -- 普通 token --> F[位置参数] - E -- 包含 '=' 且含 '&' --> G[查询串参数] - E -- 包含 '=' 且含空格 --> H[表单参数] - E -- 以 '{' 或 '[' 开头 --> I[JSON 参数] - F --> J[写入 ArgSet / inv.Args] - G --> J - H --> J - I --> J - J --> K[必填与类型校验] - K --> L[进入中间件与 Handler] +Web 控制台支持可视化填写 flags/args,并展示 `curl` 与多行 CLI 调用过程。更多说明见:[`docs/USAGE_AT_A_GLANCE.md`](docs/USAGE_AT_A_GLANCE.md)。 + +### WebTTY 本地终端 + +```text +app webtty +app webtty --addr 127.0.0.1:18081 --open=false ``` -参数解析落地示例见:[`example/args-test/README.md`](example/args-test/README.md)。 - -### 参数解析优先级 - -```mermaid -flowchart TD - A[输入命令行] --> B{是否命中显式子命令} - B -- 是 --> C[按显式子命令执行] - B -- 否 --> D{argv0 是否命中命令/别名} - D -- 是 --> E[按 argv0 分发子命令] - D -- 否 --> F[保留根命令路径] - C --> G[解析标志] - E --> G - F --> G - G --> H[解析剩余参数] - H --> I[进入中间件与 Handler] +`webtty` 提供最简本地 Web 终端能力(`WebSocket + PTY`),并支持文件上传/下载。详细接口与迭代路线见:[`docs/WEBTTY.md`](docs/WEBTTY.md)。 + +### Richline 交互终端(可选挂载) + +若你的应用挂载了 `cmds/richlinecmd`,可通过以下方式进入交互终端: + +```text +app richline ``` -优先级顺序: +当前 richline 界面支持: -1. 显式子命令(最高) -2. `argv0` 命令/别名分发 -3. 根命令默认路径 -4. 标志解析与参数格式解析 +- 状态栏:`IDLE / RUNNING / OUTPUT_SCROLL` +- 焦点提示:`focus=INPUT / OUTPUT` +- 输出区滚动状态:显示 `offset/rows` +- 快捷切换:`Ctrl+O` 在输入区与输出滚动区之间切换,并给出显式切换提示 -## 全局标志 +### MCP 集成 -- `--help, -h`:显示帮助 -- `--list-commands`:列出命令树 -- `--list-flags`:列出所有标志 +```text +app mcp list +app mcp list --format text +app mcp serve --transport stdio +``` + +MCP 输入/输出协议、Schema 规则与排查建议见:[`docs/MCP.md`](docs/MCP.md)。 ## 示例目录 @@ -173,35 +132,13 @@ flowchart TD - `example/echo`:最小命令示例 - `example/env-test`:环境变量示例 - `example/globalflags`:全局标志示例 -- `example/args-test`:参数格式解析示例 - -## AI 协作:文档与 Changelog 维护 - -本仓库已提供面向 Copilot Chat 的文档与变更日志维护配置,便于在多人协作时保持文风一致、结构稳定、条目可追溯。 - -### 相关文件 - -- 工作区总指引:`.github/copilot-instructions.md` -- 文档专项规则:`.github/instructions/documentation.instructions.md` -- Changelog 专项规则:`.github/instructions/changelog.instructions.md` -- Changelog 维护提示词:`.github/prompts/changelog-maintenance.prompt.md` -- Changelog 模板参考:`docs/CHANGELOG_LLM_PROMPT.md` - -### 怎么使用 - -1. 常规文档维护(`README.md`、`docs/**`、`example/**/README.md` 等): - - 直接在聊天中描述文档修改需求,文档专项规则会自动参与。 -2. 维护 `docs/CHANGELOG.md`(推荐): - - 在聊天输入:`/changelog-maintenance draft` - - 用于根据当前改动更新 `Unreleased`。 -3. 发布前落版 `CHANGELOG`: - - 在聊天输入:`/changelog-maintenance release` - - 由 agent 按 `.version/VERSION` 自动执行版本落版。 +- `example/args-test`:参数解析示例 -### 维护建议 +## 开发与维护 -- 当前 changelog 流程采用 LLM/agent 维护,不再依赖本地脚本与 task 子命令。 -- 建议在合并前执行一次 `/changelog-maintenance draft`,发布前执行 `/changelog-maintenance release`。 +- 文档入口:[`docs/INDEX.md`](docs/INDEX.md) +- 变更记录:[`.version/changelog/README.md`](.version/changelog/README.md) +- 文档/变更维护提示:[`docs/CHANGELOG_LLM_PROMPT.md`](docs/CHANGELOG_LLM_PROMPT.md) ## 许可证 diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..032d68b --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,43 @@ +# https://taskfile.dev + +version: "3" + +dotenv: [".env"] + +tasks: + default: + desc: "default task" + cmds: + - task -a + + vet: + desc: "go vet" + cmds: + - go vet ./... + + test: + desc: "go test" + cmds: + - | + pkgs=$(go list -json ./... | awk ' + /^\}/ { if (pkg != "" && has_test == 1) print pkg; pkg = ""; has_test = 0; next } + /"ImportPath":/ { gsub(/[",]/, "", $2); pkg = $2 } + /"TestGoFiles": \[/ { has_test = 1 } + /"XTestGoFiles": \[/ { has_test = 1 } + ') + go test -short -race -v -cover $pkgs + + lint: + desc: "golangci-lint" + cmds: + - golangci-lint run --verbose ./... + + pr:current: + desc: "get PR of current branch" + cmds: + - bash scripts/get-current-pr.sh + + pr:ensure-draft: + desc: "get current branch PR or create draft if missing" + cmds: + - bash scripts/get-current-pr.sh --ensure-draft --base main diff --git a/args.go b/args.go index 41cc542..c0e71bc 100644 --- a/args.go +++ b/args.go @@ -63,6 +63,8 @@ import ( // ArgValidator is a function that validates an argument. +const internalArgsOverrideFlag = "args" + type ArgSet []Arg type Arg struct { @@ -268,6 +270,23 @@ func GlobalFlags() OptionSet { Description: "List all flags.", Value: BoolOf(new(bool)), }, + { + Flag: "env", + Shorthand: "e", + Description: "Set environment variables (format: KEY=VALUE). Supports repeat and CSV.", + Value: StringArrayOf(new([]string)), + }, + { + Flag: "env-file", + Description: "Load environment variables from file(s). Supports repeat and CSV.", + Value: StringArrayOf(new([]string)), + }, + { + Flag: internalArgsOverrideFlag, + Description: "Internal: override parsed args using repeated/CSV values.", + Value: StringArrayOf(new([]string)), + Hidden: true, + }, } } diff --git a/cmds/completioncmd/completion.go b/cmds/completioncmd/completion.go index 0a37c52..095d6ac 100644 --- a/cmds/completioncmd/completion.go +++ b/cmds/completioncmd/completion.go @@ -6,7 +6,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "github.com/pubgo/redant" @@ -173,29 +172,46 @@ func generateZshCompletion(ctx context.Context, inv *redant.Invocation) error { } progName := filepath.Base(os.Args[0]) + funcName := "_" + progName // Header header := fmt.Sprintf(`#compdef %s +compdef %s %s # %s completion for zsh # Autogenerated by redant %s() { - local context state line - typeset -A opt_args + local -a cmd_path options subcommands + local word i - _arguments -C \ -`, progName, progName, progName) + for (( i = 2; i < CURRENT; i++ )); do + word="${words[i]}" + [[ -z "$word" ]] && continue + [[ "$word" == "--" ]] && break + [[ "$word" == -* ]] && continue + cmd_path+=("$word") + done + + local cmd_key="${(j: :)cmd_path}" + case "$cmd_key" in +`, progName, funcName, progName, progName, funcName) // Generate command completions var commandsBuf bytes.Buffer - generateZshCommandCompletions(cmd, &commandsBuf) + generateZshCommandCases(cmd, nil, &commandsBuf) // Footer - footer := fmt.Sprintf(`)} - -%s -`, progName) + footer := ` esac + + if (( ${#subcommands[@]} > 0 )); then + _describe 'subcommands' subcommands + fi + if (( ${#options[@]} > 0 )) && [[ "${words[CURRENT]}" == -* ]]; then + _describe 'options' options + fi +} +` // Write the full script if _, err := fmt.Fprint(inv.Stdout, header); err != nil { @@ -211,68 +227,55 @@ func generateZshCompletion(ctx context.Context, inv *redant.Invocation) error { return nil } -// generateZshCommandCompletions generates command completion for zsh recursively -func generateZshCommandCompletions(cmd *redant.Command, buf *bytes.Buffer) { - cmdName := cmd.Name() - fullName := cmd.FullName() - - // Generate command entry - if fullName == cmdName { // Root command - fmt.Fprintf(buf, " '::subcommands:(%s)'", getZshSubcommands(cmd)) - } +// generateZshCommandCases emits command-path based case branches for zsh completion. +func generateZshCommandCases(cmd *redant.Command, path []string, buf *bytes.Buffer) { + caseKey := strings.Join(path, " ") + fmt.Fprintf(buf, " %q)\n", caseKey) - // Add options + buf.WriteString(" options=(\n") for _, opt := range cmd.FullOptions() { - if opt.Hidden { + if opt.Hidden || opt.Flag == "" { continue } - - var optDef string + desc := optionZshDescription(opt) + fmt.Fprintf(buf, " '--%s:%s'\n", opt.Flag, desc) if opt.Shorthand != "" { - optDef = fmt.Sprintf("'-%s[--%s]' '--%s[%s]'", opt.Shorthand, opt.Flag, opt.Flag, opt.Description) - } else { - optDef = fmt.Sprintf("'--%s[%s]'", opt.Flag, opt.Description) + fmt.Fprintf(buf, " '-%s:%s'\n", opt.Shorthand, desc) } - - buf.WriteString(" " + optDef) } + buf.WriteString(" )\n") - // Generate completions for subcommands + buf.WriteString(" subcommands=(\n") for _, child := range cmd.Children { - if !child.Hidden { - buf.WriteString(" '1: :->subcmds'") - break + if child.Hidden { + continue } + fmt.Fprintf(buf, " '%s:%s'\n", child.Name(), escapeZshDescription(child.Short)) } + buf.WriteString(" )\n") + buf.WriteString(" ;;\n") - // Add subcommand handlers - if len(cmd.Children) > 0 { - buf.WriteString(`\ case $state in - subcmds) - local subcommands=(") - for _, child := range cmd.Children { - if !child.Hidden { - buf.WriteString("'" + child.Name() + ":" + child.Short + "' ") - } + for _, child := range cmd.Children { + if child.Hidden { + continue } - buf.WriteString(")") - buf.WriteString(" - _describe 'subcommands' subcommands - ;; - esac`) + generateZshCommandCases(child, append(path, child.Name()), buf) } } -// getZshSubcommands returns a string of subcommands for zsh completion -func getZshSubcommands(cmd *redant.Command) string { - var subcmds []string - for _, child := range cmd.Children { - if !child.Hidden { - subcmds = append(subcmds, child.Name()) - } +func escapeZshDescription(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "'", "\\'") + s = strings.ReplaceAll(s, ":", "\\:") + return s +} + +func optionZshDescription(opt redant.Option) string { + desc := strings.TrimSpace(opt.Description) + if desc == "" { + desc = opt.Flag } - sort.Strings(subcmds) - return strings.Join(subcmds, " ") + return escapeZshDescription(desc) } // generateFishCompletion generates fish completion script diff --git a/cmds/completioncmd/completion_test.go b/cmds/completioncmd/completion_test.go index 9f83cf7..e7cbbc3 100644 --- a/cmds/completioncmd/completion_test.go +++ b/cmds/completioncmd/completion_test.go @@ -3,6 +3,8 @@ package completioncmd import ( "bytes" "context" + "os" + "path/filepath" "testing" "github.com/pubgo/redant" @@ -76,6 +78,10 @@ func TestCompletionCommandMissingShell(t *testing.T) { if !bytes.Contains(stderr.Bytes(), []byte("Available shells: bash, zsh, fish")) { t.Fatalf("Expected available shells list, got: %s", stderr.String()) } + + if stdout.Len() != 0 { + t.Fatalf("Expected empty stdout on missing shell, got: %s", stdout.String()) + } } // TestCompletionCommandUnsupportedShell tests error handling for unsupported shell @@ -122,4 +128,135 @@ func TestCompletionCommandUnsupportedShell(t *testing.T) { if !bytes.Contains(stderr.Bytes(), []byte("Available shells: bash, zsh, fish")) { t.Fatalf("Expected available shells list, got: %s", stderr.String()) } + + if stdout.Len() != 0 { + t.Fatalf("Expected empty stdout on unsupported shell, got: %s", stdout.String()) + } +} + +func TestCompletionCommandGeneratesScriptsForSupportedShells(t *testing.T) { + oldArg0 := os.Args[0] + os.Args[0] = "testapp" + defer func() { os.Args[0] = oldArg0 }() + + tests := []struct { + name string + shell string + golden string + }{ + {name: "bash", shell: "bash", golden: "testapp.bash.golden"}, + {name: "zsh", shell: "zsh", golden: "testapp.zsh.golden"}, + {name: "fish", shell: "fish", golden: "testapp.fish.golden"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rootCmd := newCompletionTestRoot() + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + inv := rootCmd.Invoke("completion", tt.shell) + inv.Stdout = stdout + inv.Stderr = stderr + + if err := inv.Run(); err != nil { + t.Fatalf("run completion %s: %v", tt.shell, err) + } + if stderr.Len() != 0 { + t.Fatalf("expected empty stderr, got: %s", stderr.String()) + } + + wantPath := filepath.Join("testdata", tt.golden) + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.WriteFile(wantPath, stdout.Bytes(), 0o644); err != nil { + t.Fatalf("update golden %s: %v", wantPath, err) + } + } + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("read golden %s: %v", wantPath, err) + } + + got := stdout.String() + if got != string(want) { + t.Fatalf("generated %s script mismatch\n--- got ---\n%s\n--- want ---\n%s", tt.shell, got, string(want)) + } + }) + } +} + +func newCompletionTestRoot() *redant.Command { + var ( + verbose bool + outputFormat string + configFile string + + projectNS string + projectAll bool + + repoRegion string + repoForce bool + + createPrivate bool + templateFile string + tags []string + ) + + rootCmd := &redant.Command{ + Use: "testapp", + Short: "Test application", + Options: redant.OptionSet{ + {Flag: "verbose", Shorthand: "v", Description: "verbose mode", Value: redant.BoolOf(&verbose)}, + {Flag: "output", Description: "output format", Value: redant.EnumOf(&outputFormat, "text", "json", "yaml"), Default: "text"}, + {Flag: "config", Description: "config file", Value: redant.StringOf(&configFile)}, + }, + } + + projectCmd := &redant.Command{ + Use: "project", + Short: "manage projects", + Args: redant.ArgSet{ + {Name: "project_name", Required: false, Value: redant.StringOf(new(string)), Description: "project name"}, + }, + Options: redant.OptionSet{ + {Flag: "namespace", Description: "project namespace", Value: redant.StringOf(&projectNS)}, + {Flag: "all", Description: "apply to all projects", Value: redant.BoolOf(&projectAll)}, + }, + } + + repoCmd := &redant.Command{ + Use: "repo", + Short: "manage repositories", + Args: redant.ArgSet{ + {Name: "repo_name", Required: false, Value: redant.StringOf(new(string)), Description: "repository name"}, + }, + Options: redant.OptionSet{ + {Flag: "region", Description: "target region", Value: redant.EnumOf(&repoRegion, "cn", "us", "eu"), Default: "cn"}, + {Flag: "force", Description: "force operation", Value: redant.BoolOf(&repoForce)}, + }, + } + + createCmd := &redant.Command{ + Use: "create", + Short: "create repository", + Args: redant.ArgSet{ + {Name: "repo", Required: true, Value: redant.StringOf(new(string)), Description: "repository id"}, + }, + Options: redant.OptionSet{ + {Flag: "private", Description: "create private repository", Value: redant.BoolOf(&createPrivate)}, + {Flag: "template", Description: "template file", Value: redant.StringOf(&templateFile)}, + {Flag: "tags", Description: "repository tags", Value: redant.StringArrayOf(&tags)}, + }, + } + + repoCmd.Children = append(repoCmd.Children, createCmd) + projectCmd.Children = append(projectCmd.Children, repoCmd) + + rootCmd.Children = append(rootCmd.Children, + &redant.Command{Use: "hello", Short: "say hello", Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }}, + &redant.Command{Use: "secret", Short: "hidden command", Hidden: true, Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }}, + projectCmd, + ) + AddCompletionCommand(rootCmd) + return rootCmd } diff --git a/cmds/completioncmd/testdata/testapp.bash.golden b/cmds/completioncmd/testdata/testapp.bash.golden new file mode 100644 index 0000000..0dedf42 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.bash.golden @@ -0,0 +1,137 @@ +#!/bin/bash + +# testapp completion for bash +# Autogenerated by redant + +testapp() { + local cur prev opts cmd + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + # Command stack tracking + local cmd_stack=() + local i=1 + local found_cmd=false + + # Build command stack + while [ $i -lt $COMP_CWORD ]; do + cmd="${COMP_WORDS[$i]}" + cmd_stack+=($cmd) + i=$((i+1)) + done + + # Handle completion based on command stack + case "${cmd_stack[@]}" in + "testapp") + # Completions for testapp + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + local subcmds="completion hello project " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp completion") + # Completions for completion + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; "testapp hello") + # Completions for hello + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; "testapp project") + # Completions for project + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + local subcmds="repo " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp project repo") + # Completions for repo + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + opts+='--force ' + opts+='--region ' + local subcmds="create " + COMPREPLY=( $(compgen -W "$opts $subcmds" -- "$cur") ) + ;; "testapp project repo create") + # Completions for create + local opts="--help " + opts+='--config ' + opts+='--env ' + opts+='-e ' + opts+='--env-file ' + opts+='--help ' + opts+='-h ' + opts+='--list-commands ' + opts+='--list-flags ' + opts+='--output ' + opts+='--verbose ' + opts+='-v ' + opts+='--all ' + opts+='--namespace ' + opts+='--force ' + opts+='--region ' + opts+='--private ' + opts+='--tags ' + opts+='--template ' + COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + ;; *) + COMPREPLY=() + ;; + esac +} + +complete -F testapp testapp diff --git a/cmds/completioncmd/testdata/testapp.fish.golden b/cmds/completioncmd/testdata/testapp.fish.golden new file mode 100644 index 0000000..afdce41 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.fish.golden @@ -0,0 +1,70 @@ +# testapp completion for fish +# Autogenerated by redant + +complete -c testapp -n "__fish_use_subcommand" -a "completion" -d "Generate the autocompletion script for the specified shell" +complete -c testapp -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from " -a "completion" -d "Generate the autocompletion script for the specified shell" +complete -c testapp -n "__fish_seen_subcommand_from " -a "hello" -d "say hello" +complete -c testapp -n "__fish_seen_subcommand_from " -a "project" -d "manage projects" +complete -c testapp -n "__fish_seen_subcommand_from completion" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from completion" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from completion" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from hello" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from hello" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project" -a "repo" -d "manage repositories" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l force -d "FORCE OPERATION." +complete -c testapp -n "__fish_seen_subcommand_from project repo" -l region -d "TARGET REGION." -r -f -a "(__fish_complete_placeholder enum[cn\|us\|eu])" +complete -c testapp -n "__fish_seen_subcommand_from project repo" -a "create" -d "create repository" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l config -d "CONFIG FILE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s e -l env -d "SET ENVIRONMENT VARIABLES (FORMAT: KEY=VALUE). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l env-file -d "LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s h -l help -d "SHOW HELP FOR COMMAND." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l list-commands -d "LIST ALL COMMANDS, INCLUDING SUBCOMMANDS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l list-flags -d "LIST ALL FLAGS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l output -d "OUTPUT FORMAT." -r -f -a "(__fish_complete_placeholder enum[text\|json\|yaml])" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -s v -l verbose -d "VERBOSE MODE." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l all -d "APPLY TO ALL PROJECTS." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l namespace -d "PROJECT NAMESPACE." -r -f -a "(__fish_complete_placeholder string)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l force -d "FORCE OPERATION." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l region -d "TARGET REGION." -r -f -a "(__fish_complete_placeholder enum[cn\|us\|eu])" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l private -d "CREATE PRIVATE REPOSITORY." +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l tags -d "REPOSITORY TAGS." -r -f -a "(__fish_complete_placeholder string-array)" +complete -c testapp -n "__fish_seen_subcommand_from project repo create" -l template -d "TEMPLATE FILE." -r -f -a "(__fish_complete_placeholder string)" diff --git a/cmds/completioncmd/testdata/testapp.zsh.golden b/cmds/completioncmd/testdata/testapp.zsh.golden new file mode 100644 index 0000000..eef64a3 --- /dev/null +++ b/cmds/completioncmd/testdata/testapp.zsh.golden @@ -0,0 +1,149 @@ +#compdef testapp +compdef _testapp testapp + +# testapp completion for zsh +# Autogenerated by redant + +_testapp() { + local -a cmd_path options subcommands + local word i + + for (( i = 2; i < CURRENT; i++ )); do + word="${words[i]}" + [[ -z "$word" ]] && continue + [[ "$word" == "--" ]] && break + [[ "$word" == -* ]] && continue + cmd_path+=("$word") + done + + local cmd_key="${(j: :)cmd_path}" + case "$cmd_key" in + "") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + 'completion:Generate the autocompletion script for the specified shell' + 'hello:say hello' + 'project:manage projects' + ) + ;; + "completion") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + ) + ;; + "hello") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + ) + subcommands=( + ) + ;; + "project") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + ) + subcommands=( + 'repo:manage repositories' + ) + ;; + "project repo") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + '--force:FORCE OPERATION.' + '--region:TARGET REGION.' + ) + subcommands=( + 'create:create repository' + ) + ;; + "project repo create") + options=( + '--config:CONFIG FILE.' + '--env:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '-e:SET ENVIRONMENT VARIABLES (FORMAT\: KEY=VALUE). SUPPORTS REPEAT AND CSV.' + '--env-file:LOAD ENVIRONMENT VARIABLES FROM FILE(S). SUPPORTS REPEAT AND CSV.' + '--help:SHOW HELP FOR COMMAND.' + '-h:SHOW HELP FOR COMMAND.' + '--list-commands:LIST ALL COMMANDS, INCLUDING SUBCOMMANDS.' + '--list-flags:LIST ALL FLAGS.' + '--output:OUTPUT FORMAT.' + '--verbose:VERBOSE MODE.' + '-v:VERBOSE MODE.' + '--all:APPLY TO ALL PROJECTS.' + '--namespace:PROJECT NAMESPACE.' + '--force:FORCE OPERATION.' + '--region:TARGET REGION.' + '--private:CREATE PRIVATE REPOSITORY.' + '--tags:REPOSITORY TAGS.' + '--template:TEMPLATE FILE.' + ) + subcommands=( + ) + ;; + esac + + if (( ${#subcommands[@]} > 0 )); then + _describe 'subcommands' subcommands + fi + if (( ${#options[@]} > 0 )) && [[ "${words[CURRENT]}" == -* ]]; then + _describe 'options' options + fi +} diff --git a/cmds/mcpcmd/mcp.go b/cmds/mcpcmd/mcp.go new file mode 100644 index 0000000..358fca6 --- /dev/null +++ b/cmds/mcpcmd/mcp.go @@ -0,0 +1,131 @@ +package mcpcmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/pubgo/redant" + "github.com/pubgo/redant/internal/mcpserver" +) + +func New() *redant.Command { + var transport string + var listFormat string + + serveCmd := &redant.Command{ + Use: "serve", + Short: "Start MCP server for current command tree.", + Long: "Expose current redant command tree as MCP tools over selected transport.", + Options: redant.OptionSet{ + { + Flag: "transport", + Description: "MCP transport type.", + Value: redant.EnumOf(&transport, "stdio"), + Default: "stdio", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + transport = strings.TrimSpace(transport) + if transport == "" { + transport = "stdio" + } + + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + switch transport { + case "stdio": + return mcpserver.ServeStdio(ctx, root, inv.Stdin, inv.Stdout) + default: + return fmt.Errorf("unsupported mcp transport: %s", transport) + } + }, + } + + listCmd := &redant.Command{ + Use: "list", + Short: "List all MCP tools metadata.", + Long: "List all mapped MCP tools (name, description, path, input/output schema).", + Options: redant.OptionSet{ + { + Flag: "format", + Description: "Output format.", + Value: redant.EnumOf(&listFormat, "json", "text"), + Default: "json", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + infos := mcpserver.ListToolInfos(root) + format := strings.TrimSpace(strings.ToLower(listFormat)) + if format == "" { + format = "json" + } + + switch format { + case "json": + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(infos) + case "text": + return writeToolInfosText(inv.Stdout, infos) + default: + return fmt.Errorf("unsupported format: %s", format) + } + }, + } + + return &redant.Command{ + Use: "mcp", + Short: "Model Context Protocol integration commands.", + Long: "Expose redant CLI definitions (commands/flags/args) as MCP tools.", + Children: []*redant.Command{ + listCmd, + serveCmd, + }, + } +} + +func AddMCPCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +func writeToolInfosText(w io.Writer, infos []mcpserver.ToolInfo) error { + if len(infos) == 0 { + _, err := fmt.Fprintln(w, "No MCP tools found.") + return err + } + + for i, info := range infos { + if _, err := fmt.Fprintf(w, "%d. %s\n", i+1, info.Name); err != nil { + return err + } + desc := strings.TrimSpace(info.Description) + if desc == "" { + desc = "(no description)" + } + if _, err := fmt.Fprintf(w, " description: %s\n", desc); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " path: %s\n", strings.Join(info.Path, " > ")); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " inputSchema: yes"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, " outputSchema: yes"); err != nil { + return err + } + } + + return nil +} diff --git a/cmds/mcpcmd/mcp_test.go b/cmds/mcpcmd/mcp_test.go new file mode 100644 index 0000000..508a27e --- /dev/null +++ b/cmds/mcpcmd/mcp_test.go @@ -0,0 +1,131 @@ +package mcpcmd + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/pubgo/redant" +) + +func TestAddMCPCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddMCPCommand(root) + + if len(root.Children) != 1 { + t.Fatalf("children len = %d, want 1", len(root.Children)) + } + + mcp := root.Children[0] + if mcp.Name() != "mcp" { + t.Fatalf("child name = %q, want %q", mcp.Name(), "mcp") + } + + if len(mcp.Children) != 2 { + t.Fatalf("mcp children len = %d, want 2", len(mcp.Children)) + } + + hasList := false + hasServe := false + for _, child := range mcp.Children { + switch child.Name() { + case "list": + hasList = true + case "serve": + hasServe = true + } + } + + if !hasList || !hasServe { + t.Fatalf("expected mcp list and mcp serve subcommands") + } +} + +func TestMCPListCommandPrintsToolInfosJSONByDefault(t *testing.T) { + root := &redant.Command{Use: "app"} + + var message string + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo one message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&message), Description: "text to echo"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + AddMCPCommand(root) + + var stdout bytes.Buffer + inv := root.Invoke("mcp", "list") + inv.Stdout = &stdout + + if err := inv.Run(); err != nil { + t.Fatalf("run mcp list: %v", err) + } + + var tools []map[string]any + if err := json.Unmarshal(stdout.Bytes(), &tools); err != nil { + t.Fatalf("parse mcp list output as json: %v\noutput:\n%s", err, stdout.String()) + } + if len(tools) == 0 { + t.Fatalf("mcp list output is empty") + } + + var echoTool map[string]any + for _, tool := range tools { + if name, _ := tool["name"].(string); name == "echo" { + echoTool = tool + break + } + } + if echoTool == nil { + t.Fatalf("echo tool not found in mcp list output: %#v", tools) + } + + if _, ok := echoTool["inputSchema"].(map[string]any); !ok { + t.Fatalf("echo inputSchema missing: %#v", echoTool) + } + if _, ok := echoTool["outputSchema"].(map[string]any); !ok { + t.Fatalf("echo outputSchema missing: %#v", echoTool) + } +} + +func TestMCPListCommandPrintsToolInfosText(t *testing.T) { + root := &redant.Command{Use: "app"} + + var message string + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo one message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&message), Description: "text to echo"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + AddMCPCommand(root) + + var stdout bytes.Buffer + inv := root.Invoke("mcp", "list", "--format", "text") + inv.Stdout = &stdout + + if err := inv.Run(); err != nil { + t.Fatalf("run mcp list --format text: %v", err) + } + + out := stdout.String() + for _, mustContain := range []string{ + "1. echo", + "description: echo one message", + "path: echo", + "inputSchema: yes", + "outputSchema: yes", + } { + if !strings.Contains(out, mustContain) { + t.Fatalf("text output missing %q\noutput:\n%s", mustContain, out) + } + } +} diff --git a/cmds/readlinecmd/readline.go b/cmds/readlinecmd/readline.go new file mode 100644 index 0000000..d27da84 --- /dev/null +++ b/cmds/readlinecmd/readline.go @@ -0,0 +1,699 @@ +package readlinecmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode" + + "github.com/chzyer/readline" + "github.com/spf13/pflag" + + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var ( + prompt string + history string + noHistory bool + doubleCtrlCToExit bool + ) + + return &redant.Command{ + Use: "readline", + Short: "启动交互式 readline 命令行", + Long: "进入多轮交互 REPL,支持命令补全、flag 补全、参数提示与循环执行。输入 exit/quit 退出。", + Options: redant.OptionSet{ + {Flag: "prompt", Description: "交互提示符", Value: redant.StringOf(&prompt), Default: "redant> "}, + {Flag: "history-file", Description: "历史记录文件路径(为空自动使用 ~/.redant_readline_history)", Value: redant.StringOf(&history)}, + {Flag: "no-history", Description: "禁用历史记录持久化", Value: redant.BoolOf(&noHistory)}, + {Flag: "double-ctrl-c-exit", Description: "启用后需要连续按两次 Ctrl+C 才退出 readline", Value: redant.BoolOf(&doubleCtrlCToExit)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + historyFile := strings.TrimSpace(history) + if historyFile == "" && !noHistory { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + historyFile = filepath.Join(home, ".redant_readline_history") + } + } + + cfg := &readline.Config{ + Prompt: prompt, + AutoComplete: &dynamicCompleter{root: root}, + InterruptPrompt: "^C", + EOFPrompt: "exit", + HistoryFile: historyFile, + Stdout: inv.Stdout, + Stderr: inv.Stderr, + } + cfg.Stdin = io.NopCloser(inv.Stdin) + if noHistory { + cfg.HistoryFile = "" + cfg.DisableAutoSaveHistory = true + } + + rl, err := readline.NewEx(cfg) + if err != nil { + return err + } + defer func() { _ = rl.Close() }() + + _, _ = fmt.Fprintln(inv.Stdout, "readline mode started, type 'help' for tips, 'exit' to quit.") + + go func() { + <-ctx.Done() + _ = rl.Close() + }() + + pendingInterrupt := false + for { + line, err := rl.Readline() + done, readErr, nextPending := handleReadlineReadError(ctx, err, line, doubleCtrlCToExit, pendingInterrupt) + pendingInterrupt = nextPending + if done { + return readErr + } + if err != nil { + continue + } + pendingInterrupt = false + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + switch line { + case "exit", "quit", ":q", `\q`: + return nil + case "help", ":help", "?": + printReadlineHelp(inv.Stdout, root) + continue + } + + args, parseErr := splitCommandLine(line) + if parseErr != nil { + _, _ = fmt.Fprintf(inv.Stderr, "parse input failed: %v\n", parseErr) + continue + } + if len(args) == 0 { + continue + } + if args[0] == root.Name() { + args = args[1:] + } + if len(args) == 0 { + continue + } + + runInv := root.Invoke(args...) + runInv.Stdout = inv.Stdout + runInv.Stderr = inv.Stderr + runInv.Stdin = readerOnly{r: inv.Stdin} + + _, _ = fmt.Fprintf(inv.Stdout, "$ %s\n", formatCommandLine(root.Name(), args)) + + if runErr := runInv.WithContext(ctx).Run(); runErr != nil { + _, _ = fmt.Fprintf(inv.Stderr, "error: %v\n", runErr) + } + } + }, + } +} + +func handleReadlineReadError(ctx context.Context, err error, line string, doubleCtrlCToExit, pendingInterrupt bool) (done bool, readErr error, nextPending bool) { + if err == nil { + return false, nil, false + } + + if errors.Is(err, readline.ErrInterrupt) { + if strings.TrimSpace(line) == "" { + if !doubleCtrlCToExit { + return true, nil, false + } + if pendingInterrupt { + return true, nil, false + } + return false, nil, true + } + return false, nil, false + } + + if errors.Is(err, io.EOF) { + return true, nil, false + } + + if ctx != nil && ctx.Err() != nil { + return true, nil, false + } + + return true, err, false +} + +type readerOnly struct { + r io.Reader +} + +func (r readerOnly) Read(p []byte) (int, error) { + if r.r == nil { + return 0, io.EOF + } + return r.r.Read(p) +} + +func formatCommandLine(program string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, quoteShellArg(program)) + for _, arg := range args { + parts = append(parts, quoteShellArg(arg)) + } + return strings.Join(parts, " ") +} + +func quoteShellArg(s string) string { + if s == "" { + return `""` + } + if !needsQuote(s) { + return s + } + return strconv.Quote(s) +} + +func needsQuote(s string) bool { + for _, r := range s { + if unicode.IsSpace(r) { + return true + } + switch r { + case '"', '\'', '\\', '$', '`', '|', '&', ';', '(', ')', '<', '>', '*', '?', '[', ']', '{', '}', '!': + return true + } + } + return false +} + +func AddReadlineCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +func printReadlineHelp(w io.Writer, root *redant.Command) { + _, _ = fmt.Fprintln(w, "available shortcuts:") + _, _ = fmt.Fprintln(w, " - TAB: completion") + _, _ = fmt.Fprintln(w, " - exit / quit: exit readline") + _, _ = fmt.Fprintln(w, " - help: show this message") + _, _ = fmt.Fprintln(w, "examples:") + _, _ = fmt.Fprintf(w, " %s commit -m \"message\"\n", root.Name()) + _, _ = fmt.Fprintln(w, " commit --help") +} + +type dynamicCompleter struct { + root *redant.Command +} + +func (c *dynamicCompleter) Do(line []rune, pos int) ([][]rune, int) { + if c == nil || c.root == nil || pos < 0 || pos > len(line) { + return nil, 0 + } + + input := string(line[:pos]) + tokens, current := splitCompletionInput(input) + + cmd, consumed := resolveCommandContext(c.root, tokens) + if cmd == nil { + cmd = c.root + } + + items := c.suggestions(cmd, consumed, tokens, current) + if len(items) == 0 { + return nil, len([]rune(current)) + } + + prefix := []rune(current) + out := make([][]rune, 0, len(items)) + for _, item := range items { + r := []rune(item) + if len(prefix) > 0 && len(r) >= len(prefix) && runesHasPrefix(r, prefix) { + out = append(out, r[len(prefix):]) + continue + } + out = append(out, r) + } + return out, len([]rune(current)) +} + +func runesHasPrefix(s, prefix []rune) bool { + if len(prefix) > len(s) { + return false + } + for i := range prefix { + if s[i] != prefix[i] { + return false + } + } + return true +} + +func (c *dynamicCompleter) suggestions(cmd *redant.Command, consumed int, tokens []string, current string) []string { + idx := buildOptionIndex(cmd) + + if v, ok := suggestFlagValues(idx, tokens, current); ok { + return uniqueSorted(v) + } + + var out []string + if strings.HasPrefix(current, "-") { + out = append(out, suggestFlags(idx, current)...) + return uniqueSorted(out) + } + + if consumed == len(tokens) { + out = append(out, suggestChildren(cmd, current)...) + } + + out = append(out, suggestArgs(cmd, idx, tokens[consumed:], current)...) + out = append(out, suggestFlags(idx, current)...) + + return uniqueSorted(out) +} + +func splitCompletionInput(input string) ([]string, string) { + trimmedRight := strings.TrimRightFunc(input, unicode.IsSpace) + if trimmedRight == "" { + return nil, "" + } + + if len(trimmedRight) < len(input) { + return strings.Fields(trimmedRight), "" + } + + parts := strings.Fields(trimmedRight) + if len(parts) == 0 { + return nil, "" + } + + current := parts[len(parts)-1] + return parts[:len(parts)-1], current +} + +func resolveCommandContext(root *redant.Command, tokens []string) (*redant.Command, int) { + if root == nil { + return nil, 0 + } + + cur := root + consumed := 0 + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + if i == 0 && tok == root.Name() { + consumed++ + continue + } + if strings.HasPrefix(tok, "-") { + break + } + + next, ok := resolveCommandToken(cur, tok) + if !ok { + break + } + cur = next + consumed++ + } + + return cur, consumed +} + +func resolveCommandToken(parent *redant.Command, token string) (*redant.Command, bool) { + if parent == nil || token == "" { + return nil, false + } + + if strings.Contains(token, ":") { + parts := strings.Split(token, ":") + cur := parent + for _, part := range parts { + child := childByNameOrAlias(cur, part) + if child == nil { + return nil, false + } + cur = child + } + return cur, true + } + + child := childByNameOrAlias(parent, token) + if child == nil { + return nil, false + } + return child, true +} + +func childByNameOrAlias(parent *redant.Command, token string) *redant.Command { + if parent == nil { + return nil + } + for _, child := range parent.Children { + if child.Hidden { + continue + } + if child.Name() == token { + return child + } + for _, alias := range child.Aliases { + if strings.TrimSpace(alias) == token { + return child + } + } + } + return nil +} + +type optionIndex struct { + byLong map[string]redant.Option + byShort map[string]redant.Option +} + +func buildOptionIndex(cmd *redant.Command) optionIndex { + idx := optionIndex{ + byLong: map[string]redant.Option{}, + byShort: map[string]redant.Option{}, + } + if cmd == nil { + return idx + } + for _, opt := range cmd.FullOptions() { + if opt.Hidden || opt.Flag == "" { + continue + } + idx.byLong[opt.Flag] = opt + if opt.Shorthand != "" { + idx.byShort[opt.Shorthand] = opt + } + } + return idx +} + +func suggestChildren(cmd *redant.Command, current string) []string { + if cmd == nil { + return nil + } + out := make([]string, 0) + for _, child := range cmd.Children { + if child.Hidden { + continue + } + if current == "" || strings.HasPrefix(child.Name(), current) { + out = append(out, child.Name()) + } + for _, alias := range child.Aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + if current == "" || strings.HasPrefix(alias, current) { + out = append(out, alias) + } + } + } + return out +} + +func suggestFlags(idx optionIndex, current string) []string { + out := make([]string, 0) + for name := range idx.byLong { + cand := "--" + name + if current == "" || strings.HasPrefix(cand, current) { + out = append(out, cand) + } + } + for short := range idx.byShort { + cand := "-" + short + if current == "" || strings.HasPrefix(cand, current) { + out = append(out, cand) + } + } + return out +} + +func suggestFlagValues(idx optionIndex, tokens []string, current string) ([]string, bool) { + if strings.HasPrefix(current, "--") && strings.Contains(current, "=") { + nameWithPrefix, valuePrefix, _ := strings.Cut(current, "=") + name := strings.TrimPrefix(nameWithPrefix, "--") + opt, ok := idx.byLong[name] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + out := make([]string, 0, len(vals)) + for _, v := range vals { + if strings.HasPrefix(v, valuePrefix) { + out = append(out, nameWithPrefix+"="+v) + } + } + return out, true + } + + if len(tokens) == 0 { + return nil, false + } + + prev := tokens[len(tokens)-1] + if strings.HasPrefix(prev, "--") { + name := strings.TrimPrefix(prev, "--") + opt, ok := idx.byLong[name] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + return filterPrefix(vals, current), true + } + + if strings.HasPrefix(prev, "-") && len(prev) == 2 { + short := strings.TrimPrefix(prev, "-") + opt, ok := idx.byShort[short] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + return filterPrefix(vals, current), true + } + + return nil, false +} + +func suggestArgs(cmd *redant.Command, idx optionIndex, restTokens []string, current string) []string { + if cmd == nil || len(cmd.Args) == 0 { + return nil + } + + argPos := countProvidedPositionals(restTokens, idx) + if argPos >= len(cmd.Args) { + return nil + } + + target := cmd.Args[argPos] + vals := filterPrefix(enumValuesFromArg(target), current) + if target.Name != "" && (current == "" || strings.HasPrefix(target.Name, current)) { + vals = append(vals, "<"+target.Name+">") + } + return vals +} + +func countProvidedPositionals(tokens []string, idx optionIndex) int { + count := 0 + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + if strings.HasPrefix(tok, "--") { + name, _, hasEq := strings.Cut(strings.TrimPrefix(tok, "--"), "=") + opt, ok := idx.byLong[name] + if ok && optionNeedsValue(opt) && !hasEq { + if i+1 < len(tokens) { + i++ + } + } + continue + } + if strings.HasPrefix(tok, "-") && len(tok) == 2 { + short := strings.TrimPrefix(tok, "-") + opt, ok := idx.byShort[short] + if ok && optionNeedsValue(opt) { + if i+1 < len(tokens) { + i++ + } + } + continue + } + count++ + } + return count +} + +func optionNeedsValue(opt redant.Option) bool { + return strings.TrimSpace(opt.Type()) != "bool" +} + +func enumValuesFromArg(arg redant.Arg) []string { + if arg.Value == nil { + return nil + } + return parseEnumValues(arg.Value.Type()) +} + +func enumValuesFromOption(opt redant.Option) []string { + if opt.Value == nil { + return nil + } + + vals := enumValuesFromValue(opt.Value) + if len(vals) == 0 { + vals = parseEnumValues(opt.Value.Type()) + } + return uniqueSorted(vals) +} + +func enumValuesFromValue(value pflag.Value) []string { + if value == nil { + return nil + } + + switch v := value.(type) { + case *redant.Enum: + return append([]string(nil), v.Choices...) + case *redant.EnumArray: + return append([]string(nil), v.Choices...) + case interface{ Underlying() pflag.Value }: + return enumValuesFromValue(v.Underlying()) + default: + return nil + } +} + +func parseEnumValues(typ string) []string { + if (!strings.HasPrefix(typ, "enum[") && !strings.HasPrefix(typ, "enum-array[")) || !strings.HasSuffix(typ, "]") { + return nil + } + + start := strings.IndexByte(typ, '[') + if start < 0 || start+1 >= len(typ)-1 { + return nil + } + inner := typ[start+1 : len(typ)-1] + + var parts []string + if strings.Contains(inner, `\|`) { + parts = strings.Split(inner, `\|`) + } else { + parts = strings.Split(inner, "|") + } + + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(strings.ReplaceAll(p, `\|`, "|")) + if v == "" { + continue + } + out = append(out, v) + } + return uniqueSorted(out) +} + +func filterPrefix(values []string, prefix string) []string { + out := make([]string, 0, len(values)) + for _, v := range values { + if prefix == "" || strings.HasPrefix(v, prefix) { + out = append(out, v) + } + } + return out +} + +func uniqueSorted(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func splitCommandLine(input string) ([]string, error) { + var ( + out []string + cur strings.Builder + quote rune + escaped bool + ) + + flush := func() { + if cur.Len() == 0 { + return + } + out = append(out, cur.String()) + cur.Reset() + } + + for _, r := range input { + switch { + case escaped: + cur.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case unicode.IsSpace(r): + flush() + default: + cur.WriteRune(r) + } + } + + if escaped { + return nil, fmt.Errorf("unfinished escape sequence") + } + if quote != 0 { + return nil, fmt.Errorf("unclosed quote") + } + flush() + return out, nil +} diff --git a/cmds/readlinecmd/readline_test.go b/cmds/readlinecmd/readline_test.go new file mode 100644 index 0000000..4147023 --- /dev/null +++ b/cmds/readlinecmd/readline_test.go @@ -0,0 +1,287 @@ +package readlinecmd + +import ( + "context" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/chzyer/readline" + + "github.com/pubgo/redant" +) + +func TestAddReadlineCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddReadlineCommand(root) + if len(root.Children) != 1 { + t.Fatalf("expected 1 child, got %d", len(root.Children)) + } + if got := root.Children[0].Name(); got != "readline" { + t.Fatalf("expected child name readline, got %q", got) + } +} + +func TestSplitCommandLine(t *testing.T) { + got, err := splitCommandLine(`commit -m "hello world" --format json`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"commit", "-m", "hello world", "--format", "json"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("split mismatch want=%v got=%v", want, got) + } + + if _, err := splitCommandLine(`commit -m "hello`); err == nil { + t.Fatal("expected unclosed quote error, got nil") + } +} + +func TestDynamicCompleter(t *testing.T) { + root := buildTestRoot() + c := &dynamicCompleter{root: root} + + contains := func(vals []string, want string) bool { + for _, v := range vals { + if v == want { + return true + } + } + return false + } + + if vals := suggestions(c, "com"); !contains(vals, "commit") { + t.Fatalf("expected commit in %v", vals) + } + if vals := suggestions(c, "commit --fo"); !contains(vals, "--format") { + t.Fatalf("expected --format in %v", vals) + } + if vals := suggestions(c, "commit --format "); !contains(vals, "json") { + t.Fatalf("expected json in %v", vals) + } + if vals := suggestions(c, "commit --format=j"); !contains(vals, "--format=json") { + t.Fatalf("expected --format=json in %v", vals) + } + if vals := suggestions(c, "commit "); !contains(vals, "") { + t.Fatalf("expected in %v", vals) + } +} + +func suggestions(c *dynamicCompleter, input string) []string { + _, current := splitCompletionInput(input) + items, _ := c.Do([]rune(input), len([]rune(input))) + out := make([]string, 0, len(items)) + for _, item := range items { + s := string(item) + switch { + case current == "": + out = append(out, s) + case strings.HasPrefix(s, current): + out = append(out, s) + default: + out = append(out, current+s) + } + } + return out +} + +type closeTrackingReader struct { + r io.Reader + closed bool +} + +func (c *closeTrackingReader) Read(p []byte) (int, error) { return c.r.Read(p) } + +func (c *closeTrackingReader) Close() error { + c.closed = true + return nil +} + +func TestReaderOnlyNotClosedByInvocation(t *testing.T) { + tracked := &closeTrackingReader{r: strings.NewReader("hello")} + + cmd := &redant.Command{ + Use: "noop", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + buf := make([]byte, 5) + _, _ = inv.Stdin.Read(buf) + return nil + }, + } + + inv := cmd.Invoke() + inv.Stdin = readerOnly{r: tracked} + if _, ok := inv.Stdin.(io.ReadCloser); ok { + t.Fatal("readerOnly should not implement io.ReadCloser") + } + + if err := inv.Run(); err != nil { + t.Fatalf("run failed: %v", err) + } + + if tracked.closed { + t.Fatal("underlying reader should not be closed") + } +} + +func TestFormatCommandLine(t *testing.T) { + tests := []struct { + name string + program string + args []string + want string + }{ + { + name: "simple", + program: "fastcommit", + args: []string{"commit", "--format", "json"}, + want: "fastcommit commit --format json", + }, + { + name: "with spaces and symbols", + program: "fastcommit", + args: []string{"commit", "-m", "hello world", "--expr", "a|b"}, + want: `fastcommit commit -m "hello world" --expr "a|b"`, + }, + { + name: "empty arg", + program: "fastcommit", + args: []string{"commit", "--note", ""}, + want: `fastcommit commit --note ""`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatCommandLine(tt.program, tt.args) + if got != tt.want { + t.Fatalf("format mismatch\nwant=%s\ngot=%s", tt.want, got) + } + }) + } +} + +func TestHandleReadlineReadError(t *testing.T) { + t.Run("ctrl+c empty line exits by default", func(t *testing.T) { + done, err, pending := handleReadlineReadError(context.Background(), readline.ErrInterrupt, "", false, false) + if !done { + t.Fatal("expected done=true for empty interrupt") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if pending { + t.Fatal("expected pending=false") + } + }) + + t.Run("ctrl+c with input continues", func(t *testing.T) { + done, err, pending := handleReadlineReadError(context.Background(), readline.ErrInterrupt, "commit", false, false) + if done { + t.Fatal("expected done=false for interrupt with input") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if pending { + t.Fatal("expected pending=false") + } + }) + + t.Run("double ctrl+c mode requires two interrupts", func(t *testing.T) { + done, err, pending := handleReadlineReadError(context.Background(), readline.ErrInterrupt, "", true, false) + if done { + t.Fatal("expected done=false on first interrupt in double mode") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if !pending { + t.Fatal("expected pending=true after first interrupt") + } + + done, err, pending = handleReadlineReadError(context.Background(), readline.ErrInterrupt, "", true, pending) + if !done { + t.Fatal("expected done=true on second interrupt in double mode") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if pending { + t.Fatal("expected pending=false after exit") + } + }) + + t.Run("eof exits", func(t *testing.T) { + done, err, pending := handleReadlineReadError(context.Background(), io.EOF, "", false, false) + if !done { + t.Fatal("expected done=true for EOF") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if pending { + t.Fatal("expected pending=false") + } + }) + + t.Run("context canceled exits", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done, err, pending := handleReadlineReadError(ctx, errors.New("read failed"), "", false, false) + if !done { + t.Fatal("expected done=true for canceled context") + } + if err != nil { + t.Fatalf("expected nil error, got %v", err) + } + if pending { + t.Fatal("expected pending=false") + } + }) + + t.Run("other errors return error", func(t *testing.T) { + targetErr := errors.New("boom") + done, err, pending := handleReadlineReadError(context.Background(), targetErr, "", false, false) + if !done { + t.Fatal("expected done=true for regular errors") + } + if !errors.Is(err, targetErr) { + t.Fatalf("expected target error, got %v", err) + } + if pending { + t.Fatal("expected pending=false") + } + }) +} + +func buildTestRoot() *redant.Command { + var ( + format string + message string + amend bool + target string + ) + + commit := &redant.Command{ + Use: "commit", + Options: redant.OptionSet{ + {Flag: "format", Value: redant.EnumOf(&format, "text", "json", "yaml")}, + {Flag: "message", Shorthand: "m", Value: redant.StringOf(&message)}, + {Flag: "amend", Value: redant.BoolOf(&amend)}, + }, + Args: redant.ArgSet{ + {Name: "target", Value: redant.EnumOf(&target, "alpha", "beta", "release")}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } + + return &redant.Command{ + Use: "app", + Children: []*redant.Command{commit}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } +} diff --git a/cmds/richlinecmd/richline.go b/cmds/richlinecmd/richline.go new file mode 100644 index 0000000..f3a824d --- /dev/null +++ b/cmds/richlinecmd/richline.go @@ -0,0 +1,1938 @@ +package richlinecmd + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/spf13/pflag" + + "github.com/pubgo/redant" +) + +const ( + defaultSuggestionRows = 10 + defaultOutputRows = 20 + minOutputRows = 6 + maxOutputBlocks = 300 + maxLogLines = 2000 +) + +var ( + stylePrompt = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + styleInputText = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + styleHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + styleHint = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + styleHeaderOutput = lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) + styleHeaderInput = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + styleHintOutput = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + styleHintInput = lipgloss.NewStyle().Foreground(lipgloss.Color("110")) + styleDescription = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + styleRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) + styleStatusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("114")).Bold(true) + styleStatusBusy = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) + styleSelectedRow = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")).Bold(true) + styleBlockHeader = lipgloss.NewStyle().Foreground(lipgloss.Color("110")).Bold(true) + + styleKindCommand = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) + styleKindFlag = lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true) + styleKindArg = lipgloss.NewStyle().Foreground(lipgloss.Color("150")).Bold(true) + styleKindEnum = lipgloss.NewStyle().Foreground(lipgloss.Color("221")).Bold(true) + styleKindDefault = lipgloss.NewStyle().Foreground(lipgloss.Color("248")).Bold(true) +) + +type completionItem struct { + Insert string + Description string + Kind completionKind +} + +type completionKind string + +const ( + completionKindCommand completionKind = "command" + completionKindFlag completionKind = "flag" + completionKindArg completionKind = "arg" + completionKindEnum completionKind = "enum" +) + +type outputBlock struct { + Title string + Lines []string +} + +type slashCommand struct { + Name string + Aliases []string + Description string +} + +var slashCommands = []slashCommand{ + {Name: "help", Aliases: []string{"?"}, Description: "显示 slash 命令帮助"}, + {Name: "output", Aliases: []string{"o", "out"}, Description: "进入输出滚动模式"}, + {Name: "input", Aliases: []string{"i"}, Description: "返回输入模式"}, + {Name: "top", Description: "跳到输出历史顶部"}, + {Name: "bottom", Aliases: []string{"end"}, Description: "跳到输出历史底部"}, + {Name: "up", Description: "输出按行向上滚动"}, + {Name: "down", Description: "输出按行向下滚动"}, + {Name: "pgup", Description: "输出按页向上滚动"}, + {Name: "pgdown", Description: "输出按页向下滚动"}, + {Name: "quit", Aliases: []string{"exit", "q"}, Description: "退出 richline"}, +} + +func New() *redant.Command { + var ( + prompt string + history string + noHistory bool + ) + + return &redant.Command{ + Use: "richline", + Short: "基于 Bubble Tea 的交互命令行(竖向补全列表)", + Long: "启动交互式 richline,使用 Bubble Tea 提供竖向补全候选和描述信息展示。", + Options: redant.OptionSet{ + {Flag: "prompt", Description: "交互提示符", Value: redant.StringOf(&prompt), Default: "richline> "}, + {Flag: "history-file", Description: "历史记录文件路径(为空自动使用 ~/.redant_richline_history)", Value: redant.StringOf(&history)}, + {Flag: "no-history", Description: "禁用历史记录持久化", Value: redant.BoolOf(&noHistory)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + historyFile := strings.TrimSpace(history) + if historyFile == "" && !noHistory { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + historyFile = filepath.Join(home, ".redant_richline_history") + } + } + + historyLines := []string{} + if !noHistory && historyFile != "" { + historyLines = loadHistory(historyFile) + } + + model := newRichlineModel(ctx, root, prompt, historyLines, historyFile, !noHistory) + p := tea.NewProgram(model, tea.WithInput(inv.Stdin), tea.WithOutput(inv.Stdout)) + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + p.Quit() + case <-done: + } + }() + + _, err := p.Run() + close(done) + if err != nil { + return err + } + return nil + }, + } +} + +func AddRichlineCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +type richlineModel struct { + ctx context.Context + root *redant.Command + input textinput.Model + prompt string + history []string + historyPos int + historyFile string + persistHistory bool + blocks []outputBlock + suggestions []completionItem + selected int + running bool + width int + height int + outputOffset int + outputFocus bool + starterPinned bool + runningCommand string +} + +type runLineResultMsg struct { + block outputBlock + quit bool +} + +func newRichlineModel(ctx context.Context, root *redant.Command, prompt string, history []string, historyFile string, persist bool) *richlineModel { + ti := textinput.New() + ti.Prompt = prompt + styles := textinput.DefaultStyles(true) + styles.Focused.Prompt = stylePrompt + styles.Focused.Text = styleInputText + styles.Blurred.Prompt = stylePrompt + styles.Blurred.Text = styleInputText + ti.SetStyles(styles) + ti.Focus() + ti.CharLimit = 0 + ti.SetValue("") + + if strings.TrimSpace(prompt) == "" { + prompt = "richline> " + ti.Prompt = prompt + } + + m := &richlineModel{ + ctx: ctx, + root: root, + input: ti, + prompt: prompt, + history: append([]string(nil), history...), + historyPos: len(history), + historyFile: historyFile, + persistHistory: persist, + blocks: []outputBlock{{ + Title: "system", + Lines: []string{"richline mode started, TAB 查看补全,↑/↓ 选择候选,Enter 执行,Ctrl+C 退出。"}, + }}, + } + m.recomputeSuggestions() + return m +} + +func (m *richlineModel) Init() tea.Cmd { return nil } + +func (m *richlineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case runLineResultMsg: + m.running = false + m.runningCommand = "" + if strings.TrimSpace(msg.block.Title) != "" || len(msg.block.Lines) > 0 { + m.appendBlock(msg.block) + m.outputOffset = 0 + } + if msg.quit { + return m, tea.Quit + } + m.recomputeSuggestions() + m.normalizeOutputOffset() + return m, nil + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + if m.width > len([]rune(m.prompt))+4 { + m.input.SetWidth(m.width - len([]rune(m.prompt)) - 4) + } + m.normalizeOutputOffset() + return m, nil + case tea.KeyMsg: + key := msg.String() + switch key { + case "ctrl+c": + return m, tea.Quit + case "ctrl+o": + m.toggleOutputFocusWithNotice() + return m, nil + case "esc": + if m.outputFocus { + m.outputFocus = false + return m, nil + } + m.suggestions = nil + m.selected = 0 + m.starterPinned = false + return m, nil + } + + if m.outputFocus { + switch key { + case "up": + m.scrollOutputLines(1) + return m, nil + case "down": + m.scrollOutputLines(-1) + return m, nil + case "pgup": + m.scrollOutputPage(1) + return m, nil + case "pgdown": + m.scrollOutputPage(-1) + return m, nil + case "home": + m.scrollOutputTop() + return m, nil + case "end": + m.scrollOutputBottom() + return m, nil + } + } + + switch key { + case "tab": + if strings.TrimSpace(m.input.Value()) == "" && len(m.suggestions) == 0 { + m.suggestions = collectStarterCompletionItems(m.root) + m.selected = 0 + m.starterPinned = true + m.normalizeOutputOffset() + return m, nil + } + m.starterPinned = false + m.applySuggestion() + m.recomputeSuggestions() + m.normalizeOutputOffset() + return m, nil + case "home": + if len(m.suggestions) > 0 { + m.selected = 0 + return m, nil + } + m.scrollOutputTop() + return m, nil + case "end": + if len(m.suggestions) > 0 { + m.selected = len(m.suggestions) - 1 + return m, nil + } + m.scrollOutputBottom() + return m, nil + case "pgup": + if len(m.suggestions) > 0 { + m.selected -= m.suggestionRows(len(m.suggestions)) + if m.selected < 0 { + m.selected = 0 + } + return m, nil + } + m.scrollOutputPage(1) + return m, nil + case "pgdown": + if len(m.suggestions) > 0 { + m.selected += m.suggestionRows(len(m.suggestions)) + if m.selected >= len(m.suggestions) { + m.selected = len(m.suggestions) - 1 + } + return m, nil + } + m.scrollOutputPage(-1) + return m, nil + case "up": + if len(m.suggestions) > 0 { + if m.selected > 0 { + m.selected-- + } + return m, nil + } + m.historyUp() + m.recomputeSuggestions() + return m, nil + case "down": + if len(m.suggestions) > 0 { + if m.selected < len(m.suggestions)-1 { + m.selected++ + } + return m, nil + } + m.historyDown() + m.recomputeSuggestions() + return m, nil + case "enter": + if m.running { + return m, nil + } + line := strings.TrimSpace(m.input.Value()) + if line == "" { + return m, nil + } + m.starterPinned = false + m.appendHistory(line) + m.input.SetValue("") + m.historyPos = len(m.history) + m.suggestions = nil + m.selected = 0 + if handled, cmd := m.handleSlashCommand(line); handled { + return m, cmd + } + m.running = true + m.runningCommand = line + m.outputFocus = false + return m, runLineCmd(m.ctx, m.root, line) + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.recomputeSuggestions() + m.normalizeOutputOffset() + return m, cmd +} + +func (m *richlineModel) View() tea.View { + var b strings.Builder + contentWidth := m.contentWidth() + renderedLines := m.renderOutputLines(contentWidth) + outputRows := m.outputRows() + offset := clampOutputOffset(m.outputOffset, len(renderedLines), outputRows) + start, end := visibleOutputRange(len(renderedLines), outputRows, offset) + + statusText := "IDLE" + statusStyle := styleStatusIdle + if m.running { + statusText = "RUNNING" + statusStyle = styleStatusBusy + } else if m.outputFocus { + statusText = "OUTPUT_SCROLL" + statusStyle = styleStatusBusy + } + status := statusStyle.Render(statusText) + + focus := "INPUT" + if m.outputFocus { + focus = "OUTPUT" + } + header := fmt.Sprintf("richline · status=%s · focus=%s · blocks=%d · lines=%d", status, focus, len(m.blocks), len(renderedLines)) + b.WriteString(styleHeader.Render(truncateDisplayWidth(header, contentWidth))) + b.WriteByte('\n') + b.WriteString(styleHeaderOutput.Render(truncateDisplayWidth(fmt.Sprintf("输出区域(%d-%d/%d)", displayStart(start, end), end, len(renderedLines)), contentWidth))) + b.WriteByte('\n') + b.WriteString(styleHintOutput.Render(truncateDisplayWidth(fmt.Sprintf("滚动状态:offset=%d rows=%d", offset, outputRows), contentWidth))) + b.WriteByte('\n') + + if len(renderedLines) == 0 { + b.WriteString(styleHint.Render("暂无输出")) + b.WriteByte('\n') + } else { + for i := start; i < end; i++ { + line := renderedLines[i] + b.WriteString(line) + if !strings.HasSuffix(line, "\n") { + b.WriteByte('\n') + } + } + } + + if len(m.suggestions) > 0 { + rows := m.suggestionRows(len(m.suggestions)) + start, end := visibleSuggestionRange(len(m.suggestions), m.selected, rows) + b.WriteString("\n") + header := fmt.Sprintf("补全候选(%d,显示 %d-%d):", len(m.suggestions), start+1, end) + b.WriteString(styleHeader.Render(truncateDisplayWidth(header, contentWidth))) + b.WriteByte('\n') + suggestionWidth := contentWidth + if suggestionWidth > 0 { + suggestionWidth -= 2 // 前缀 " " / "> " + } + for i := start; i < end; i++ { + item := m.suggestions[i] + prefix := " " + if i == m.selected { + prefix = "> " + } + b.WriteString(prefix) + b.WriteString(renderSuggestionLine(item, i == m.selected, suggestionWidth)) + b.WriteByte('\n') + } + b.WriteString(" ") + hint := "提示:↑/↓ 选择,PgUp/PgDn 翻页;Tab 空输入可列出命令;Esc 隐藏候选" + b.WriteString(styleHint.Render(truncateDisplayWidth(hint, suggestionWidth))) + b.WriteByte('\n') + } + + if m.running { + b.WriteString("\n") + if cmd := strings.TrimSpace(m.runningCommand); cmd != "" { + b.WriteString(styleRunning.Render(truncateDisplayWidth("执行中: "+cmd, contentWidth))) + b.WriteByte('\n') + } + b.WriteString(styleRunning.Render(truncateDisplayWidth("执行中...", contentWidth))) + b.WriteByte('\n') + b.WriteString(styleHint.Render(truncateDisplayWidth("运行中暂不接收普通命令,请等待完成;可用 Ctrl+C 退出。", contentWidth))) + b.WriteByte('\n') + } + + b.WriteString("\n") + b.WriteString(styleHeaderInput.Render(truncateDisplayWidth(fmt.Sprintf("输入区域(focus=%s)", focus), contentWidth))) + b.WriteByte('\n') + b.WriteString(m.input.View()) + b.WriteByte('\n') + if m.outputFocus { + b.WriteString(styleHintInput.Render(truncateDisplayWidth("当前焦点=输出区:↑/↓ 单行滚动,PgUp/PgDn 翻页,Home/End 顶/底;Ctrl+O 或 /input 返回输入区。", contentWidth))) + } else { + b.WriteString(styleHintInput.Render(truncateDisplayWidth("当前焦点=输入区:可输入命令执行;Ctrl+O 或 /output 切换到输出滚动;/help 查看 slash 命令。", contentWidth))) + } + v := tea.NewView(b.String()) + v.AltScreen = true + return v +} + +func (m *richlineModel) handleSlashCommand(line string) (bool, tea.Cmd) { + raw := strings.TrimSpace(line) + if !strings.HasPrefix(raw, "/") { + return false, nil + } + + cmdText := strings.TrimSpace(strings.TrimPrefix(raw, "/")) + if cmdText == "" { + cmdText = "help" + } + parts := strings.Fields(cmdText) + cmd := strings.ToLower(parts[0]) + + // 支持 / 直接执行业务命令(优先于内建 slash unknown 分支)。 + if isRichlineCommandLikeInput(m.root, cmdText) { + if m.running { + return true, nil + } + m.running = true + m.runningCommand = cmdText + m.outputFocus = false + return true, runLineCmd(m.ctx, m.root, cmdText) + } + + switch cmd { + case "help", "?": + m.appendBlock(outputBlock{Title: "/help", Lines: slashHelpLines()}) + case "o", "out", "output": + m.outputFocus = true + m.appendBlock(outputBlock{Title: "/output", Lines: []string{ + "已进入输出滚动模式。", + "使用 ↑/↓ 单行滚动,PgUp/PgDn 翻页,Home/End 顶/底。", + "输入 /input 返回普通输入模式。", + }}) + case "i", "input": + m.outputFocus = false + m.appendBlock(outputBlock{Title: "/input", Lines: []string{"已返回输入模式。"}}) + case "top": + m.scrollOutputTop() + case "bottom", "end": + m.scrollOutputBottom() + case "up": + m.scrollOutputLines(1) + case "down": + m.scrollOutputLines(-1) + case "pgup": + m.scrollOutputPage(1) + case "pgdown": + m.scrollOutputPage(-1) + case "quit", "exit", "q": + return true, tea.Quit + default: + m.appendBlock(outputBlock{Title: raw, Lines: []string{ + fmt.Sprintf("未知 slash 命令: %s", cmd), + "输入 /help 查看可用命令。", + }}) + } + + m.normalizeOutputOffset() + return true, nil +} + +func (m *richlineModel) toggleOutputFocusWithNotice() { + m.outputFocus = !m.outputFocus + if m.outputFocus { + m.appendBlock(outputBlock{Title: "focus", Lines: []string{ + "已切换到输出滚动模式。", + "使用 ↑/↓ 单行滚动,PgUp/PgDn 翻页,Home/End 顶/底;Ctrl+O 可返回输入模式。", + }}) + } else { + m.appendBlock(outputBlock{Title: "focus", Lines: []string{ + "已切换到输入模式。", + "可继续输入命令执行;Ctrl+O 可再次进入输出滚动模式。", + }}) + } + m.outputOffset = 0 + m.normalizeOutputOffset() +} + +func displayStart(start, end int) int { + if end == 0 { + return 0 + } + return start + 1 +} + +func (m *richlineModel) contentWidth() int { + if m.width <= 0 { + return 0 + } + w := m.width - 1 + if w < 1 { + return 1 + } + return w +} + +func (m *richlineModel) suggestionRows(total int) int { + if total <= 0 { + return 1 + } + + rows := defaultSuggestionRows + if m.height <= 0 { + if total < rows { + return total + } + return rows + } + + available := m.height - m.baseOccupiedRows(true) - minOutputRows + if available < 1 { + available = 1 + } + if available > rows { + available = rows + } + if available > total { + available = total + } + return available +} + +func (m *richlineModel) outputRows() int { + if m.height <= 0 { + return defaultOutputRows + } + + occupied := m.baseOccupiedRows(false) + if len(m.suggestions) > 0 { + occupied += 3 + m.suggestionRows(len(m.suggestions)) // 候选块:空行 + 标题 + 提示 + 数据行 + } + + rows := m.height - occupied + if rows < 1 { + rows = 1 + } + return rows +} + +func (m *richlineModel) baseOccupiedRows(withSuggestionFrame bool) int { + rows := 7 // 状态头 + 输出标题 + 输出滚动状态 + 输入前空行 + 输入标题 + 输入框 + 输入提示 + if m.running { + rows += 3 // 运行态:空行 + 执行中 + 提示 + if strings.TrimSpace(m.runningCommand) != "" { + rows += 1 // 运行命令行 + } + } + if withSuggestionFrame { + rows += 3 // 候选块:空行 + 标题 + 提示 + } + return rows +} + +func (m *richlineModel) recomputeSuggestions() { + line := m.input.Value() + if strings.TrimSpace(line) == "" { + if m.starterPinned && len(m.suggestions) > 0 { + if m.selected >= len(m.suggestions) { + m.selected = len(m.suggestions) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + return + } + m.suggestions = nil + m.selected = 0 + m.starterPinned = false + return + } + m.starterPinned = false + + if strings.HasPrefix(strings.TrimLeftFunc(line, unicode.IsSpace), "/") { + m.suggestions = collectSlashCompletionItems(m.root, line) + if len(m.suggestions) == 0 { + m.selected = 0 + return + } + if m.selected >= len(m.suggestions) { + m.selected = len(m.suggestions) - 1 + } + if m.selected < 0 { + m.selected = 0 + } + return + } + + items := collectCompletionItems(m.root, line) + m.suggestions = items + if len(m.suggestions) == 0 { + m.selected = 0 + return + } + if m.selected >= len(m.suggestions) { + m.selected = len(m.suggestions) - 1 + } + if m.selected < 0 { + m.selected = 0 + } +} + +func collectSlashCompletionItems(root *redant.Command, input string) []completionItem { + trimmedRight := strings.TrimRightFunc(input, unicode.IsSpace) + if trimmedRight == "" { + return nil + } + + fields := strings.Fields(trimmedRight) + if len(fields) == 0 { + return nil + } + first := fields[0] + if !strings.HasPrefix(first, "/") { + return nil + } + firstName := strings.TrimSpace(strings.TrimPrefix(first, "/")) + + // /:在 slash 模式下也提示业务命令的 flags/args/enum。 + if len(fields) > 1 || len(trimmedRight) < len(input) { + if isBuiltinSlashName(firstName) { + return nil + } + probeTokens := append([]string{firstName}, fields[1:]...) + probeLine := strings.TrimSpace(strings.Join(probeTokens, " ")) + if probeLine == "" { + return nil + } + if len(trimmedRight) < len(input) { + probeLine += " " + } + items := collectCompletionItems(root, probeLine) + return uniqueCompletionItems(items) + } + + prefix := firstName + out := make([]completionItem, 0, len(slashCommands)) + + for _, cmd := range slashCommands { + name := strings.TrimSpace(cmd.Name) + if name == "" { + continue + } + if !slashCommandMatchesPrefix(cmd, prefix) { + continue + } + desc := "slash · " + strings.TrimSpace(cmd.Description) + if len(cmd.Aliases) > 0 { + aliases := make([]string, 0, len(cmd.Aliases)) + for _, alias := range cmd.Aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + aliases = append(aliases, "/"+alias) + } + if len(aliases) > 0 { + desc = desc + "(别名: " + strings.Join(aliases, " ") + ")" + } + } + out = append(out, completionItem{Insert: "/" + name, Description: desc, Kind: completionKindCommand}) + } + + // 将业务命令一并作为 slash 候选展示(仅展示主命令,不展示别名)。 + cmdItems := collectTopLevelSlashCommandItems(root, prefix) + out = append(out, cmdItems...) + + return uniqueCompletionItems(out) +} + +func isBuiltinSlashName(name string) bool { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return false + } + for _, sc := range slashCommands { + if strings.ToLower(strings.TrimSpace(sc.Name)) == name { + return true + } + for _, alias := range sc.Aliases { + if strings.ToLower(strings.TrimSpace(alias)) == name { + return true + } + } + } + return false +} + +func collectTopLevelSlashCommandItems(root *redant.Command, prefix string) []completionItem { + if root == nil { + return nil + } + prefix = strings.TrimSpace(prefix) + out := make([]completionItem, 0, len(root.Children)) + for _, child := range root.Children { + if child == nil || child.Hidden { + continue + } + name := strings.TrimSpace(child.Name()) + if name == "" { + continue + } + matched := prefix == "" || strings.HasPrefix(name, prefix) + if !matched { + for _, alias := range child.Aliases { + if strings.HasPrefix(strings.TrimSpace(alias), prefix) { + matched = true + break + } + } + } + if !matched { + continue + } + + desc := strings.TrimSpace(commandDescription(child)) + if desc == "" { + desc = "命令" + } + if len(child.Aliases) > 0 { + aliases := make([]string, 0, len(child.Aliases)) + for _, alias := range child.Aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + aliases = append(aliases, "/"+alias) + } + if len(aliases) > 0 { + desc += "(别名: " + strings.Join(aliases, " ") + ")" + } + } + out = append(out, completionItem{Insert: "/" + name, Description: "command · " + desc, Kind: completionKindCommand}) + } + + return out +} + +func isRichlineCommandLikeInput(root *redant.Command, line string) bool { + if root == nil { + return false + } + args, err := splitCommandLine(strings.TrimSpace(line)) + if err != nil || len(args) == 0 { + return false + } + + if args[0] == root.Name() { + args = args[1:] + if len(args) == 0 { + return false + } + } + + cur := root + consumed := 0 + for _, tok := range args { + if strings.HasPrefix(tok, "-") || strings.Contains(tok, "=") { + break + } + next, ok := resolveCommandToken(cur, tok) + if !ok { + break + } + cur = next + consumed++ + } + + return consumed > 0 +} + +func slashCommandMatchesPrefix(cmd slashCommand, prefix string) bool { + prefix = strings.TrimSpace(prefix) + if prefix == "" { + return true + } + if strings.HasPrefix(strings.TrimSpace(cmd.Name), prefix) { + return true + } + for _, alias := range cmd.Aliases { + if strings.HasPrefix(strings.TrimSpace(alias), prefix) { + return true + } + } + return false +} + +func collectStarterCompletionItems(root *redant.Command) []completionItem { + if root == nil { + return nil + } + items := uniqueCompletionItems(suggestChildrenItems(root, "")) + if len(items) > 0 { + return items + } + return collectCompletionItems(root, "") +} + +func (m *richlineModel) applySuggestion() { + if len(m.suggestions) == 0 { + m.recomputeSuggestions() + if len(m.suggestions) == 0 { + return + } + } + idx := m.selected + if idx < 0 || idx >= len(m.suggestions) { + idx = 0 + } + newLine := applySelectedCompletion(m.input.Value(), m.suggestions[idx].Insert) + m.input.SetValue(newLine) + m.input.CursorEnd() +} + +func (m *richlineModel) scrollOutputLines(delta int) { + total := len(m.renderOutputLines(m.contentWidth())) + rows := m.outputRows() + m.outputOffset = clampOutputOffset(m.outputOffset+delta, total, rows) +} + +func (m *richlineModel) scrollOutputPage(deltaPage int) { + if deltaPage == 0 { + return + } + rows := m.outputRows() + m.scrollOutputLines(deltaPage * rows) +} + +func (m *richlineModel) scrollOutputTop() { + total := len(m.renderOutputLines(m.contentWidth())) + rows := m.outputRows() + maxOffset := total - rows + if maxOffset < 0 { + maxOffset = 0 + } + m.outputOffset = maxOffset +} + +func (m *richlineModel) scrollOutputBottom() { + m.outputOffset = 0 +} + +func (m *richlineModel) normalizeOutputOffset() { + total := len(m.renderOutputLines(m.contentWidth())) + m.outputOffset = clampOutputOffset(m.outputOffset, total, m.outputRows()) +} + +func (m *richlineModel) renderOutputLines(width int) []string { + if len(m.blocks) == 0 { + return nil + } + out := make([]string, 0, len(m.blocks)*3) + for i, block := range m.blocks { + title := strings.TrimSpace(block.Title) + if title == "" { + title = "output" + } + head := fmt.Sprintf("■ #%d %s", i+1, title) + out = append(out, styleBlockHeader.Render(truncateDisplayWidth(head, width))) + + if len(block.Lines) == 0 { + out = append(out, " (no output)") + } else { + for _, line := range block.Lines { + wrapped := wrapDisplayWidth(line, width-2) + if len(wrapped) == 0 { + continue + } + for _, w := range wrapped { + out = append(out, " "+w) + } + } + } + + if i < len(m.blocks)-1 { + sep := "" + if width > 0 { + sep = strings.Repeat("─", width) + } else { + sep = "────────────────" + } + out = append(out, sep) + } + } + return out +} + +func (m *richlineModel) appendBlock(block outputBlock) { + title := strings.TrimSpace(block.Title) + if title == "" { + title = "output" + } + + normalized := make([]string, 0, len(block.Lines)) + for _, line := range block.Lines { + normalized = append(normalized, normalizeOutputLines(line)...) + } + + m.blocks = append(m.blocks, outputBlock{Title: title, Lines: normalized}) + m.trimOutputHistory() +} + +func (m *richlineModel) trimOutputHistory() { + if len(m.blocks) == 0 { + return + } + total := 0 + for _, b := range m.blocks { + total += len(b.Lines) + } + + for len(m.blocks) > 1 && (len(m.blocks) > maxOutputBlocks || total > maxLogLines) { + total -= len(m.blocks[0].Lines) + m.blocks = m.blocks[1:] + } +} + +func (m *richlineModel) appendHistory(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + if len(m.history) > 0 && m.history[len(m.history)-1] == line { + return + } + m.history = append(m.history, line) + if m.persistHistory && m.historyFile != "" { + _ = appendHistoryLine(m.historyFile, line) + } +} + +func (m *richlineModel) historyUp() { + if len(m.history) == 0 { + return + } + if m.historyPos <= 0 { + m.historyPos = 0 + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() + return + } + m.historyPos-- + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() +} + +func (m *richlineModel) historyDown() { + if len(m.history) == 0 { + return + } + if m.historyPos >= len(m.history)-1 { + m.historyPos = len(m.history) + m.input.SetValue("") + m.input.CursorEnd() + return + } + m.historyPos++ + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() +} + +func runLineCmd(ctx context.Context, root *redant.Command, line string) tea.Cmd { + return func() tea.Msg { + switch strings.TrimSpace(line) { + case "exit", "quit", ":q", `\q`: + return runLineResultMsg{quit: true} + case "help", ":help", "?": + return runLineResultMsg{block: outputBlock{Title: "$ help", Lines: richlineHelpLines(root)}} + } + + args, parseErr := splitCommandLine(line) + if parseErr != nil { + return runLineResultMsg{block: outputBlock{Title: "$ " + line, Lines: []string{fmt.Sprintf("parse input failed: %v", parseErr)}}} + } + if len(args) == 0 { + return runLineResultMsg{} + } + if args[0] == root.Name() { + args = args[1:] + } + if len(args) == 0 { + return runLineResultMsg{} + } + + title := "$ " + formatCommandLine(root.Name(), args) + lines := make([]string, 0, 8) + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + runInv := root.Invoke(args...) + runInv.Stdout = stdout + runInv.Stderr = stderr + runInv.Stdin = strings.NewReader("") + + runErr := runInv.WithContext(ctx).Run() + + if out := strings.TrimSpace(stdout.String()); out != "" { + lines = append(lines, strings.Split(out, "\n")...) + } + if out := strings.TrimSpace(stderr.String()); out != "" { + lines = append(lines, strings.Split(out, "\n")...) + } + if runErr != nil { + lines = append(lines, fmt.Sprintf("error: %v", runErr)) + } + if len(lines) == 0 { + lines = append(lines, "(no output)") + } + + return runLineResultMsg{block: outputBlock{Title: title, Lines: lines}} + } +} + +func richlineHelpLines(root *redant.Command) []string { + return []string{ + "available shortcuts:", + " - TAB: apply selected completion (空输入首次 TAB 显示起始候选)", + " - ↑/↓: switch completion candidate (or history when no candidate)", + " - PgUp/PgDn/Home/End: scroll output history (when no suggestion list)", + " - Ctrl+O: toggle output scroll mode", + " - /output: enter output scroll mode (Ctrl+O 替代)", + " - /input: back to input mode", + " - /help: show slash commands", + " - output mode: ↑/↓ line, PgUp/PgDn page, Home/End top/bottom", + " - enter: execute command", + " - exit / quit: exit richline", + "examples:", + fmt.Sprintf(" %s commit -m \"message\"", root.Name()), + " commit --help", + } +} + +func slashHelpLines() []string { + return []string{ + "slash commands:", + " /output (/o): 进入输出滚动模式", + " /input (/i): 返回输入模式", + " /top /bottom: 跳到输出历史顶/底", + " /up /down: 输出按行滚动", + " /pgup /pgdown: 输出按页滚动", + " /help: 显示本帮助", + " /quit: 退出 richline", + } +} + +func tailLines(lines []string, n int) []string { + if len(lines) <= n { + return lines + } + return lines[len(lines)-n:] +} + +func loadHistory(path string) []string { + f, err := os.Open(path) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + + out := make([]string, 0) + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" { + continue + } + out = append(out, line) + } + return out +} + +func appendHistoryLine(path, line string) error { + if strings.TrimSpace(path) == "" || strings.TrimSpace(line) == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + _, err = fmt.Fprintln(f, line) + return err +} + +func collectCompletionItems(root *redant.Command, input string) []completionItem { + if root == nil { + return nil + } + tokens, current := splitCompletionInput(input) + cmd, consumed := resolveCommandContext(root, tokens) + if cmd == nil { + cmd = root + } + + idx := buildOptionIndex(cmd) + if vals, ok := suggestFlagValueItems(idx, tokens, current); ok { + return uniqueCompletionItems(vals) + } + + var out []completionItem + if strings.HasPrefix(current, "-") { + out = append(out, suggestFlagItems(idx, current)...) + return uniqueCompletionItems(out) + } + + if consumed == len(tokens) { + out = append(out, suggestChildrenItems(cmd, current)...) + } + out = append(out, suggestArgItems(cmd, idx, tokens[consumed:], current)...) + out = append(out, suggestFlagItems(idx, current)...) + + return uniqueCompletionItems(out) +} + +func suggestionKindTag(kind completionKind) string { + switch kind { + case completionKindCommand: + return "[CMD ]" + case completionKindFlag: + return "[FLAG]" + case completionKindArg: + return "[ARG ]" + case completionKindEnum: + return "[ENUM]" + default: + return "[ITEM]" + } +} + +func renderKindTag(kind completionKind) string { + tag := suggestionKindTag(kind) + switch kind { + case completionKindCommand: + return styleKindCommand.Render(tag) + case completionKindFlag: + return styleKindFlag.Render(tag) + case completionKindArg: + return styleKindArg.Render(tag) + case completionKindEnum: + return styleKindEnum.Render(tag) + default: + return styleKindDefault.Render(tag) + } +} + +func renderSuggestionLine(item completionItem, selected bool, maxWidth int) string { + kind := suggestionKindTag(item.Kind) + kindWidth := lipgloss.Width(kind) + + insertWidth := 24 + if maxWidth > 0 { + maxInsert := maxWidth - kindWidth - 1 + if maxInsert < 1 { + maxInsert = 1 + } + if insertWidth > maxInsert { + insertWidth = maxInsert + } + } + + insert := padRightDisplay(item.Insert, insertWidth) + line := fmt.Sprintf("%s %s", renderKindTag(item.Kind), insert) + + baseWidth := kindWidth + 1 + lipgloss.Width(insert) + if item.Description != "" { + desc := item.Description + if maxWidth > 0 { + descWidth := maxWidth - baseWidth - 1 + if descWidth < 1 { + desc = "" + } else { + desc = truncateDisplayWidth(desc, descWidth) + } + } + if desc != "" { + line += " " + styleDescription.Render(desc) + } + } + + if selected { + return styleSelectedRow.Render(line) + } + return line +} + +func padRightDisplay(s string, width int) string { + if width <= 0 { + return "" + } + s = truncateDisplayWidth(s, width) + w := lipgloss.Width(s) + if w >= width { + return s + } + return s + strings.Repeat(" ", width-w) +} + +func truncateDisplayWidth(s string, maxWidth int) string { + if maxWidth <= 0 { + return s + } + if lipgloss.Width(s) <= maxWidth { + return s + } + + ellipsis := "…" + ellipsisWidth := lipgloss.Width(ellipsis) + if maxWidth <= ellipsisWidth { + return ellipsis + } + + target := maxWidth - ellipsisWidth + var b strings.Builder + w := 0 + for _, r := range s { + rw := lipgloss.Width(string(r)) + if w+rw > target { + break + } + b.WriteRune(r) + w += rw + } + return b.String() + ellipsis +} + +func wrapDisplayWidth(s string, maxWidth int) []string { + s = strings.ReplaceAll(s, "\t", " ") + if maxWidth <= 0 || lipgloss.Width(s) <= maxWidth { + return []string{s} + } + + var lines []string + var cur strings.Builder + curWidth := 0 + + flush := func() { + lines = append(lines, cur.String()) + cur.Reset() + curWidth = 0 + } + + for _, r := range s { + rw := lipgloss.Width(string(r)) + if rw <= 0 { + rw = 1 + } + if curWidth > 0 && curWidth+rw > maxWidth { + flush() + } + cur.WriteRune(r) + curWidth += rw + } + if cur.Len() > 0 { + flush() + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func normalizeOutputLines(s string) []string { + if s == "" { + return nil + } + s = strings.ReplaceAll(s, "\r\n", "\n") + parts := strings.Split(s, "\n") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if strings.Contains(part, "\r") { + seg := strings.Split(part, "\r") + part = seg[len(seg)-1] + } + part = strings.TrimRight(part, "\r") + out = append(out, part) + } + return out +} + +func visibleSuggestionRange(total, selected, maxRows int) (start, end int) { + if total <= 0 { + return 0, 0 + } + if maxRows <= 0 { + maxRows = 1 + } + if total <= maxRows { + return 0, total + } + if selected < 0 { + selected = 0 + } + if selected >= total { + selected = total - 1 + } + + start = selected - maxRows/2 + if start < 0 { + start = 0 + } + if start+maxRows > total { + start = total - maxRows + } + end = start + maxRows + return start, end +} + +func visibleOutputRange(total, rows, offset int) (start, end int) { + if total <= 0 { + return 0, 0 + } + if rows <= 0 { + rows = 1 + } + if total <= rows { + return 0, total + } + + offset = clampOutputOffset(offset, total, rows) + end = total - offset + start = end - rows + return start, end +} + +func clampOutputOffset(offset, total, rows int) int { + if offset < 0 { + offset = 0 + } + if total <= 0 { + return 0 + } + if rows <= 0 { + rows = 1 + } + maxOffset := total - rows + if maxOffset < 0 { + maxOffset = 0 + } + if offset > maxOffset { + return maxOffset + } + return offset +} + +func applySelectedCompletion(input, selected string) string { + trimmedRight := strings.TrimRightFunc(input, unicode.IsSpace) + if trimmedRight == "" { + return selected + " " + } + if len(trimmedRight) < len(input) { + return trimmedRight + " " + selected + " " + } + idx := strings.LastIndexFunc(trimmedRight, unicode.IsSpace) + if idx < 0 { + return selected + " " + } + return trimmedRight[:idx+1] + selected + " " +} + +func splitCompletionInput(input string) ([]string, string) { + trimmedRight := strings.TrimRightFunc(input, unicode.IsSpace) + if trimmedRight == "" { + return nil, "" + } + if len(trimmedRight) < len(input) { + return strings.Fields(trimmedRight), "" + } + parts := strings.Fields(trimmedRight) + if len(parts) == 0 { + return nil, "" + } + return parts[:len(parts)-1], parts[len(parts)-1] +} + +func resolveCommandContext(root *redant.Command, tokens []string) (*redant.Command, int) { + if root == nil { + return nil, 0 + } + cur := root + consumed := 0 + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + if i == 0 && tok == root.Name() { + consumed++ + continue + } + if strings.HasPrefix(tok, "-") { + break + } + next, ok := resolveCommandToken(cur, tok) + if !ok { + break + } + cur = next + consumed++ + } + return cur, consumed +} + +func resolveCommandToken(parent *redant.Command, token string) (*redant.Command, bool) { + if parent == nil || token == "" { + return nil, false + } + if strings.Contains(token, ":") { + parts := strings.Split(token, ":") + cur := parent + for _, p := range parts { + child := childByNameOrAlias(cur, p) + if child == nil { + return nil, false + } + cur = child + } + return cur, true + } + child := childByNameOrAlias(parent, token) + if child == nil { + return nil, false + } + return child, true +} + +func childByNameOrAlias(parent *redant.Command, token string) *redant.Command { + if parent == nil { + return nil + } + for _, child := range parent.Children { + if child.Hidden { + continue + } + if child.Name() == token { + return child + } + for _, alias := range child.Aliases { + if strings.TrimSpace(alias) == token { + return child + } + } + } + return nil +} + +type optionIndex struct { + byLong map[string]redant.Option + byShort map[string]redant.Option +} + +func buildOptionIndex(cmd *redant.Command) optionIndex { + idx := optionIndex{byLong: map[string]redant.Option{}, byShort: map[string]redant.Option{}} + if cmd == nil { + return idx + } + for _, opt := range cmd.FullOptions() { + if opt.Hidden || opt.Flag == "" { + continue + } + idx.byLong[opt.Flag] = opt + if opt.Shorthand != "" { + idx.byShort[opt.Shorthand] = opt + } + } + return idx +} + +func suggestChildrenItems(cmd *redant.Command, current string) []completionItem { + if cmd == nil { + return nil + } + out := make([]completionItem, 0) + for _, child := range cmd.Children { + if child.Hidden { + continue + } + desc := commandDescription(child) + if current == "" || strings.HasPrefix(child.Name(), current) { + out = append(out, completionItem{Insert: child.Name(), Description: desc, Kind: completionKindCommand}) + } + for _, alias := range child.Aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + if current == "" || strings.HasPrefix(alias, current) { + ad := desc + if ad == "" { + ad = fmt.Sprintf("%s 的别名", child.Name()) + } else { + ad = fmt.Sprintf("%s(%s 的别名)", ad, child.Name()) + } + out = append(out, completionItem{Insert: alias, Description: ad, Kind: completionKindCommand}) + } + } + } + return out +} + +func suggestFlagItems(idx optionIndex, current string) []completionItem { + out := make([]completionItem, 0) + for name, opt := range idx.byLong { + cand := "--" + name + if current == "" || strings.HasPrefix(cand, current) { + out = append(out, completionItem{Insert: cand, Description: flagDescription(opt), Kind: completionKindFlag}) + } + } + for short, opt := range idx.byShort { + cand := "-" + short + if current == "" || strings.HasPrefix(cand, current) { + out = append(out, completionItem{Insert: cand, Description: flagDescription(opt), Kind: completionKindFlag}) + } + } + return out +} + +func suggestFlagValueItems(idx optionIndex, tokens []string, current string) ([]completionItem, bool) { + if strings.HasPrefix(current, "--") && strings.Contains(current, "=") { + nameWithPrefix, valuePrefix, _ := strings.Cut(current, "=") + name := strings.TrimPrefix(nameWithPrefix, "--") + opt, ok := idx.byLong[name] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + out := make([]completionItem, 0, len(vals)) + for _, v := range vals { + if strings.HasPrefix(v, valuePrefix) { + out = append(out, completionItem{Insert: nameWithPrefix + "=" + v, Description: "枚举值", Kind: completionKindEnum}) + } + } + return out, true + } + + if len(tokens) == 0 { + return nil, false + } + prev := tokens[len(tokens)-1] + if strings.HasPrefix(prev, "--") { + name := strings.TrimPrefix(prev, "--") + opt, ok := idx.byLong[name] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + out := make([]completionItem, 0, len(vals)) + for _, v := range vals { + if current == "" || strings.HasPrefix(v, current) { + out = append(out, completionItem{Insert: v, Description: "枚举值", Kind: completionKindEnum}) + } + } + return out, true + } + if strings.HasPrefix(prev, "-") && len(prev) == 2 { + short := strings.TrimPrefix(prev, "-") + opt, ok := idx.byShort[short] + if !ok || !optionNeedsValue(opt) { + return nil, false + } + vals := enumValuesFromOption(opt) + if len(vals) == 0 { + return nil, false + } + out := make([]completionItem, 0, len(vals)) + for _, v := range vals { + if current == "" || strings.HasPrefix(v, current) { + out = append(out, completionItem{Insert: v, Description: "枚举值", Kind: completionKindEnum}) + } + } + return out, true + } + return nil, false +} + +func suggestArgItems(cmd *redant.Command, idx optionIndex, restTokens []string, current string) []completionItem { + if cmd == nil || len(cmd.Args) == 0 { + return nil + } + argPos := countProvidedPositionals(restTokens, idx) + if argPos >= len(cmd.Args) { + return nil + } + target := cmd.Args[argPos] + desc := strings.TrimSpace(target.Description) + if desc == "" { + desc = "参数" + } + + out := make([]completionItem, 0) + for _, v := range enumValuesFromArg(target) { + if current == "" || strings.HasPrefix(v, current) { + out = append(out, completionItem{Insert: v, Description: "枚举值 · " + desc, Kind: completionKindEnum}) + } + } + if target.Name != "" && (current == "" || strings.HasPrefix(target.Name, current)) { + out = append(out, completionItem{Insert: "<" + target.Name + ">", Description: desc, Kind: completionKindArg}) + } + return out +} + +func countProvidedPositionals(tokens []string, idx optionIndex) int { + count := 0 + for i := 0; i < len(tokens); i++ { + tok := tokens[i] + if strings.HasPrefix(tok, "--") { + name, _, hasEq := strings.Cut(strings.TrimPrefix(tok, "--"), "=") + opt, ok := idx.byLong[name] + if ok && optionNeedsValue(opt) && !hasEq && i+1 < len(tokens) { + i++ + } + continue + } + if strings.HasPrefix(tok, "-") && len(tok) == 2 { + short := strings.TrimPrefix(tok, "-") + opt, ok := idx.byShort[short] + if ok && optionNeedsValue(opt) && i+1 < len(tokens) { + i++ + } + continue + } + count++ + } + return count +} + +func optionNeedsValue(opt redant.Option) bool { + return strings.TrimSpace(opt.Type()) != "bool" +} + +func flagDescription(opt redant.Option) string { + desc := strings.TrimSpace(opt.Description) + if desc == "" { + desc = "flag" + } + typ := strings.TrimSpace(opt.Type()) + if typ != "" { + desc = fmt.Sprintf("%s [%s]", desc, typ) + } + return desc +} + +func commandDescription(cmd *redant.Command) string { + short := strings.TrimSpace(cmd.Short) + if short != "" { + return short + } + return strings.TrimSpace(cmd.Long) +} + +func uniqueCompletionItems(items []completionItem) []completionItem { + if len(items) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]completionItem, 0, len(items)) + for _, item := range items { + ins := strings.TrimSpace(item.Insert) + if ins == "" { + continue + } + if _, ok := seen[ins]; ok { + continue + } + seen[ins] = struct{}{} + item.Insert = ins + out = append(out, item) + } + sort.Slice(out, func(i, j int) bool { + pi := completionKindPriority(out[i].Kind) + pj := completionKindPriority(out[j].Kind) + if pi != pj { + return pi < pj + } + if out[i].Insert != out[j].Insert { + return out[i].Insert < out[j].Insert + } + return out[i].Description < out[j].Description + }) + return out +} + +func completionKindPriority(kind completionKind) int { + switch kind { + case completionKindCommand: + return 1 + case completionKindFlag: + return 2 + case completionKindArg: + return 3 + case completionKindEnum: + return 4 + default: + return 99 + } +} + +func enumValuesFromArg(arg redant.Arg) []string { + if arg.Value == nil { + return nil + } + return parseEnumValues(arg.Value.Type()) +} + +func enumValuesFromOption(opt redant.Option) []string { + if opt.Value == nil { + return nil + } + vals := enumValuesFromValue(opt.Value) + if len(vals) == 0 { + vals = parseEnumValues(opt.Value.Type()) + } + return uniqueSorted(vals) +} + +func enumValuesFromValue(value pflag.Value) []string { + if value == nil { + return nil + } + switch v := value.(type) { + case *redant.Enum: + return append([]string(nil), v.Choices...) + case *redant.EnumArray: + return append([]string(nil), v.Choices...) + case interface{ Underlying() pflag.Value }: + return enumValuesFromValue(v.Underlying()) + default: + return nil + } +} + +func parseEnumValues(typ string) []string { + if (!strings.HasPrefix(typ, "enum[") && !strings.HasPrefix(typ, "enum-array[")) || !strings.HasSuffix(typ, "]") { + return nil + } + start := strings.IndexByte(typ, '[') + if start < 0 || start+1 >= len(typ)-1 { + return nil + } + inner := typ[start+1 : len(typ)-1] + var parts []string + if strings.Contains(inner, `\|`) { + parts = strings.Split(inner, `\|`) + } else { + parts = strings.Split(inner, "|") + } + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(strings.ReplaceAll(p, `\|`, "|")) + if v == "" { + continue + } + out = append(out, v) + } + return uniqueSorted(out) +} + +func uniqueSorted(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + sort.Strings(out) + return out +} + +func splitCommandLine(input string) ([]string, error) { + var ( + out []string + cur strings.Builder + quote rune + escaped bool + ) + flush := func() { + if cur.Len() == 0 { + return + } + out = append(out, cur.String()) + cur.Reset() + } + for _, r := range input { + switch { + case escaped: + cur.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } else { + cur.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case unicode.IsSpace(r): + flush() + default: + cur.WriteRune(r) + } + } + if escaped { + return nil, errors.New("unfinished escape sequence") + } + if quote != 0 { + return nil, errors.New("unclosed quote") + } + flush() + return out, nil +} + +func formatCommandLine(program string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, quoteShellArg(program)) + for _, arg := range args { + parts = append(parts, quoteShellArg(arg)) + } + return strings.Join(parts, " ") +} + +func quoteShellArg(s string) string { + if s == "" { + return `""` + } + if !needsQuote(s) { + return s + } + return strconv.Quote(s) +} + +func needsQuote(s string) bool { + for _, r := range s { + if unicode.IsSpace(r) { + return true + } + switch r { + case '"', '\'', '\\', '$', '`', '|', '&', ';', '(', ')', '<', '>', '*', '?', '[', ']', '{', '}', '!': + return true + } + } + return false +} diff --git a/cmds/richlinecmd/richline_test.go b/cmds/richlinecmd/richline_test.go new file mode 100644 index 0000000..0410c4c --- /dev/null +++ b/cmds/richlinecmd/richline_test.go @@ -0,0 +1,612 @@ +package richlinecmd + +import ( + "context" + "regexp" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/pubgo/redant" +) + +var ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(s string) string { + return ansiRegexp.ReplaceAllString(s, "") +} + +func TestCollectCompletionItems_WithDescription(t *testing.T) { + root := buildTestRoot() + + items := collectCompletionItems(root, "com") + item, ok := findCompletion(items, "commit") + if !ok { + t.Fatalf("expected commit in completion items: %+v", items) + } + if !strings.Contains(item.Description, "提交") { + t.Fatalf("expected commit description, got: %q", item.Description) + } + if item.Kind != completionKindCommand { + t.Fatalf("expected command kind, got: %q", item.Kind) + } + + items = collectCompletionItems(root, "commit --fo") + item, ok = findCompletion(items, "--format") + if !ok { + t.Fatalf("expected --format in completion items: %+v", items) + } + if !strings.Contains(item.Description, "输出格式") { + t.Fatalf("expected flag description, got: %q", item.Description) + } + if item.Kind != completionKindFlag { + t.Fatalf("expected flag kind, got: %q", item.Kind) + } + + items = collectCompletionItems(root, "commit --format ") + item, ok = findCompletion(items, "json") + if !ok { + t.Fatalf("expected enum value json in completion items: %+v", items) + } + if item.Kind != completionKindEnum { + t.Fatalf("expected enum kind, got: %q", item.Kind) + } +} + +func TestApplySelectedCompletion(t *testing.T) { + tests := []struct { + name string + line string + selected string + want string + }{ + {name: "replace token", line: "com", selected: "commit", want: "commit "}, + {name: "append after space", line: "commit ", selected: "--format", want: "commit --format "}, + {name: "replace last token", line: "commit --fo", selected: "--format", want: "commit --format "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applySelectedCompletion(tt.line, tt.selected) + if got != tt.want { + t.Fatalf("completion mismatch, want=%q got=%q", tt.want, got) + } + }) + } +} + +func TestFormatCommandLine(t *testing.T) { + got := formatCommandLine("fastcommit", []string{"commit", "-m", "hello world", "--expr", "a|b"}) + want := `fastcommit commit -m "hello world" --expr "a|b"` + if got != want { + t.Fatalf("format mismatch\nwant=%s\ngot=%s", want, got) + } +} + +func TestVisibleSuggestionRange(t *testing.T) { + tests := []struct { + name string + total int + selected int + maxRows int + wantS int + wantE int + }{ + {name: "empty", total: 0, selected: 0, maxRows: 10, wantS: 0, wantE: 0}, + {name: "small list", total: 6, selected: 3, maxRows: 10, wantS: 0, wantE: 6}, + {name: "center", total: 100, selected: 50, maxRows: 10, wantS: 45, wantE: 55}, + {name: "near top", total: 100, selected: 1, maxRows: 10, wantS: 0, wantE: 10}, + {name: "near bottom", total: 100, selected: 98, maxRows: 10, wantS: 90, wantE: 100}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, e := visibleSuggestionRange(tt.total, tt.selected, tt.maxRows) + if s != tt.wantS || e != tt.wantE { + t.Fatalf("range mismatch, want=(%d,%d) got=(%d,%d)", tt.wantS, tt.wantE, s, e) + } + }) + } +} + +func TestSuggestionRows(t *testing.T) { + tests := []struct { + name string + total int + h int + want int + }{ + {name: "default when window unknown", total: 30, h: 0, want: 10}, + {name: "bounded by default rows", total: 18, h: 40, want: 10}, + {name: "reserve output area", total: 30, h: 14, want: 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := &richlineModel{height: tt.h} + got := m.suggestionRows(tt.total) + if got != tt.want { + t.Fatalf("suggestion rows mismatch, want=%d got=%d", tt.want, got) + } + }) + } +} + +func TestRenderOutputLines_WithBlocks(t *testing.T) { + m := &richlineModel{ + blocks: []outputBlock{ + {Title: "$ app commit -m hi", Lines: []string{"ok line 1", "ok line 2"}}, + {Title: "$ app release", Lines: []string{"release done"}}, + }, + } + + lines := m.renderOutputLines(80) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "#1") || !strings.Contains(joined, "#2") { + t.Fatalf("expected block numbering in output, got: %s", joined) + } + if !strings.Contains(joined, "app commit") || !strings.Contains(joined, "release done") { + t.Fatalf("expected block content in output, got: %s", joined) + } +} + +func TestAppendBlock_TrimOutputHistory(t *testing.T) { + m := &richlineModel{} + for i := 0; i < maxOutputBlocks+20; i++ { + m.appendBlock(outputBlock{Title: "cmd", Lines: []string{"line"}}) + } + if len(m.blocks) > maxOutputBlocks { + t.Fatalf("expected blocks trimmed to <= %d, got %d", maxOutputBlocks, len(m.blocks)) + } +} + +func TestVisibleOutputRange(t *testing.T) { + tests := []struct { + name string + total int + rows int + offset int + wantS int + wantE int + }{ + {name: "empty", total: 0, rows: 10, offset: 0, wantS: 0, wantE: 0}, + {name: "fit all", total: 8, rows: 10, offset: 0, wantS: 0, wantE: 8}, + {name: "from bottom", total: 20, rows: 6, offset: 0, wantS: 14, wantE: 20}, + {name: "scroll up", total: 20, rows: 6, offset: 5, wantS: 9, wantE: 15}, + {name: "clamp overflow offset", total: 20, rows: 6, offset: 999, wantS: 0, wantE: 6}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, e := visibleOutputRange(tt.total, tt.rows, tt.offset) + if s != tt.wantS || e != tt.wantE { + t.Fatalf("range mismatch, want=(%d,%d) got=(%d,%d)", tt.wantS, tt.wantE, s, e) + } + }) + } +} + +func TestRecomputeSuggestions_HideWhenInputEmpty(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.input.SetValue("") + m.recomputeSuggestions() + if len(m.suggestions) != 0 { + t.Fatalf("expected no suggestions on empty input, got=%d", len(m.suggestions)) + } + + m.input.SetValue("com") + m.recomputeSuggestions() + if len(m.suggestions) == 0 { + t.Fatalf("expected suggestions for non-empty input") + } +} + +func TestCompletionItemsSortedByKindPriority(t *testing.T) { + items := uniqueCompletionItems([]completionItem{ + {Insert: "json", Kind: completionKindEnum}, + {Insert: "", Kind: completionKindArg}, + {Insert: "--format", Kind: completionKindFlag}, + {Insert: "commit", Kind: completionKindCommand}, + {Insert: "-m", Kind: completionKindFlag}, + {Insert: "deploy", Kind: completionKindCommand}, + }) + + got := make([]string, 0, len(items)) + for _, item := range items { + got = append(got, string(item.Kind)+":"+item.Insert) + } + + want := []string{ + "command:commit", + "command:deploy", + "flag:--format", + "flag:-m", + "arg:", + "enum:json", + } + + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("unexpected sorted order\nwant=%v\ngot=%v", want, got) + } +} + +func TestRenderSuggestionLine_RespectsWidth(t *testing.T) { + item := completionItem{ + Insert: "very-long-command-name-that-should-be-clamped", + Description: strings.Repeat("description ", 20), + Kind: completionKindCommand, + } + + line := renderSuggestionLine(item, false, 40) + if got := lipgloss.Width(line); got > 40 { + t.Fatalf("line width overflow, want <=40 got=%d", got) + } +} + +func TestTruncateDisplayWidth(t *testing.T) { + if got := truncateDisplayWidth("abcdef", 4); got != "abc…" { + t.Fatalf("unexpected truncate result: %q", got) + } + if got := truncateDisplayWidth("你好世界", 5); got != "你好…" { + t.Fatalf("unexpected wide-char truncate result: %q", got) + } +} + +func TestNormalizeOutputLines(t *testing.T) { + lines := normalizeOutputLines("progress 10%\rprogress 100%\nline2\rline2-final") + if len(lines) != 2 { + t.Fatalf("unexpected line count: %d", len(lines)) + } + if lines[0] != "progress 100%" { + t.Fatalf("unexpected first line: %q", lines[0]) + } + if lines[1] != "line2-final" { + t.Fatalf("unexpected second line: %q", lines[1]) + } +} + +func TestWrapDisplayWidth(t *testing.T) { + wrapped := wrapDisplayWidth("abcdefghijklmnopqrstuvwxyz", 8) + if len(wrapped) < 3 { + t.Fatalf("expected wrapped lines, got: %v", wrapped) + } + for _, line := range wrapped { + if w := lipgloss.Width(line); w > 8 { + t.Fatalf("wrapped line too wide, line=%q width=%d", line, w) + } + } +} + +func TestHandleSlashCommand_ModeSwitch(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + + handled, cmd := m.handleSlashCommand("/output") + if !handled || cmd != nil { + t.Fatalf("expected /output handled without cmd, handled=%v cmd=%v", handled, cmd) + } + if !m.outputFocus { + t.Fatalf("expected outputFocus=true after /output") + } + + handled, cmd = m.handleSlashCommand("/input") + if !handled || cmd != nil { + t.Fatalf("expected /input handled without cmd, handled=%v cmd=%v", handled, cmd) + } + if m.outputFocus { + t.Fatalf("expected outputFocus=false after /input") + } +} + +func TestCtrlO_ToggleFocusWithNoticeBlock(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + baseBlocks := len(m.blocks) + + m.toggleOutputFocusWithNotice() + if !m.outputFocus { + t.Fatalf("expected outputFocus=true after first Ctrl+O") + } + if len(m.blocks) <= baseBlocks { + t.Fatalf("expected notice block appended after first Ctrl+O") + } + last := m.blocks[len(m.blocks)-1] + if last.Title != "focus" || !strings.Contains(strings.Join(last.Lines, "\n"), "输出滚动模式") { + t.Fatalf("expected output-focus notice block, got title=%q lines=%v", last.Title, last.Lines) + } + + m.toggleOutputFocusWithNotice() + if m.outputFocus { + t.Fatalf("expected outputFocus=false after second Ctrl+O") + } + last = m.blocks[len(m.blocks)-1] + if last.Title != "focus" || !strings.Contains(strings.Join(last.Lines, "\n"), "输入模式") { + t.Fatalf("expected input-focus notice block, got title=%q lines=%v", last.Title, last.Lines) + } +} + +func TestHandleSlashCommand_HelpAndUnknown(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + baseBlocks := len(m.blocks) + + handled, _ := m.handleSlashCommand("/") + if !handled { + t.Fatalf("expected slash help handled") + } + if len(m.blocks) <= baseBlocks { + t.Fatalf("expected help block appended") + } + if m.blocks[len(m.blocks)-1].Title != "/help" { + t.Fatalf("expected last block title /help, got %q", m.blocks[len(m.blocks)-1].Title) + } + + handled, _ = m.handleSlashCommand("/not-exists") + if !handled { + t.Fatalf("expected unknown slash handled") + } + last := m.blocks[len(m.blocks)-1] + if !strings.Contains(strings.Join(last.Lines, "\n"), "未知 slash 命令") { + t.Fatalf("expected unknown slash feedback, got: %v", last.Lines) + } +} + +func TestCollectSlashCompletionItems(t *testing.T) { + root := buildTestRoot() + items := collectSlashCompletionItems(root, "/") + if len(items) == 0 { + t.Fatalf("expected slash suggestions for '/'") + } + if _, ok := findCompletion(items, "/output"); !ok { + t.Fatalf("expected /output in slash suggestions") + } + if _, ok := findCompletion(items, "/commit"); !ok { + t.Fatalf("expected /commit in slash suggestions") + } + if _, ok := findCompletion(items, "/o"); ok { + t.Fatalf("expected alias /o hidden from slash suggestions") + } + + items = collectSlashCompletionItems(root, "/o") + if _, ok := findCompletion(items, "/output"); !ok { + t.Fatalf("expected /output for prefix /o") + } + if _, ok := findCompletion(items, "/o"); ok { + t.Fatalf("expected alias /o hidden for prefix /o") + } + + items = collectSlashCompletionItems(root, "/q") + if _, ok := findCompletion(items, "/quit"); !ok { + t.Fatalf("expected /quit for prefix /q") + } + if _, ok := findCompletion(items, "/q"); ok { + t.Fatalf("expected alias /q hidden for prefix /q") + } + if _, ok := findCompletion(items, "/exit"); ok { + t.Fatalf("expected alias /exit hidden for prefix /q") + } + + items = collectSlashCompletionItems(root, "/output now") + if len(items) != 0 { + t.Fatalf("expected no slash suggestions after second token, got=%d", len(items)) + } +} + +func TestCollectSlashCompletionItems_CommandFlagsArgsAndEnum(t *testing.T) { + root := buildTestRoot() + + items := collectSlashCompletionItems(root, "/commit ") + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in /commit flag suggestions") + } + if _, ok := findCompletion(items, ""); !ok { + t.Fatalf("expected in /commit arg suggestions") + } + + items = collectSlashCompletionItems(root, "/commit --m") + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in /commit --m suggestions") + } + + items = collectSlashCompletionItems(root, "/commit --format ") + if _, ok := findCompletion(items, "json"); !ok { + t.Fatalf("expected enum value json in /commit --format suggestions") + } +} + +func TestHandleSlashCommand_CommandAsSlash(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + + handled, cmd := m.handleSlashCommand("/commit --message hi") + if !handled { + t.Fatalf("expected slash command handled") + } + if cmd == nil { + t.Fatalf("expected slash command returns run cmd") + } + if !m.running { + t.Fatalf("expected running=true for slash business command") + } + if strings.TrimSpace(m.runningCommand) != "commit --message hi" { + t.Fatalf("unexpected running command: %q", m.runningCommand) + } +} + +func TestRecomputeSuggestions_Slash(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.input.SetValue("/o") + m.recomputeSuggestions() + if len(m.suggestions) == 0 { + t.Fatalf("expected slash suggestions") + } + if _, ok := findCompletion(m.suggestions, "/output"); !ok { + t.Fatalf("expected /output in recomputed suggestions") + } +} + +func TestTabOnEmptyInputShowsStarterSuggestions(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + + if len(m.suggestions) != 0 { + t.Fatalf("expected no suggestions on init empty input") + } + + model, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = model.(*richlineModel) + if len(m.suggestions) == 0 { + t.Fatalf("expected starter suggestions on first TAB") + } + if _, ok := findCompletion(m.suggestions, "commit"); !ok { + t.Fatalf("expected command suggestion commit on first TAB") + } + if _, ok := findCompletion(m.suggestions, "--help"); ok { + t.Fatalf("expected no flag suggestion on first TAB starter list") + } + if got := m.input.Value(); got != "" { + t.Fatalf("expected first TAB not to apply completion, got input=%q", got) + } + + model, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = model.(*richlineModel) + if got := m.input.Value(); got == "" { + t.Fatalf("expected second TAB to apply selected completion") + } +} + +func TestStarterSuggestionsPinnedOnEmptyInput(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + + model, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyTab})) + m = model.(*richlineModel) + if len(m.suggestions) == 0 { + t.Fatalf("expected starter suggestions after first TAB") + } + + model, _ = m.Update(struct{}{}) + m = model.(*richlineModel) + if len(m.suggestions) == 0 { + t.Fatalf("expected starter suggestions kept after non-key message") + } +} + +func TestView_ShowsStatusHeader(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.width = 100 + m.height = 24 + + v := m.View() + content := stripANSI(v.Content) + if !strings.Contains(content, "status=IDLE") { + t.Fatalf("expected view contains status=IDLE, got: %s", content) + } +} + +func TestView_ShowsRunningCommandHint(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.width = 120 + m.height = 24 + m.running = true + m.runningCommand = "commit --message hello" + + v := m.View() + content := stripANSI(v.Content) + if !strings.Contains(content, "status=RUNNING") { + t.Fatalf("expected view contains status=RUNNING") + } + if !strings.Contains(content, "执行中: commit --message hello") { + t.Fatalf("expected running command hint in view") + } +} + +func TestView_ShowsOutputFocusAndScrollStatus(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.width = 120 + m.height = 24 + m.outputFocus = true + m.outputOffset = 3 + m.appendBlock(outputBlock{Title: "$ app commit", Lines: []string{"line1", "line2", "line3", "line4", "line5", "line6"}}) + + v := m.View() + content := stripANSI(v.Content) + if !strings.Contains(content, "status=OUTPUT_SCROLL") { + t.Fatalf("expected view contains status=OUTPUT_SCROLL") + } + if !strings.Contains(content, "focus=OUTPUT") { + t.Fatalf("expected view contains focus=OUTPUT") + } + if !strings.Contains(content, "滚动状态:offset=") { + t.Fatalf("expected view contains scroll state line") + } + if !strings.Contains(content, "当前焦点=输出区") { + t.Fatalf("expected output focus hint in view") + } + if !strings.Contains(content, "输入区域(focus=OUTPUT)") { + t.Fatalf("expected input section header contains focus=OUTPUT") + } +} + +func TestView_InputAreaRemainsVisibleWithLargeOutput(t *testing.T) { + root := buildTestRoot() + m := newRichlineModel(context.Background(), root, "richline> ", nil, "", false) + m.width = 100 + m.height = 14 + + lines := make([]string, 0, 120) + for i := 0; i < 120; i++ { + lines = append(lines, "line") + } + m.appendBlock(outputBlock{Title: "$ app commit", Lines: lines}) + + v := m.View() + content := stripANSI(v.Content) + if !strings.Contains(content, "输入区域(focus=INPUT)") { + t.Fatalf("expected input section header remains visible") + } + if !strings.Contains(content, "richline>") { + t.Fatalf("expected input prompt remains visible") + } +} + +func buildTestRoot() *redant.Command { + var ( + format string + msg string + target string + ) + commit := &redant.Command{ + Use: "commit", + Short: "提交代码", + Options: redant.OptionSet{ + {Flag: "format", Description: "输出格式", Value: redant.EnumOf(&format, "text", "json", "yaml")}, + {Flag: "message", Shorthand: "m", Description: "提交信息", Value: redant.StringOf(&msg)}, + }, + Args: redant.ArgSet{{Name: "target", Description: "目标环境", Value: redant.EnumOf(&target, "dev", "test", "prod")}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + } + return &redant.Command{ + Use: "app", + Children: []*redant.Command{commit}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } +} + +func findCompletion(items []completionItem, insert string) (completionItem, bool) { + for _, item := range items { + if item.Insert == insert { + return item, true + } + } + return completionItem{}, false +} diff --git a/cmds/webcmd/web.go b/cmds/webcmd/web.go new file mode 100644 index 0000000..1873cb3 --- /dev/null +++ b/cmds/webcmd/web.go @@ -0,0 +1,110 @@ +package webcmd + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/pubgo/redant" + "github.com/pubgo/redant/internal/webui" +) + +func New() *redant.Command { + var addr string + var autoOpen bool + + return &redant.Command{ + Use: "web", + Short: "打开可视化命令执行页面", + Long: "启动本地 Web 控制台:左侧命令列表,右侧 flags/args 输入,并展示完整调用过程与执行结果。", + Options: redant.OptionSet{ + { + Flag: "addr", + Description: "Web 服务监听地址", + Value: redant.StringOf(&addr), + Default: "127.0.0.1:18080", + }, + { + Flag: "open", + Description: "启动后自动打开浏览器", + Value: redant.BoolOf(&autoOpen), + Default: "true", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + root := inv.Command + for root.Parent() != nil { + root = root.Parent() + } + + listenAddr := strings.TrimSpace(addr) + if listenAddr == "" { + listenAddr = "127.0.0.1:18080" + } + + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + defer func() { _ = ln.Close() }() + + url := "http://" + ln.Addr().String() + _, _ = fmt.Fprintf(inv.Stdout, "web ui listening on %s\n", url) + _, _ = fmt.Fprintf(inv.Stdout, "press Ctrl+C to stop\n") + + if autoOpen { + if openErr := openBrowser(url); openErr != nil { + _, _ = fmt.Fprintf(inv.Stderr, "open browser failed: %v\n", openErr) + } + } + + server := &http.Server{Handler: webui.New(root).Handler()} + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(ln) + }() + + select { + case serveErr := <-errCh: + if errors.Is(serveErr, http.ErrServerClosed) { + return nil + } + return serveErr + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + serveErr := <-errCh + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + return serveErr + } + return nil + } + }, + } +} + +func AddWebCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/cmds/webcmd/web_test.go b/cmds/webcmd/web_test.go new file mode 100644 index 0000000..23b34e3 --- /dev/null +++ b/cmds/webcmd/web_test.go @@ -0,0 +1,65 @@ +package webcmd + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/pubgo/redant" +) + +func TestAddWebCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddWebCommand(root) + + if len(root.Children) != 1 { + t.Fatalf("expected one child command, got %d", len(root.Children)) + } + if root.Children[0].Name() != "web" { + t.Fatalf("expected child command web, got %s", root.Children[0].Name()) + } +} + +func TestWebCommandRunAndShutdown(t *testing.T) { + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "hello", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte("hello")) + return nil + }, + }) + AddWebCommand(root) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inv := root.Invoke("web", "--addr", "127.0.0.1:0", "--open=false") + inv.Stdout = stdout + inv.Stderr = stderr + + done := make(chan error, 1) + go func() { + done <- inv.WithContext(ctx).Run() + }() + + time.Sleep(150 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("web command run failed: %v (stderr=%s)", err, stderr.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("web command did not shutdown in time") + } + + if !strings.Contains(stdout.String(), "web ui listening on") { + t.Fatalf("expected startup output, got %q", stdout.String()) + } +} diff --git a/cmds/webttycmd/pty_signal_unix.go b/cmds/webttycmd/pty_signal_unix.go new file mode 100644 index 0000000..e490025 --- /dev/null +++ b/cmds/webttycmd/pty_signal_unix.go @@ -0,0 +1,80 @@ +//go:build !windows + +package webttycmd + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "golang.org/x/sys/unix" +) + +func prepareInteractiveShellCmd(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func signalPTYForegroundProcessGroup(ptmx *os.File, signalName string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + + pgid, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP) + if err != nil { + return fmt.Errorf("read pty foreground process group failed: %w", err) + } + if pgid <= 0 { + return fmt.Errorf("invalid pty foreground process group: %d", pgid) + } + + var sig unix.Signal + switch signalName { + case "INT": + sig = unix.SIGINT + case "TSTP": + sig = unix.SIGTSTP + default: + return fmt.Errorf("unsupported signal name: %s", signalName) + } + + if err := unix.Kill(-pgid, sig); err != nil { + return fmt.Errorf("send signal to foreground process group failed: %w", err) + } + return nil +} + +func signalProcessGroupByPID(pid int, signalName string) error { + if pid <= 0 { + return fmt.Errorf("invalid pid: %d", pid) + } + + pgid, err := unix.Getpgid(pid) + if err != nil { + return fmt.Errorf("get process group by pid failed: %w", err) + } + if pgid <= 0 { + return fmt.Errorf("invalid process group id: %d", pgid) + } + + var sig unix.Signal + switch signalName { + case "INT": + sig = unix.SIGINT + case "TSTP": + sig = unix.SIGTSTP + default: + return fmt.Errorf("unsupported signal name: %s", signalName) + } + + if err := unix.Kill(-pgid, sig); err != nil { + return fmt.Errorf("send signal to process group failed: %w", err) + } + return nil +} diff --git a/cmds/webttycmd/pty_signal_windows.go b/cmds/webttycmd/pty_signal_windows.go new file mode 100644 index 0000000..190f9e3 --- /dev/null +++ b/cmds/webttycmd/pty_signal_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package webttycmd + +import ( + "fmt" + "os" + "os/exec" +) + +func prepareInteractiveShellCmd(cmd *exec.Cmd) { + _ = cmd +} + +func signalPTYForegroundProcessGroup(ptmx *os.File, signalName string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + return fmt.Errorf("signal forwarding is not supported on windows") +} + +func signalProcessGroupByPID(pid int, signalName string) error { + if pid <= 0 { + return fmt.Errorf("invalid pid: %d", pid) + } + return fmt.Errorf("signal forwarding is not supported on windows") +} diff --git a/cmds/webttycmd/webtty.go b/cmds/webttycmd/webtty.go new file mode 100644 index 0000000..fb5ad38 --- /dev/null +++ b/cmds/webttycmd/webtty.go @@ -0,0 +1,1435 @@ +package webttycmd + +import ( + "archive/zip" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "time" + "unicode" + + "github.com/coder/websocket" + "github.com/creack/pty" + + "github.com/pubgo/redant" +) + +const maxUploadBytes int64 = 100 << 20 // 100MB + +type uploadItemResponse struct { + FileName string `json:"fileName"` + SavedPath string `json:"savedPath,omitempty"` + Size int64 `json:"size,omitempty"` + Error string `json:"error,omitempty"` +} + +type uploadResponse struct { + OK bool `json:"ok"` + Dir string `json:"dir"` + Items []uploadItemResponse `json:"items"` +} + +type fileEntry struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + IsDir bool `json:"isDir"` + ModTime string `json:"modTime"` +} + +type fileListResponse struct { + OK bool `json:"ok"` + Dir string `json:"dir"` + Entries []fileEntry `json:"entries"` +} + +func New() *redant.Command { + var addr string + var autoOpen bool + + return &redant.Command{ + Use: "webtty", + Short: "打开 WebTTY 交互终端页面", + Long: "启动最简 WebTTY:仅暴露本地 shell(WebSocket + PTY),不复用 webui。", + Options: redant.OptionSet{ + { + Flag: "addr", + Description: "WebTTY 服务监听地址", + Value: redant.StringOf(&addr), + Default: "127.0.0.1:18081", + }, + { + Flag: "open", + Description: "启动后自动打开浏览器", + Value: redant.BoolOf(&autoOpen), + Default: "true", + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + listenAddr := strings.TrimSpace(addr) + if listenAddr == "" { + listenAddr = "127.0.0.1:18081" + } + + ln, err := net.Listen("tcp", listenAddr) + if err != nil { + return err + } + defer func() { _ = ln.Close() }() + + url := "http://" + ln.Addr().String() + _, _ = fmt.Fprintf(inv.Stdout, "webtty listening on %s\n", url) + _, _ = fmt.Fprintln(inv.Stdout, "tip: 页面会直接连接本地 shell") + _, _ = fmt.Fprintln(inv.Stdout, "press Ctrl+C to stop") + + if autoOpen { + if openErr := openBrowser(url); openErr != nil { + _, _ = fmt.Fprintf(inv.Stderr, "open browser failed: %v\n", openErr) + } + } + + server := &http.Server{Handler: newHandler()} + errCh := make(chan error, 1) + go func() { + errCh <- server.Serve(ln) + }() + + select { + case serveErr := <-errCh: + if errors.Is(serveErr, http.ErrServerClosed) { + return nil + } + return serveErr + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + serveErr := <-errCh + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + return serveErr + } + return nil + } + }, + } +} + +func newHandler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) + }) + mux.HandleFunc("/ws", handleTerminalWS) + mux.HandleFunc("/upload", handleUpload) + mux.HandleFunc("/api/files", handleListFiles) + mux.HandleFunc("/download", handleDownload) + mux.HandleFunc("/download-zip", handleDownloadZip) + return mux +} + +func handleUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1024) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + http.Error(w, fmt.Sprintf("invalid multipart form: %v", err), http.StatusBadRequest) + return + } + + wd, err := os.Getwd() + if err != nil { + http.Error(w, fmt.Sprintf("resolve working dir failed: %v", err), http.StatusInternalServerError) + return + } + + destDir, err := resolveUploadDir(wd, r.FormValue("dir")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := os.MkdirAll(destDir, 0o755); err != nil { + http.Error(w, fmt.Sprintf("create upload dir failed: %v", err), http.StatusInternalServerError) + return + } + + headers := collectUploadFileHeaders(r.MultipartForm) + if len(headers) == 0 { + http.Error(w, "missing file field", http.StatusBadRequest) + return + } + + resp := uploadResponse{OK: true, Dir: filepath.ToSlash(strings.TrimPrefix(strings.TrimPrefix(destDir, wd), string(filepath.Separator)))} + if resp.Dir == "" { + resp.Dir = "." + } + + for _, header := range headers { + item := uploadItemResponse{FileName: header.Filename} + + fileName := sanitizeUploadFileName(header.Filename) + if fileName == "" { + item.Error = "invalid filename" + resp.OK = false + resp.Items = append(resp.Items, item) + continue + } + + file, openErr := header.Open() + if openErr != nil { + item.Error = fmt.Sprintf("open upload failed: %v", openErr) + resp.OK = false + resp.Items = append(resp.Items, item) + continue + } + + destPath := filepath.Join(destDir, fileName) + out, createErr := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if createErr != nil { + _ = file.Close() + item.Error = fmt.Sprintf("open destination failed: %v", createErr) + resp.OK = false + resp.Items = append(resp.Items, item) + continue + } + + written, copyErr := io.Copy(out, file) + closeOutErr := out.Close() + _ = file.Close() + if copyErr != nil { + item.Error = fmt.Sprintf("write file failed: %v", copyErr) + resp.OK = false + resp.Items = append(resp.Items, item) + continue + } + if closeOutErr != nil { + item.Error = fmt.Sprintf("close file failed: %v", closeOutErr) + resp.OK = false + resp.Items = append(resp.Items, item) + continue + } + + relPath := fileName + if rel, relErr := filepath.Rel(wd, destPath); relErr == nil { + relPath = filepath.ToSlash(rel) + } + + item.SavedPath = relPath + item.Size = written + resp.Items = append(resp.Items, item) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func collectUploadFileHeaders(form *multipart.Form) []*multipart.FileHeader { + if form == nil || len(form.File) == 0 { + return nil + } + + headers := make([]*multipart.FileHeader, 0) + for _, files := range form.File { + headers = append(headers, files...) + } + sort.SliceStable(headers, func(i, j int) bool { + return headers[i].Filename < headers[j].Filename + }) + return headers +} + +func handleListFiles(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + wd, err := os.Getwd() + if err != nil { + http.Error(w, fmt.Sprintf("resolve working dir failed: %v", err), http.StatusInternalServerError) + return + } + + dirPath, err := resolveUploadDir(wd, r.URL.Query().Get("dir")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + entries, err := os.ReadDir(dirPath) + if err != nil { + http.Error(w, fmt.Sprintf("read dir failed: %v", err), http.StatusBadRequest) + return + } + + out := make([]fileEntry, 0, len(entries)) + for _, entry := range entries { + info, infoErr := entry.Info() + if infoErr != nil { + continue + } + fullPath := filepath.Join(dirPath, entry.Name()) + relPath := entry.Name() + if rel, relErr := filepath.Rel(wd, fullPath); relErr == nil { + relPath = filepath.ToSlash(rel) + } + out = append(out, fileEntry{ + Name: entry.Name(), + Path: relPath, + Size: info.Size(), + IsDir: entry.IsDir(), + ModTime: info.ModTime().Format(time.RFC3339), + }) + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].IsDir != out[j].IsDir { + return out[i].IsDir && !out[j].IsDir + } + return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) + }) + + relDir := "." + if rel, relErr := filepath.Rel(wd, dirPath); relErr == nil { + rel = filepath.ToSlash(rel) + if rel != "." { + relDir = rel + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(fileListResponse{OK: true, Dir: relDir, Entries: out}) +} + +func handleDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + wd, err := os.Getwd() + if err != nil { + http.Error(w, fmt.Sprintf("resolve working dir failed: %v", err), http.StatusInternalServerError) + return + } + + fullPath, relPath, err := resolveDownloadPath(wd, r.URL.Query().Get("path")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + info, err := os.Stat(fullPath) + if err != nil { + http.Error(w, fmt.Sprintf("stat file failed: %v", err), http.StatusBadRequest) + return + } + if info.IsDir() { + http.Error(w, "path is directory", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(relPath))) + http.ServeFile(w, r, fullPath) +} + +func handleDownloadZip(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + wd, err := os.Getwd() + if err != nil { + http.Error(w, fmt.Sprintf("resolve working dir failed: %v", err), http.StatusInternalServerError) + return + } + + dirPath, relDir, err := resolveDownloadDir(wd, r.URL.Query().Get("dir")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + info, err := os.Stat(dirPath) + if err != nil { + http.Error(w, fmt.Sprintf("stat dir failed: %v", err), http.StatusBadRequest) + return + } + if !info.IsDir() { + http.Error(w, "dir is not a directory", http.StatusBadRequest) + return + } + + zipName := strings.ReplaceAll(relDir, "/", "_") + if zipName == "." || zipName == "" { + zipName = "workspace" + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipName+".zip")) + + zw := zip.NewWriter(w) + defer func() { _ = zw.Close() }() + + err = filepath.WalkDir(dirPath, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + rel, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + + name := filepath.ToSlash(rel) + info, err := d.Info() + if err != nil { + return err + } + + if d.IsDir() { + h := &zip.FileHeader{Name: name + "/", Method: zip.Store} + h.SetMode(info.Mode()) + _, err = zw.CreateHeader(h) + return err + } + + h, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + h.Name = name + h.Method = zip.Deflate + + writer, err := zw.CreateHeader(h) + if err != nil { + return err + } + + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + _, err = io.Copy(writer, f) + return err + }) + if err != nil { + http.Error(w, fmt.Sprintf("zip directory failed: %v", err), http.StatusInternalServerError) + return + } +} + +func resolveDownloadPath(wd, rawPath string) (string, string, error) { + p := strings.TrimSpace(rawPath) + if p == "" { + return "", "", fmt.Errorf("path is required") + } + if filepath.IsAbs(p) { + return "", "", fmt.Errorf("path must be relative") + } + + clean := filepath.Clean(p) + if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", "", fmt.Errorf("path cannot escape working directory") + } + + full := filepath.Join(wd, clean) + return full, filepath.ToSlash(clean), nil +} + +func resolveDownloadDir(wd, rawDir string) (string, string, error) { + dirPath, err := resolveUploadDir(wd, rawDir) + if err != nil { + return "", "", err + } + + relDir := "." + if rel, relErr := filepath.Rel(wd, dirPath); relErr == nil { + rel = filepath.ToSlash(rel) + if rel != "." { + relDir = rel + } + } + + return dirPath, relDir, nil +} + +func sanitizeUploadFileName(name string) string { + base := filepath.Base(strings.TrimSpace(name)) + if base == "" || base == "." || base == ".." { + return "" + } + + base = strings.ReplaceAll(base, "/", "_") + base = strings.ReplaceAll(base, "\\", "_") + base = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return '_' + } + if r == ':' { + return '_' + } + return r + }, base) + + base = strings.TrimSpace(base) + if base == "" || base == "." || base == ".." { + return "" + } + return base +} + +func resolveUploadDir(wd, rawDir string) (string, error) { + d := strings.TrimSpace(rawDir) + if d == "" { + return wd, nil + } + + if filepath.IsAbs(d) { + return "", fmt.Errorf("dir must be relative") + } + + clean := filepath.Clean(d) + if clean == "." { + return wd, nil + } + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("dir cannot escape working directory") + } + + return filepath.Join(wd, clean), nil +} + +func writePTYInput(ptmx, pts *os.File, processPID int, data string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + if data == "" { + return nil + } + + if handled, err := trySignalFromControlInput(pts, processPID, data); handled { + if err == nil { + return nil + } + // fallback to raw write when signal forwarding fails + } + + _, err := ptmx.Write([]byte(data)) + return err +} + +func trySignalFromControlInput(pts *os.File, processPID int, data string) (bool, error) { + b := []byte(data) + if len(b) != 1 { + return false, nil + } + + switch b[0] { + case 0x03: // Ctrl+C + return true, signalFromControlInput(pts, processPID, "INT") + case 0x1a: // Ctrl+Z + return true, signalFromControlInput(pts, processPID, "TSTP") + default: + return false, nil + } +} + +func signalFromControlInput(pts *os.File, processPID int, signalName string) error { + err := signalPTYForegroundProcessGroup(pts, signalName) + if err == nil { + return nil + } + + if processPID <= 0 { + return err + } + + fallbackErr := signalProcessGroupByPID(processPID, signalName) + if fallbackErr == nil { + return nil + } + + return errors.Join(err, fmt.Errorf("fallback process group signal failed: %w", fallbackErr)) +} + +func shellProcessPID(cmd *exec.Cmd) int { + if cmd == nil || cmd.Process == nil { + return 0 + } + return cmd.Process.Pid +} + +func handleTerminalWS(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "done") }() + + cols, rows := parseTerminalSize(r.URL.Query()) + + ptmx, pts, err := pty.Open() + if err != nil { + _ = conn.Close(websocket.StatusInternalError, "open pty failed") + return + } + defer func() { + _ = ptmx.Close() + _ = pts.Close() + }() + + _ = pty.Setsize(ptmx, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) + + shellPath, shellArgs := detectInteractiveShell() + cmd := exec.CommandContext(r.Context(), shellPath, shellArgs...) + cmd.Stdin = pts + cmd.Stdout = pts + cmd.Stderr = pts + prepareInteractiveShellCmd(cmd) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + + if wd, wdErr := os.Getwd(); wdErr == nil { + cmd.Dir = wd + } + + if err := cmd.Start(); err != nil { + _ = conn.Close(websocket.StatusInternalError, "start shell failed") + return + } + shellPID := shellProcessPID(cmd) + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + var wg sync.WaitGroup + var writeMu sync.Mutex + + writeWS := func(data []byte) error { + writeMu.Lock() + defer writeMu.Unlock() + return conn.Write(ctx, websocket.MessageText, data) + } + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 4096) + for { + n, readErr := ptmx.Read(buf) + if n > 0 { + if err := writeWS(buf[:n]); err != nil { + cancel() + return + } + } + if readErr != nil { + if !errors.Is(readErr, io.EOF) { + cancel() + } + return + } + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for { + msgType, data, readErr := conn.Read(ctx) + if readErr != nil { + cancel() + return + } + if msgType != websocket.MessageText { + continue + } + + if strings.HasPrefix(string(data), "{") { + var resizeMsg struct { + Type string `json:"type"` + Cols int `json:"cols"` + Rows int `json:"rows"` + } + if json.Unmarshal(data, &resizeMsg) == nil && resizeMsg.Type == "resize" { + if resizeMsg.Cols > 0 && resizeMsg.Rows > 0 { + _ = pty.Setsize(ptmx, &pty.Winsize{Cols: uint16(resizeMsg.Cols), Rows: uint16(resizeMsg.Rows)}) + } + continue + } + } + + if len(data) > 0 { + if err := writePTYInput(ptmx, pts, shellPID, string(data)); err != nil { + cancel() + return + } + } + } + }() + + waitCh := make(chan error, 1) + go func() { waitCh <- cmd.Wait() }() + + select { + case <-ctx.Done(): + _ = cmd.Process.Kill() + case <-waitCh: + cancel() + } + + wg.Wait() +} + +func parseTerminalSize(values url.Values) (int, int) { + cols := 80 + rows := 24 + if c, err := strconv.Atoi(strings.TrimSpace(values.Get("cols"))); err == nil && c > 0 && c <= 1000 { + cols = c + } + if r, err := strconv.Atoi(strings.TrimSpace(values.Get("rows"))); err == nil && r > 0 && r <= 1000 { + rows = r + } + return cols, rows +} + +func detectInteractiveShell() (string, []string) { + if runtime.GOOS == "windows" { + if shell := strings.TrimSpace(os.Getenv("COMSPEC")); shell != "" { + return shell, nil + } + return "cmd.exe", nil + } + + if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" { + return shell, []string{"-i"} + } + + return "/bin/sh", []string{"-i"} +} + +const indexHTML = ` + + + + + webtty + + + + + + +
+
+ webtty: local shell + + + + + + + + + + + + ws: connecting + +
+ +
+
拖拽文件到此处上传
+
+
+ +
+
+

上传队列(支持失败重试)

+
+
总进度:0 / 0
+
+
+
+
+

文件列表 / 下载

+ + + +
+ + + + + +
namesizeaction
+
+
+
+ + + +` + +func AddWebTTYCommand(rootCmd *redant.Command) { + rootCmd.Children = append(rootCmd.Children, New()) +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + return cmd.Start() +} diff --git a/cmds/webttycmd/webtty_test.go b/cmds/webttycmd/webtty_test.go new file mode 100644 index 0000000..d0cdffc --- /dev/null +++ b/cmds/webttycmd/webtty_test.go @@ -0,0 +1,390 @@ +package webttycmd + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/pubgo/redant" +) + +func TestAddWebTTYCommand(t *testing.T) { + root := &redant.Command{Use: "app"} + AddWebTTYCommand(root) + + if len(root.Children) != 1 { + t.Fatalf("expected one child command, got %d", len(root.Children)) + } + if root.Children[0].Name() != "webtty" { + t.Fatalf("expected child command webtty, got %s", root.Children[0].Name()) + } +} + +func TestWebTTYCommandRunAndShutdown(t *testing.T) { + root := &redant.Command{Use: "app"} + AddWebTTYCommand(root) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inv := root.Invoke("webtty", "--addr", "127.0.0.1:0", "--open=false") + inv.Stdout = stdout + inv.Stderr = stderr + + done := make(chan error, 1) + go func() { + done <- inv.WithContext(ctx).Run() + }() + + time.Sleep(150 * time.Millisecond) + cancel() + + select { + case err := <-done: + if err != nil { + t.Fatalf("webtty command run failed: %v (stderr=%s)", err, stderr.String()) + } + case <-time.After(3 * time.Second): + t.Fatal("webtty command did not shutdown in time") + } + + if !strings.Contains(stdout.String(), "webtty listening on") { + t.Fatalf("expected startup output, got %q", stdout.String()) + } +} + +func TestUploadEndpointSuccess(t *testing.T) { + h := newHandler() + ts := httptest.NewServer(h) + defer ts.Close() + + tmp := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir tmp: %v", err) + } + defer func() { _ = os.Chdir(originalWD) }() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, err := mw.CreateFormFile("file", "hello.txt") + if err != nil { + t.Fatalf("create form file: %v", err) + } + _, _ = fw.Write([]byte("hello webtty")) + _ = mw.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/upload", &body) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload uploadResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.OK { + t.Fatalf("expected ok=true") + } + if len(payload.Items) != 1 { + t.Fatalf("expected one item, got %d", len(payload.Items)) + } + if payload.Items[0].SavedPath != "hello.txt" { + t.Fatalf("unexpected savedPath: %q", payload.Items[0].SavedPath) + } + + data, err := os.ReadFile(filepath.Join(tmp, "hello.txt")) + if err != nil { + t.Fatalf("read uploaded file: %v", err) + } + if string(data) != "hello webtty" { + t.Fatalf("unexpected file content: %q", string(data)) + } +} + +func TestUploadEndpointRejectDirTraversal(t *testing.T) { + h := newHandler() + ts := httptest.NewServer(h) + defer ts.Close() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, err := mw.CreateFormFile("file", "evil.txt") + if err != nil { + t.Fatalf("create form file: %v", err) + } + _, _ = fw.Write([]byte("evil")) + _ = mw.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/upload", &body) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + q := req.URL.Query() + q.Set("dir", "../outside") + req.URL.RawQuery = q.Encode() + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } +} + +func TestUploadEndpointMultiFilesToSubDir(t *testing.T) { + h := newHandler() + ts := httptest.NewServer(h) + defer ts.Close() + + tmp := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir tmp: %v", err) + } + defer func() { _ = os.Chdir(originalWD) }() + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw1, err := mw.CreateFormFile("file", "a.txt") + if err != nil { + t.Fatalf("create form file a: %v", err) + } + _, _ = fw1.Write([]byte("A")) + fw2, err := mw.CreateFormFile("file", "b.txt") + if err != nil { + t.Fatalf("create form file b: %v", err) + } + _, _ = fw2.Write([]byte("BB")) + _ = mw.Close() + + req, err := http.NewRequest(http.MethodPost, ts.URL+"/upload?dir=uploads/sub", &body) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", mw.FormDataContentType()) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload uploadResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if !payload.OK { + t.Fatalf("expected ok=true, got %+v", payload) + } + if len(payload.Items) != 2 { + t.Fatalf("expected 2 uploaded items, got %d", len(payload.Items)) + } + + if _, err := os.Stat(filepath.Join(tmp, "uploads", "sub", "a.txt")); err != nil { + t.Fatalf("a.txt missing: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "uploads", "sub", "b.txt")); err != nil { + t.Fatalf("b.txt missing: %v", err) + } +} + +func TestListAndDownloadEndpoints(t *testing.T) { + h := newHandler() + ts := httptest.NewServer(h) + defer ts.Close() + + tmp := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir tmp: %v", err) + } + defer func() { _ = os.Chdir(originalWD) }() + + if err := os.MkdirAll(filepath.Join(tmp, "downloads"), 0o755); err != nil { + t.Fatalf("mkdir downloads: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "downloads", "x.txt"), []byte("download-me"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + listResp, err := http.Get(ts.URL + "/api/files?dir=downloads") + if err != nil { + t.Fatalf("list files request: %v", err) + } + defer func() { _ = listResp.Body.Close() }() + if listResp.StatusCode != http.StatusOK { + t.Fatalf("expected list 200, got %d", listResp.StatusCode) + } + + var listPayload fileListResponse + if err := json.NewDecoder(listResp.Body).Decode(&listPayload); err != nil { + t.Fatalf("decode list response: %v", err) + } + if !listPayload.OK { + t.Fatalf("expected list ok=true") + } + if len(listPayload.Entries) == 0 { + t.Fatalf("expected list entries") + } + + dlResp, err := http.Get(ts.URL + "/download?path=downloads/x.txt") + if err != nil { + t.Fatalf("download request: %v", err) + } + defer func() { _ = dlResp.Body.Close() }() + if dlResp.StatusCode != http.StatusOK { + t.Fatalf("expected download 200, got %d", dlResp.StatusCode) + } + + content, err := io.ReadAll(dlResp.Body) + if err != nil { + t.Fatalf("read download body: %v", err) + } + if string(content) != "download-me" { + t.Fatalf("unexpected download content: %q", string(content)) + } +} + +func TestDownloadZipEndpoint(t *testing.T) { + h := newHandler() + ts := httptest.NewServer(h) + defer ts.Close() + + tmp := t.TempDir() + originalWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir tmp: %v", err) + } + defer func() { _ = os.Chdir(originalWD) }() + + if err := os.MkdirAll(filepath.Join(tmp, "pack", "sub"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "pack", "a.txt"), []byte("A"), 0o644); err != nil { + t.Fatalf("write a.txt: %v", err) + } + if err := os.WriteFile(filepath.Join(tmp, "pack", "sub", "b.txt"), []byte("BB"), 0o644); err != nil { + t.Fatalf("write b.txt: %v", err) + } + + resp, err := http.Get(ts.URL + "/download-zip?dir=pack") + if err != nil { + t.Fatalf("download zip request: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read zip body: %v", err) + } + + zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body))) + if err != nil { + t.Fatalf("open zip: %v", err) + } + + seen := map[string]bool{} + for _, f := range zr.File { + seen[f.Name] = true + } + + if !seen["a.txt"] { + t.Fatalf("zip missing a.txt, got %+v", seen) + } + if !seen["sub/b.txt"] { + t.Fatalf("zip missing sub/b.txt, got %+v", seen) + } +} + +func TestTrySignalFromControlInput(t *testing.T) { + t.Run("ctrl+c recognized", func(t *testing.T) { + handled, err := trySignalFromControlInput(nil, 0, "\x03") + if !handled { + t.Fatalf("expected handled=true for ctrl+c") + } + if err == nil { + t.Fatalf("expected non-nil error when pty/process unavailable") + } + }) + + t.Run("non-control ignored", func(t *testing.T) { + handled, err := trySignalFromControlInput(nil, 0, "a") + if handled { + t.Fatalf("expected handled=false for normal input") + } + if err != nil { + t.Fatalf("expected nil error for normal input, got %v", err) + } + }) + + t.Run("multi-byte ignored", func(t *testing.T) { + handled, err := trySignalFromControlInput(nil, 0, "ab") + if handled { + t.Fatalf("expected handled=false for multi-byte input") + } + if err != nil { + t.Fatalf("expected nil error for multi-byte input, got %v", err) + } + }) +} + +func TestShellProcessPID(t *testing.T) { + if got := shellProcessPID(nil); got != 0 { + t.Fatalf("expected 0 pid for nil cmd, got %d", got) + } + + cmd := &exec.Cmd{} + if got := shellProcessPID(cmd); got != 0 { + t.Fatalf("expected 0 pid for cmd without process, got %d", got) + } +} diff --git a/command.go b/command.go index 3fcc016..e35fac9 100644 --- a/command.go +++ b/command.go @@ -43,6 +43,10 @@ type Command struct { // If set, the value is used as the deprecation message. Deprecated string `json:"deprecated,omitempty"` + // Metadata stores extensible command annotations for higher-level behaviors + // (for example: mode=agent, agent.command=true). + Metadata map[string]string `json:"metadata,omitempty"` + // RawArgs determines whether the command should receive unparsed arguments. // No flags are parsed when set, and the command is responsible for parsing // its own flags. @@ -69,6 +73,30 @@ func ascendingSortFn[T cmp.Ordered](a, b T) int { return 1 } +func appendMissingGlobalOptions(base, globals OptionSet) OptionSet { + existing := make(map[string]struct{}, len(base)) + for _, opt := range base { + if opt.Flag == "" { + continue + } + existing[opt.Flag] = struct{}{} + } + + for _, opt := range globals { + if opt.Flag == "" { + base = append(base, opt) + continue + } + if _, ok := existing[opt.Flag]; ok { + continue + } + base = append(base, opt) + existing[opt.Flag] = struct{}{} + } + + return base +} + // init performs initialization and linting on the command and all its children. func (c *Command) init() error { if c.Use == "" { @@ -79,7 +107,7 @@ func (c *Command) init() error { // Add global flags to the root command only if c.parent == nil { globalFlags := GlobalFlags() - c.Options = append(c.Options, globalFlags...) + c.Options = appendMissingGlobalOptions(c.Options, globalFlags) } for i := range c.Options { @@ -123,6 +151,30 @@ func (c *Command) Name() string { return strings.Split(c.Use, " ")[0] } +// Meta returns the metadata value for key. Key lookup is case-insensitive. +func (c *Command) Meta(key string) string { + if c == nil || len(c.Metadata) == 0 { + return "" + } + + key = strings.TrimSpace(key) + if key == "" { + return "" + } + + if v, ok := c.Metadata[key]; ok { + return strings.TrimSpace(v) + } + + for k, v := range c.Metadata { + if strings.EqualFold(strings.TrimSpace(k), key) { + return strings.TrimSpace(v) + } + } + + return "" +} + // FullName returns the full invocation name of the command, // as seen on the command line. func (c *Command) FullName() string { @@ -720,6 +772,23 @@ func (inv *Invocation) run(state *runState) error { inv.Args = parsedArgs[state.commandDepth:] } + if inv.Flags != nil { + if internalArgsFlag := inv.Flags.Lookup(internalArgsOverrideFlag); internalArgsFlag != nil && internalArgsFlag.Changed { + var overriddenArgs []string + switch v := internalArgsFlag.Value.(type) { + case *StringArray: + overriddenArgs = append(overriddenArgs, (*v)...) + default: + parsed, err := readAsCSV(internalArgsFlag.Value.String()) + if err != nil { + return fmt.Errorf("reading %q override values: %w", internalArgsOverrideFlag, err) + } + overriddenArgs = append(overriddenArgs, parsed...) + } + inv.Args = append([]string(nil), overriddenArgs...) + } + } + // Parse args and set values to Arg.Value if Args are defined // Skip args parsing and validation if help was requested if len(inv.Command.Args) > 0 && !isHelpRequested && !errors.Is(state.flagParseErr, pflag.ErrHelp) { @@ -991,6 +1060,16 @@ func parseAndSetArgs(argsDef ArgSet, args []string) error { // //nolint:revive func (inv *Invocation) Run() (err error) { + restoreEnv, preloadErr := preloadEnvFromArgs(inv.Args) + if preloadErr != nil { + return fmt.Errorf("preloading environment variables: %w", preloadErr) + } + defer func() { + if restoreEnv != nil { + err = errors.Join(err, restoreEnv()) + } + }() + for _, child := range inv.Command.Children { child.parent = inv.Command } diff --git a/command_init_test.go b/command_init_test.go new file mode 100644 index 0000000..fb1ae99 --- /dev/null +++ b/command_init_test.go @@ -0,0 +1,54 @@ +package redant + +import "testing" + +func TestCommandInitIsIdempotentForGlobalFlags(t *testing.T) { + root := &Command{Use: "app"} + + if err := root.init(); err != nil { + t.Fatalf("first init failed: %v", err) + } + if err := root.init(); err != nil { + t.Fatalf("second init failed: %v", err) + } + + counts := map[string]int{} + for _, opt := range root.Options { + if opt.Flag == "" { + continue + } + counts[opt.Flag]++ + } + + for _, flag := range []string{"help", "list-commands", "list-flags", "env", "env-file", internalArgsOverrideFlag} { + if counts[flag] != 1 { + t.Fatalf("expected global flag %q exactly once, got %d", flag, counts[flag]) + } + } + + globals := root.GetGlobalFlags() + _ = globals.FlagSet(root.Name()) +} + +func TestCommandInitDoesNotOverrideExistingRootGlobalFlag(t *testing.T) { + root := &Command{ + Use: "app", + Options: OptionSet{ + {Flag: "env", Description: "custom env", Value: StringArrayOf(new([]string))}, + }, + } + + if err := root.init(); err != nil { + t.Fatalf("init failed: %v", err) + } + + envCount := 0 + for _, opt := range root.Options { + if opt.Flag == "env" { + envCount++ + } + } + if envCount != 1 { + t.Fatalf("expected env flag exactly once, got %d", envCount) + } +} diff --git a/command_test.go b/command_test.go index 64afc0e..bc37d4a 100644 --- a/command_test.go +++ b/command_test.go @@ -3,6 +3,8 @@ package redant import ( "bytes" "context" + "os" + "path/filepath" "strings" "testing" ) @@ -32,6 +34,37 @@ func TestCommandBasic(t *testing.T) { } } +func TestCommandMeta(t *testing.T) { + cmd := &Command{ + Use: "test", + Metadata: map[string]string{ + "Mode": " agent ", + "agent.command": " true ", + }, + } + + if got := cmd.Meta("mode"); got != "agent" { + t.Fatalf("Meta(mode) = %q, want %q", got, "agent") + } + + if got := cmd.Meta(" AGENT.COMMAND "); got != "true" { + t.Fatalf("Meta(AGENT.COMMAND) = %q, want %q", got, "true") + } + + if got := cmd.Meta("missing"); got != "" { + t.Fatalf("Meta(missing) = %q, want empty", got) + } + + if got := cmd.Meta(" "); got != "" { + t.Fatalf("Meta(blank) = %q, want empty", got) + } + + var nilCmd *Command + if got := nilCmd.Meta("mode"); got != "" { + t.Fatalf("nil command Meta(mode) = %q, want empty", got) + } +} + func TestCommandWithFlags(t *testing.T) { tests := []struct { name string @@ -387,6 +420,258 @@ func TestEnvVarFlag(t *testing.T) { } } +func TestGlobalEnvFlagSetsOptionEnvAndRestores(t *testing.T) { + const envName = "REDANT_TEST_GLOBAL_ENV" + t.Setenv(envName, "original") + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env", envName+"=from-flag") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-flag" { + t.Errorf("value = %q, want %q", value, "from-flag") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvFileFlagSetsOptionEnvAndRestores(t *testing.T) { + const envName = "REDANT_TEST_ENV_FILE" + t.Setenv(envName, "original") + + tmpFile := filepath.Join(t.TempDir(), ".env") + err := os.WriteFile(tmpFile, []byte("# comment\nexport REDANT_TEST_ENV_FILE=from-file\n"), 0o600) + if err != nil { + t.Fatalf("write env file: %v", err) + } + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env file", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env-file", tmpFile) + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err = inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-file" { + t.Errorf("value = %q, want %q", value, "from-file") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvFileCSVAndEnvOrder(t *testing.T) { + const envName = "REDANT_TEST_ENV_FILES_ORDER" + t.Setenv(envName, "original") + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "a.env") + file2 := filepath.Join(tmpDir, "b.env") + if err := os.WriteFile(file1, []byte(envName+"=from-file1\n"), 0o600); err != nil { + t.Fatalf("write file1: %v", err) + } + if err := os.WriteFile(file2, []byte(envName+"=from-file2\n"), 0o600); err != nil { + t.Fatalf("write file2: %v", err) + } + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke( + "--env-file", file1+","+file2, + "--env", envName+"=from-env", + ) + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-env" { + t.Errorf("value = %q, want %q", value, "from-env") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvShorthandAndCSV(t *testing.T) { + const envName = "REDANT_TEST_GLOBAL_ENV_SHORT" + t.Setenv(envName, "original") + + var value string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value", + Description: "A value from env", + Value: StringOf(&value), + Envs: []string{envName}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("-e", "ANOTHER_KEY=123,"+envName+"=from-short-csv") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if value != "from-short-csv" { + t.Errorf("value = %q, want %q", value, "from-short-csv") + } + + if got := os.Getenv(envName); got != "original" { + t.Errorf("env %s after run = %q, want %q", envName, got, "original") + } +} + +func TestGlobalEnvShorthandRepeat(t *testing.T) { + const envA = "REDANT_TEST_SHORT_REPEAT_A" + const envB = "REDANT_TEST_SHORT_REPEAT_B" + t.Setenv(envA, "orig-a") + t.Setenv(envB, "orig-b") + + var valueA string + var valueB string + cmd := &Command{ + Use: "test", + Short: "Test command", + Options: OptionSet{ + { + Flag: "value-a", + Description: "A value from env A", + Value: StringOf(&valueA), + Envs: []string{envA}, + }, + { + Flag: "value-b", + Description: "A value from env B", + Value: StringOf(&valueB), + Envs: []string{envB}, + }, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("-e", envA+"=1", "-e", envB+"=2") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if valueA != "1" { + t.Errorf("valueA = %q, want %q", valueA, "1") + } + if valueB != "2" { + t.Errorf("valueB = %q, want %q", valueB, "2") + } + + if got := os.Getenv(envA); got != "orig-a" { + t.Errorf("env %s after run = %q, want %q", envA, got, "orig-a") + } + if got := os.Getenv(envB); got != "orig-b" { + t.Errorf("env %s after run = %q, want %q", envB, got, "orig-b") + } +} + +func TestGlobalEnvFlagInvalidAssignment(t *testing.T) { + cmd := &Command{ + Use: "test", + Short: "Test command", + Handler: func(ctx context.Context, inv *Invocation) error { + return nil + }, + } + + inv := cmd.Invoke("--env", "INVALID") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err == nil { + t.Fatalf("expected error for invalid --env assignment") + } + + if !strings.Contains(err.Error(), "invalid --env value") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestDeprecatedFlag(t *testing.T) { var deprecated string @@ -509,3 +794,42 @@ func TestBusyboxArgv0DoesNotOverrideExplicitArgs(t *testing.T) { t.Fatalf("expected explicit args to win (bar), got %q", executed) } } + +func TestInternalArgsFlagOverridesParsedArgs(t *testing.T) { + var gotFirst string + var gotSecond string + + cmd := &Command{ + Use: "app", + Short: "test internal args flag", + Args: ArgSet{ + {Name: "first", Value: StringOf(&gotFirst)}, + {Name: "second", Value: StringOf(&gotSecond)}, + }, + Handler: func(ctx context.Context, inv *Invocation) error { + if len(inv.Args) != 2 { + t.Fatalf("inv.Args length = %d, want 2", len(inv.Args)) + } + if inv.Args[0] != "from-flag-1" || inv.Args[1] != "from-flag-2" { + t.Fatalf("inv.Args = %#v, want [from-flag-1 from-flag-2]", inv.Args) + } + return nil + }, + } + + inv := cmd.Invoke("from-cli-1", "from-cli-2", "--args", "from-flag-1", "--args", "from-flag-2") + inv.Stdout = &bytes.Buffer{} + inv.Stderr = &bytes.Buffer{} + + err := inv.Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if gotFirst != "from-flag-1" { + t.Fatalf("first arg value = %q, want %q", gotFirst, "from-flag-1") + } + if gotSecond != "from-flag-2" { + t.Fatalf("second arg value = %q, want %q", gotSecond, "from-flag-2") + } +} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md deleted file mode 100644 index 2a1a0e7..0000000 --- a/docs/CHANGELOG.md +++ /dev/null @@ -1,66 +0,0 @@ -# 变更日志 - -本文档记录项目的关键版本变化。 - -> 关联文档:[`文档索引`](INDEX.md) · [`设计文档`](DESIGN.md) · [`评估报告`](EVALUATION.md) - -## 版本演进图 - -```mermaid -flowchart LR - U[Unreleased 当前开发中] --> V005[v0.0.5 稳定性与可用性增强] - V004[v0.0.4 初始版本] --> V005[v0.0.5 稳定性与可用性增强] -``` - -## [Unreleased] - -> 推荐维护方式: -> -> - 使用 LLM 提示词自动更新:[`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md) -> - 建议通过 agent 提示词执行:`/changelog-maintenance draft|release` - -### 变更 - -- 将 `github.com/coder/pretty` 迁移为内部实现 `internal/pretty`,以消除上游停止维护带来的依赖风险。 -- `help.go` 改为使用内部导入路径:`github.com/pubgo/redant/internal/pretty`。 - -### 文档 - -- 新增内部维护文档:`internal/pretty/README.md`。 - -## [v0.0.5] - 2026-01-20 - -### 修复 - -- 修复 `Int64.Type()` 返回类型错误导致 `pflag.GetInt64()` 获取失败的问题。 -- 修复废弃标志告警重复显示的问题。 -- 修复子命令无法继承父命令标志的问题。 -- 修复 `Option.Default` 默认值未正确应用到实际值的问题。 - -### 新增 - -- 增加命令执行相关单元测试(`command_test.go`)。 -- 增加标志值类型相关单元测试(`flags_test.go`)。 -- 增加框架评估文档(`docs/EVALUATION.md`)。 - -### 变更 - -- 优化目录结构,分离核心框架与命令实现。 -- 将补全命令移动到 `cmds/completioncmd`。 -- 移除配置文件与热更新相关能力,以保持框架简洁。 -- 优化 `--list-commands` 输出格式:去掉冗余标题、增强参数展示。 -- 优化 `--list-flags` 输出格式:精简子命令路径展示。 -- 增强全局标志显示:根命令中非隐藏标志可统一展示为全局标志。 - -## [v0.0.4] - 2025-12-24 - -### 新增 - -- 发布 Redant 初始版本。 -- 支持命令树结构与多级子命令。 -- 支持命令行标志与环境变量多来源配置。 -- 支持中间件链式编排。 -- 支持自动帮助系统。 -- 支持多格式参数:位置参数、查询串、表单、JSON。 -- 支持统一全局标志管理。 -- 提供示例工程与基础测试。 diff --git a/docs/CHANGELOG_LLM_PROMPT.md b/docs/CHANGELOG_LLM_PROMPT.md index d603a7b..6dc3231 100644 --- a/docs/CHANGELOG_LLM_PROMPT.md +++ b/docs/CHANGELOG_LLM_PROMPT.md @@ -4,22 +4,22 @@ ## 使用目标 -- 基于当前代码改动自动更新 `docs/CHANGELOG.md` 的 `Unreleased` 区域。 -- 发布前将 `Unreleased` 落版为 `.version/VERSION` 对应版本。 +- 基于当前代码改动自动更新 `.version/changelog/Unreleased.md`。 +- 发布前将 `Unreleased.md` 落版为 `.version/VERSION` 对应版本文件。 ## 模板 A:开发阶段(自动更新 Unreleased) 将以下提示词完整复制给 LLM: ```text -你是本仓库的 Changelog 维护助手。请根据当前工作区改动,自动更新 docs/CHANGELOG.md 的 [Unreleased] 区域。 +你是本仓库的 Changelog 维护助手。请根据当前工作区改动,自动更新 .version/changelog/Unreleased.md。 请严格执行: 1) 读取并理解以下文件: - .version/VERSION - - docs/CHANGELOG.md + - .version/changelog/Unreleased.md - 本次改动涉及的文件 diff(若可用) -2) 仅更新 docs/CHANGELOG.md 的 [Unreleased] 区域,不修改已发布版本历史。 +2) 仅更新 .version/changelog/Unreleased.md,不修改已发布版本文件。 3) 将变更归类到以下小节(不存在则创建): - 新增 - 修复 @@ -35,7 +35,7 @@ 7) 不杜撰内容,只基于可见改动生成。 输出要求: -- 直接给出对 docs/CHANGELOG.md 的修改结果(或补丁)。 +- 直接给出对 .version/changelog/Unreleased.md 的修改结果(或补丁)。 - 若某小节没有内容,写“暂无”。 ``` @@ -44,21 +44,22 @@ 将以下提示词完整复制给 LLM: ```text -你是本仓库的 Release Changelog 助手。请把 docs/CHANGELOG.md 中的 [Unreleased] 落版为 .version/VERSION 对应版本。 +你是本仓库的 Release Changelog 助手。请把 .version/changelog/Unreleased.md 落版为 .version/VERSION 对应版本文件。 请严格执行: 1) 读取: - .version/VERSION(例如 v0.0.6) - - docs/CHANGELOG.md + - .version/changelog/Unreleased.md 2) 在 changelog 中执行: - - 将 [Unreleased] 的现有内容迁移到新版本块: - ## [] - - - 在顶部重建新的 [Unreleased] 模板,包含四个小节:新增/修复/变更/文档,内容先写“暂无”。 -3) 不修改历史发布块内容顺序,不改写历史语义。 + - 创建新版本文件:`.version/changelog/.md`,格式为: + # [] - + - 将 `Unreleased.md` 的内容迁移到该版本文件(分类保持:新增/修复/变更/文档)。 + - 将 `Unreleased.md` 重建为空模板(四个分类,内容写“暂无”)。 +3) 不改写历史版本文件语义,不重排已发布版本顺序。 4) 保持现有 Markdown 风格与中文术语风格一致。 输出要求: -- 直接给出 docs/CHANGELOG.md 的修改结果(或补丁)。 +- 直接给出 `.version/changelog/.md` 与 `.version/changelog/Unreleased.md` 的修改结果(或补丁)。 ``` ## 推荐工作流 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f7b737c..608054b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -9,7 +9,7 @@ Redant 的目标是提供一套可组合、可测试、可扩展的命令行框 - 命令执行链路的可观测与可扩展 - 帮助信息、补全、测试支持的一体化 -> 关联文档:[`README`](../README.md) · [`评估报告`](EVALUATION.md) · [`变更日志`](CHANGELOG.md) +> 关联文档:[`README`](../README.md) · [`评估报告`](EVALUATION.md) · [`变更日志`](../.version/changelog/README.md) ## 2. 总体架构 @@ -77,6 +77,7 @@ flowchart TD 关键点: - 参数解析发生在命令定位与标志合并之后。 +- 非 `RawArgs` 模式下,若设置隐藏内部标志 `--args`,则在参数解析前用其值覆盖 `inv.Args`(支持重复与 CSV)。 - `RawArgs=true` 时,命令自行处理参数;框架不做常规标志解析。 - 对于复杂参数场景,建议在处理器中显式调用 `ParseQueryArgs`、`ParseFormArgs`、`ParseJSONArgs`。 @@ -120,14 +121,56 @@ stateDiagram-v2 ## 5. 模块职责 -| 模块 | 主要文件 | 说明 | -| -------------- | ---------------------- | ---------------------------------- | -| 命令系统 | `command.go` | 命令树、命令查找、执行流程 | -| 选项系统 | `option.go` | 标志定义、FlagSet 构建 | -| 参数系统 | `args.go` | 多格式参数解析(查询串/表单/JSON) | -| 值类型系统 | `flags.go` | 自定义 `pflag.Value` 类型集合 | -| 帮助系统 | `help.go` / `help.tpl` | 帮助渲染、命令与标志展示 | -| 中间件与处理器 | `handler.go` | 执行链组装与业务回调 | +| 模块 | 主要文件 | 说明 | +| -------------- | ------------------------------------ | ----------------------------------------------- | +| 命令系统 | `command.go` | 命令树、命令查找、执行流程 | +| 选项系统 | `option.go` | 标志定义、FlagSet 构建 | +| 参数系统 | `args.go` | 多格式参数解析(查询串/表单/JSON) | +| 值类型系统 | `flags.go` | 自定义 `pflag.Value` 类型集合 | +| 帮助系统 | `help.go` / `help.tpl` | 帮助渲染、命令与标志展示 | +| 中间件与处理器 | `handler.go` | 执行链组装与业务回调 | +| MCP 集成 | `internal/mcpserver` + `cmds/mcpcmd` | 命令树到 MCP Tools 的映射与 stdio 服务 | +| Web 控制台 | `cmds/webcmd` + `internal/webui` | 可视化命令调试、调用过程展示与执行回放 | +| WebTTY | `cmds/webttycmd` | 最简本地 Web 终端、文件上传/下载与 PTY 信号转发 | + +### 5.1 Web 调用过程重建(可观测性) + +`internal/webui` 在执行前会根据命令元数据与页面输入重建调用参数: + +1. 组装 `argv`(命令路径 + flags + args/rawArgs)。 +2. 生成单行 `invocation`(用于后端日志与兼容输出)。 +3. 返回 `program + argv` 给前端,前端按 token 渲染为多行 CLI(`\` 续行)。 + +这样可以同时满足: + +- 后端保留稳定的一行调用表示; +- 前端获得可读性更高、便于人工核对的长命令展示。 + +### 5.2 WebTTY 交互终端(最简独立实现) + +`webtty` 与 `web` 控制台分离,定位是“最小可用本地终端”,不复用 `internal/webui`。 + +```mermaid +flowchart LR + UI[浏览器 xterm.js] -->|WebSocket| WS[/ws] + WS --> PTY[PTY 桥接] + PTY --> SH[本地 shell] + + UI -->|multipart| UP[/upload] + UI -->|GET| LS[/api/files] + UI -->|GET| DL[/download] + + UP --> FS[(工作目录)] + LS --> FS + DL --> FS +``` + +关键点: + +- 终端控制键(`Ctrl+C/Ctrl+Z`)优先走前台进程组信号转发,失败再回退原始字节写入。 +- 上传/下载路径限制在工作目录内,禁止 `..` 越界与绝对路径。 +- 前端按“单能力渐进增强”策略演进(拖拽、批量、并发、调度策略、总进度、取消、重试等)。 +- 会话层支持前端自动重连(指数退避)与手动重连按钮;当前为“连接恢复”,后续可扩展为“同会话恢复”。 ## 6. Busybox 风格 argv0 分发 @@ -180,9 +223,12 @@ sequenceDiagram - 自定义中间件:包装 `HandlerFunc` 实现统一鉴权、日志、超时控制。 - 自定义帮助模板:修改 `help.tpl`。 - 新增子命令:扩展 `Command.Children`。 +- MCP 暴露:挂载 `mcp` 子命令并复用现有命令执行链路对外提供 Tools。 ## 8. 文档关联 - 上游:[`README`](../README.md) 提供入口与使用视图。 - 同级:[`EVALUATION.md`](EVALUATION.md) 提供质量视图。 +- 同级:[`MCP.md`](MCP.md) 提供 MCP 子命令、Schema 与调用协议说明。 +- 同级:[`WEBTTY.md`](WEBTTY.md) 提供 WebTTY 能力说明与分阶段迭代路线。 - 下游:[`../example/args-test/README.md`](../example/args-test/README.md) 提供参数解析落地示例。 diff --git a/docs/DOCS_CATALOG.md b/docs/DOCS_CATALOG.md new file mode 100644 index 0000000..0cff931 --- /dev/null +++ b/docs/DOCS_CATALOG.md @@ -0,0 +1,40 @@ +# Redant 文档分类目录 + +本页用于按主题聚合文档,降低检索成本。默认从上到下阅读。 + +## 1) 快速上手 + +- [`USAGE_AT_A_GLANCE.md`](USAGE_AT_A_GLANCE.md):命令命名、参数形态、Flag 规则速览。 +- [`MCP.md`](MCP.md):MCP 子命令、工具映射、输入输出约定。 +- [`WEBTTY.md`](WEBTTY.md):WebTTY 能力、接口约定、排查建议。 + +## 2) 架构与质量 + +- [`DESIGN.md`](DESIGN.md):核心模型、解析流程、扩展点。 +- [`EVALUATION.md`](EVALUATION.md):质量评估、风险与优化建议。 + +## 3) PR 审查体系(聚合) + +- [`review/PR_REVIEW_RUBRIC.md`](review/PR_REVIEW_RUBRIC.md):分轮审查基线(含零输入自动全量模式)。 +- [`review/PR_COMMENT_TEMPLATE.md`](review/PR_COMMENT_TEMPLATE.md):PR 行级评论统一模板。 +- [`review/CODE_REVIEW_GUIDE_CN.md`](review/CODE_REVIEW_GUIDE_CN.md):问题分类字典与详细检查清单。 + +## 4) 发布与变更维护 + +- [`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md):changelog 自动维护提示词。 +- 版本日志目录:`.version/changelog/`(Unreleased 与版本落版记录)。 + +## 5) 仓库外延入口(按需) + +- 项目总览:`README.md` +- 示例:`example/args-test/README.md` +- 内部样式维护:`internal/pretty/README.md` + +--- + +## 建议阅读路径 + +1. 先看“快速上手”了解能力边界。 +2. 再看“架构与质量”建立实现心智模型。 +3. 涉及 PR 审查时进入“PR 审查体系(聚合)”。 +4. 发布前进入“发布与变更维护”。 \ No newline at end of file diff --git a/docs/EVALUATION.md b/docs/EVALUATION.md index 4dcc72a..9698c09 100644 --- a/docs/EVALUATION.md +++ b/docs/EVALUATION.md @@ -9,7 +9,7 @@ - 可测试性与可维护性 - 文档一致性与可读性 -> 关联文档:[`设计文档`](DESIGN.md) · [`变更日志`](CHANGELOG.md) · [`参数示例`](../example/args-test/README.md) +> 关联文档:[`设计文档`](DESIGN.md) · [`变更日志`](../.version/changelog/README.md) · [`参数示例`](../example/args-test/README.md) ## 2. 评估流程 @@ -69,5 +69,5 @@ stateDiagram-v2 ## 6. 版本关联建议 - 每次功能变化先更新 `DESIGN.md` 的流程或状态图。 -- 合并前更新 `CHANGELOG.md`,记录“新增/修复/变更”。 +- 合并前更新 `.version/changelog/Unreleased.md`,记录“新增/修复/变更/文档”。 - 复杂参数变化同步更新 `example/args-test/README.md`。 diff --git a/docs/INDEX.md b/docs/INDEX.md index 9cb452a..21d5094 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,33 +1,31 @@ # Redant 文档索引 -本文档用于建立全仓库文档之间的逻辑关系,建议按“总览 → 设计 → 评估 → 变更 → 示例”顺序阅读。 +本文档作为文档导航首页。详细分类请优先使用:[`DOCS_CATALOG.md`](DOCS_CATALOG.md)。 -## 文档关系图 +## 文档分层图 ```mermaid flowchart TD - A[README 总览] --> B[设计文档] - B --> C[评估报告] - C --> D[变更日志] - B --> E[参数解析示例] - D --> B + A[导航首页 INDEX] --> B[分类目录 DOCS_CATALOG] + B --> C[快速上手] + B --> D[架构与质量] + B --> E[PR 审查体系] + B --> F[发布与变更] ``` -## 阅读路径 +## 推荐入口 -1. [`../README.md`](../README.md):项目总体介绍、能力边界、快速开始。 -2. [`USAGE_AT_A_GLANCE.md`](USAGE_AT_A_GLANCE.md):子命令命名、参数形态与标志(Flag)规范速览。 -3. [`DESIGN.md`](DESIGN.md):核心模型、解析流程、状态机、扩展点。 -4. [`EVALUATION.md`](EVALUATION.md):当前质量评估、风险、优化建议。 -5. [`CHANGELOG.md`](CHANGELOG.md):版本增量变化,便于追踪设计演进。 -6. [`../example/args-test/README.md`](../example/args-test/README.md):参数解析实操样例。 -7. [`../internal/pretty/README.md`](../internal/pretty/README.md):内部样式库维护说明(依赖迁移与维护边界)。 -8. [`CHANGELOG_LLM_PROMPT.md`](CHANGELOG_LLM_PROMPT.md):基于 LLM 自动维护 changelog 的提示词模板。 +1. [`DOCS_CATALOG.md`](DOCS_CATALOG.md):按主题聚合后的单一入口(推荐)。 +2. [`USAGE_AT_A_GLANCE.md`](USAGE_AT_A_GLANCE.md):新同学快速建立 CLI 使用心智模型。 +3. [`review/PR_REVIEW_RUBRIC.md`](review/PR_REVIEW_RUBRIC.md):PR 审查流程基线与轮次规则。 +4. [`DESIGN.md`](DESIGN.md):涉及实现变更时优先查阅。 + +> 说明:为保持主仓聚焦,`agentline` 与 `copilot-demo` 相关模块/示例已迁移到独立项目维护;本索引仅覆盖 `redant` 主仓当前内容。 ## 维护约定 - 新增模块时:先更新 `DESIGN.md`,再补充对应示例文档。 -- 变更行为时:同步更新 `CHANGELOG.md` 与 `EVALUATION.md` 的风险项。 +- 变更行为时:同步更新 `.version/changelog/Unreleased.md` 与 `EVALUATION.md` 的风险项。 - 文档统一使用中文,并优先使用 Mermaid 图表达流程、结构与状态。 - 外部依赖迁移到内部实现时,需补充对应内部模块维护文档(如 `internal/*/README.md`)。 diff --git a/docs/MCP.md b/docs/MCP.md new file mode 100644 index 0000000..a557fa0 --- /dev/null +++ b/docs/MCP.md @@ -0,0 +1,216 @@ +# Redant MCP 集成指南 + +本文档用于补全 Redant 在 Model Context Protocol(MCP)方向的使用说明,覆盖: + +1. 如何在项目中挂载 `mcp` 子命令 +2. `mcp list` / `mcp serve` 的用法 +3. 命令树映射为 MCP tools 的规则 +4. `tools/call` 的输入与输出结构 +5. 常见问题排查 + +## 1. 快速接入 + +在你的根命令初始化处挂载: + +```go +mcpcmd.AddMCPCommand(rootCmd) +``` + +挂载后会新增命令树: + +```text +app mcp list +app mcp serve --transport stdio +``` + +## 2. 命令说明 + +### 2.1 `mcp list` + +用于查看当前命令树映射出的 MCP tools 元信息。 + +- 默认格式:JSON +- 可选格式:`--format text` + +示例: + +```text +app mcp list +app mcp list --format text +``` + +输出包含: + +- `name`:tool 名称(例如 `echo`、`group.run`) +- `description`:命令短/长描述拼接 +- `path`:命令路径切片 +- `inputSchema`:调用输入 Schema +- `outputSchema`:调用输出 Schema + +### 2.2 `mcp serve` + +启动 MCP Server 并对外暴露 tools。 + +当前支持: + +- `--transport stdio`(默认) + +示例: + +```text +app mcp serve +app mcp serve --transport stdio +``` + +> 当前实现仅支持 `stdio`,其他 transport 会返回 `unsupported mcp transport`。 + +## 3. 工具映射规则 + +Redant 会遍历命令树,将可执行命令(有 Handler)映射为 MCP tool。 + +映射规则如下: + +1. **命令可见性**:隐藏命令(`Hidden=true`)不会暴露。 +2. **工具命名**:按命令路径用 `.` 拼接(例如 `group.run`)。 +3. **标志继承**:会合并父命令标志;子命令可使用继承标志。 +4. **标志过滤**:隐藏标志与系统标志不会出现在 schema(如 `help`、`list-commands`、`list-flags`、`args`)。 +5. **参数 schema**: + - 命令定义了 `ArgSet`:`arguments.args` 为对象(按参数名传值)。 + - 未定义 `ArgSet`:`arguments.args` 为数组(按位置传值)。 + +### 3.1 映射关系图 + +```mermaid +flowchart TD + A[Command Tree] --> B{命令是否隐藏} + B -- 是 --> C[跳过] + B -- 否 --> D{是否有 Handler} + D -- 否 --> E[仅继续遍历子节点] + D -- 是 --> F[生成 MCP Tool] + F --> G[Name=路径点分,如 group.run] + F --> H[InputSchema=flags+args] + F --> I[OutputSchema=ok/stdout/stderr/error/combined] +``` + +## 4. `tools/call` 输入结构 + +`arguments` 顶层结构: + +```json +{ + "flags": {"...": "..."}, + "args": {"...": "..."} +} +``` + +或(命令无 `ArgSet` 时): + +```json +{ + "flags": {"...": "..."}, + "args": ["pos1", "pos2"] +} +``` + +### 4.1 有 `ArgSet` 的示例 + +```json +{ + "name": "deploy", + "arguments": { + "flags": { + "stage": "dev", + "dry-run": true + }, + "args": { + "service": "api" + } + } +} +``` + +### 4.2 无 `ArgSet` 的示例 + +```json +{ + "name": "scan", + "arguments": { + "flags": { + "tags": ["x", "y"] + }, + "args": ["path-a", "path-b"] + } +} +``` + +## 5. `tools/call` 输出结构 + +Redant 返回 MCP 标准 `content`,同时通过 `structuredContent` 暴露结构化结果: + +```json +{ + "content": [{"type": "text", "text": "..."}], + "isError": false, + "structuredContent": { + "ok": true, + "stdout": "...", + "stderr": "...", + "error": "", + "combined": "..." + } +} +``` + +字段语义: + +- `ok`:命令是否执行成功。 +- `stdout` / `stderr`:标准输出与错误输出。 +- `error`:运行错误文本(成功时为空)。 +- `combined`:便于展示的合并输出。 + +## 6. 类型映射速览 + +| Redant 值类型 | InputSchema 类型 | +| -------------------- | ---------------------------------- | +| `string` | `string` | +| `bool` | `boolean` | +| `int` / `int64` | `integer` | +| `float` / `float64` | `number` | +| `string-array` | `array` | +| `enum[...]` | `string` + `enum` | +| `enum-array[...]` | `array` + `items.enum` | +| `struct[...]`/object | `object`(`additionalProperties`) | + +补充: + +- 对于 `Required=true` 的 flag,若已配置 `Default` 或 `Envs`,不会进入 schema 的 `required`。 +- 对于 `Required=true` 的 arg,若已配置 `Default`,不会进入 schema 的 `required`。 + +## 7. 常见问题排查 + +### 7.1 为什么某个命令没有出现在 tools 列表? + +优先检查: + +1. 命令是否 `Hidden=true` +2. 命令是否有 `Handler` +3. 是否正确挂载了 `mcpcmd.AddMCPCommand(rootCmd)` + +### 7.2 为什么调用报 `unknown flag`? + +`arguments.flags` 中存在工具 schema 未声明的字段。请以 `mcp list` 返回的 `inputSchema.properties.flags` 为准。 + +### 7.3 为什么参数报错 `arguments.args must be ...`? + +- 命令有 `ArgSet`:`arguments.args` 必须是对象。 +- 命令无 `ArgSet`:`arguments.args` 必须是数组。 + +### 7.4 结构化参数(object/struct)怎么传? + +可直接传对象,Redant 会在执行前序列化为 JSON 字符串传给命令值类型解析。 + +## 8. 相关文档 + +- 总览:[`../README.md`](../README.md) +- 设计:[`DESIGN.md`](DESIGN.md) +- 使用速览:[`USAGE_AT_A_GLANCE.md`](USAGE_AT_A_GLANCE.md) \ No newline at end of file diff --git a/docs/USAGE_AT_A_GLANCE.md b/docs/USAGE_AT_A_GLANCE.md index febbf53..16aee82 100644 --- a/docs/USAGE_AT_A_GLANCE.md +++ b/docs/USAGE_AT_A_GLANCE.md @@ -61,10 +61,30 @@ flowchart TD | 环境变量回退 | `GIT_AUTHOR=alice app repo commit` | `Envs` 配置生效 | | 默认值 | 未传值时自动应用 | 由 `Default` 指定 | +内建全局标志: + +- `--env, -e KEY=VALUE`:设置环境变量(支持重复与 CSV)。 +- `--env-file FILE`:从 env 文件加载环境变量(支持重复与 CSV)。 +- `--args VALUE`:内部隐藏标志;支持重复与 CSV,用于覆盖命令位置参数。 + +快速示例: + +```text +app demo -e A=1 -e B=2 +app demo --env A=1,B=2 +app demo --env-file .env +app demo --env-file .env --env-file .env.local +app demo --env-file .env,.env.local +app demo --args first --args second +app demo --args first,second +``` + +说明:`--args` 为内部能力,默认不会出现在帮助信息与 `--list-flags` 输出中。 + ## 4) 通用输入模板 ```text -app [command[:sub-command] | command sub-command] [args...] [flags...] +app [command[:sub-command] | command sub-command] [flags...] [args...] ``` 示例: @@ -73,7 +93,24 @@ app [command[:sub-command] | command sub-command] [args...] [flags...] app repo commit "message=feat&sign=true" --author=alice --sign ``` -## 5) 解析优先级 +补充说明: + +- 非 `RawArgs` 模式下,`[flags...]` 与 `[args...]` 的先后位置均可解析。 +- 为减少与子命令名称冲突的歧义,推荐使用“先 flags,后 args”的写法。 + +## 5) Web 调用过程展示约定 + +`web` 子命令执行后,页面中的“调用过程”会展示两部分: + +1. `curl` 风格请求(便于复现 API 调用)。 +2. CLI 多行命令(`\` 续行,便于检查长参数链路)。 + +其中 CLI 视图默认将 Args 放在末尾,并附加注释行用于核对命名参数映射: + +- `# args: version=v0.0.1-alpha.10` +- `# rawArgs: [v0.0.1-alpha.10]` + +## 6) 解析优先级 ```mermaid flowchart TD @@ -96,7 +133,7 @@ flowchart TD 3. 根命令 4. 标志与参数解析 -## 6) 最小实现示例(命令、参数与标志) +## 7) 最小实现示例(命令、参数与标志) ```go root := &redant.Command{Use: "app"} diff --git a/docs/WEBTTY.md b/docs/WEBTTY.md new file mode 100644 index 0000000..aaf8f04 --- /dev/null +++ b/docs/WEBTTY.md @@ -0,0 +1,89 @@ +# WebTTY 最简终端文档 + +本文档说明 `webtty` 子命令的当前能力、实现边界与后续迭代路线。目标是保持“最小可用 + 可持续演进”,按一次一个能力推进。 + +## 1. 当前能力(已实现) + +- 启动本地 WebTTY:`webtty --addr 127.0.0.1:18081 --open=true|false` +- 基于 `WebSocket + PTY` 暴露本地 shell(`/ws`) +- 终端输入/输出桥接与窗口 resize 同步 +- 控制键处理:`Ctrl+C`、`Ctrl+Z` 优先走信号转发(失败时回退原始字节写入) +- 文件上传: + - 点击上传与拖拽上传 + - 多文件批量上传 + - 可配置并发上传与总进度 + - 上传调度策略(FIFO/小文件优先/大文件优先) + - 支持指定相对目录 `dir` + - 上传进度展示与失败重试 + - 支持单文件取消与全部取消 +- 文件下载: + - `GET /api/files?dir=...` 列目录 + - `GET /download?path=...` 下载文件 + - `GET /download-zip?dir=...` 目录打包下载(zip) +- 会话重连: + - 前端自动重连(指数退避) + - 支持手动“重连会话” + +## 2. 接口概览 + +| 路径 | 方法 | 用途 | +| --------------- | ---- | --------------------- | +| `/` | GET | WebTTY 前端页面 | +| `/ws` | GET | 终端 WebSocket 通道 | +| `/upload` | POST | 文件上传(multipart) | +| `/api/files` | GET | 列出目录文件 | +| `/download` | GET | 下载单个文件 | +| `/download-zip` | GET | 打包下载目录(zip) | + +### 2.1 上传请求约定 + +- `Content-Type`: `multipart/form-data` +- 文件字段:`file`(支持多文件) +- 目录参数:`dir`(可选,相对路径) + +路径安全规则: + +- `dir` 必须是相对路径。 +- 禁止 `..` 越界与绝对路径。 +- 文件名会进行清洗,避免路径穿越与控制字符污染。 + +## 3. 架构简图 + +```mermaid +flowchart LR + B[浏览器 xterm.js] -->|ws| W[/ws] + W --> P[PTY master/slave] + P --> S[本地 shell] + + B -->|multipart| U[/upload] + U --> FS[(工作目录)] + + B -->|GET| L[/api/files] + B -->|GET| D[/download] + L --> FS + D --> FS +``` + +## 4. 迭代节奏(一次一个能力) + +已完成阶段(MVP+): + +1. 终端会话打通(`/` + `/ws`) +2. 上传能力(点击、拖拽、批量、目录、进度、重试) +3. 上传能力增强(并发上传、总进度、单文件/全部取消) +4. 上传调度能力(FIFO/小文件优先/大文件优先) +5. 下载能力(文件列表 + 单文件下载 + 目录 zip) +6. 会话重连(自动重连 + 手动重连) +7. 控制键信号可靠性(`Ctrl+C/Ctrl+Z`) + +建议后续阶段(按顺序逐项推进): + +1. 鉴权开关(本地 token)与访问来源限制 +2. 来源限制(Origin/Host 白名单) +3. 会话级恢复(重连后恢复到同一 shell 实例) + +## 5. 开发约定 + +- 每次只做一个能力点,避免横向大改。 +- 每次改动同时补最小回归测试。 +- 若改动到交互或流程,先更新 `docs/DESIGN.md`,再更新本文件。 diff --git a/docs/review/CODE_REVIEW_GUIDE_CN.md b/docs/review/CODE_REVIEW_GUIDE_CN.md new file mode 100644 index 0000000..be96af1 --- /dev/null +++ b/docs/review/CODE_REVIEW_GUIDE_CN.md @@ -0,0 +1,673 @@ +# 代码审查综合指南 + +> 结合最佳实践和问题分类检测的综合代码审查指南。 +> +> 本文档可供 GitHub Copilot、审查工具和开发团队参考使用。 + +## 目录 + +- [概述](#概述) +- [黄金法则](#黄金法则) +- [审查流程指南](#审查流程指南) +- [问题分类速查表](#问题分类速查表) +- [详细分类检查清单](#详细分类检查清单) +- [PR提交检查清单](#pr提交检查清单) +- [使用指南](#使用指南) + +--- + +## 概述 + +### 目的 + +代码审查主要有两个目的: +1. **发现缺陷**:测试通常能发现50-60%的问题,而执行良好的代码审查可以发现60-80%的缺陷。 +2. **提升质量**:确保软件具备六大关键特性:**可靠性、效率、可用性、可维护性、安全性和可移植性**。 + +### 范围 + +审查代码时,需要检查: +- 分配审查的每一行代码 +- 逻辑正确性 +- 设计决策 +- 代码质量和可维护性 + +--- + +## 黄金法则 + +| # | 法则 | 描述 | +| --- | -------------------------- | ------------------------------------ | +| 1 | **简单、高效、安全的设计** | 避免过度设计;优先考虑清晰性和安全性 | +| 2 | **严格的完整性和性能** | 确保数据完整性,按需优化 | +| 3 | **合理的模块化** | 模块内高内聚,模块间低耦合 | +| 4 | **减少重复** | DRY原则 - 不要重复自己 | +| 5 | **良好的命名和注释** | 清晰、描述性的名称;有意义的注释 | + +### 审查基准 + +> **目标:每100行代码(LOC)至少发现1个逻辑问题** + +--- + +## 审查流程指南 + +### 审查前 + +1. 理解上下文和需求 +2. 审查相关文档和设计文档 +3. 检查是否包含测试 + +### 审查中 + +1. 仔细阅读PR描述 +2. 系统性地检查所有变更文件 +3. 验证逻辑流程和边界情况 +4. 寻找问题模式(见下文分类) +5. 考虑安全影响 +6. 检查性能问题 + +### 审查后 + +1. 提供建设性反馈 +2. 建议具体改进方案 +3. 只有在所有关键问题解决后才批准 + +--- + +## 问题分类速查表 + +| 分类 | 代码 | 优先级 | 描述 | +| ---------- | ------- | ------ | ----------------- | +| 需求不匹配 | `REQ` | 🔴 关键 | 实现与需求不符 | +| 逻辑问题 | `LOGI` | 🔴 关键 | 阻止正确执行的Bug | +| 安全问题 | `SEC` | 🔴 关键 | 漏洞和安全风险 | +| 认证/授权 | `AUTH` | 🔴 关键 | 访问控制问题 | +| 设计问题 | `DSN` | 🟠 高 | 架构和设计问题 | +| 健壮性 | `RBST` | 🟠 高 | 错误处理和容错 | +| 事务问题 | `TRANS` | 🟠 高 | 数据库事务问题 | +| 并发问题 | `CONC` | 🟠 高 | 多线程问题 | +| 性能问题 | `PERF` | 🟠 高 | 资源效率问题 | +| 兼容性 | `CPT` | 🟡 中 | 版本和环境兼容性 | +| 幂等性 | `IDE` | 🟡 中 | 重复操作安全性 | +| 可维护性 | `MAIN` | 🟡 中 | 长期维护问题 | +| 耦合问题 | `CPL` | 🟡 中 | 模块间依赖 | +| 可读性 | `READ` | 🟢 普通 | 代码清晰度问题 | +| 简洁性 | `SIMPL` | 🟢 普通 | 不必要的复杂性 | +| 一致性 | `CONS` | 🟢 普通 | 风格和命名一致性 | +| 重复代码 | `DUP` | 🟢 普通 | 重复的代码/逻辑 | +| 命名问题 | `NAM` | 🟢 普通 | 变量/函数命名 | +| 文档字符串 | `DOCS` | 🟢 普通 | 文档注释 | +| 注释问题 | `COMM` | 🔵 低 | 行内注释 | +| 日志问题 | `LOGG` | 🔵 低 | 日志语句 | +| 错误消息 | `ERR` | 🔵 低 | 错误定义 | +| 格式问题 | `FOR` | 🔵 低 | 代码格式 | +| 语法问题 | `GRAM` | 🔵 低 | 文本语法问题 | +| 最佳实践 | `PRAC` | 🔵 低 | 规范违反 | +| PR描述 | `PR` | 🔵 低 | PR文档 | + +--- + +## 详细分类检查清单 + +### 🔴 关键问题 + +#### 需求不匹配 (REQ) + +**定义**:代码实现与需求/文档不符。 + +**检查清单**: +- [ ] 代码实现与需求/文档一致 +- [ ] 所有规定的行为都已实现 +- [ ] 需求中的边界情况已处理 +- [ ] 考虑了失败路径,不仅仅是"正常路径" +- [ ] 假设条件有文档记录并已验证 + +**示例**: +``` +[REQ] 文档说明为A,但实现为B +[REQ] 定义了"正常路径",但忽略了失败路径 +[REQ] 需求指定30秒超时,但代码使用10秒 +``` + +#### 逻辑问题 (LOGI) + +**定义**:任何阻止软件按预期运行的问题。 + +**检查清单**: +- [ ] 无空指针引用 +- [ ] 无除零可能性 +- [ ] 数组/列表索引边界已检查 +- [ ] 无无限递归可能性 +- [ ] 所有if-else分支完整 +- [ ] 控制流正确关闭(if-else if有else) +- [ ] 表单/输入数据已验证 +- [ ] 循环终止条件正确 + +**示例**: +``` +[LOGI] 未检查user是否为null就访问user.name +[LOGI] if-else if语句缺少else分支处理其他情况 +[LOGI] 数组索引访问未进行边界检查 +[LOGI] 无限递归导致栈溢出 +``` + +#### 安全问题 (SEC) + +**定义**:防止漏洞,保护免受未授权访问、数据泄露、篡改或中断等威胁。 + +**检查清单**: +- [ ] 无SQL注入漏洞 +- [ ] 无XSS(跨站脚本)漏洞 +- [ ] 无缓冲区溢出风险 +- [ ] 无硬编码凭证或密钥 +- [ ] 正确的认证检查 +- [ ] 数据库中无明文凭证 +- [ ] 安全的数据传输(HTTPS、加密) +- [ ] 所有用户输入都有验证 +- [ ] 正确的输出编码 + +**示例**: +``` +[SEC] SQL注入:查询直接拼接用户输入 +[SEC] 硬编码凭证:password = "admin123" +[SEC] XSS漏洞:用户输入未转义直接渲染 +[SEC] 数据库中存储明文凭证 +``` + +#### 认证/授权 (AUTH) + +**定义**:与身份验证和访问控制相关的问题。 + +**检查清单**: +- [ ] 需要认证的地方都有认证 +- [ ] 授权检查到位 +- [ ] 基于角色的访问控制正确实现 +- [ ] 会话管理安全 +- [ ] Token正确验证 + +**示例**: +``` +[AUTH] API端点无需认证即可访问 +[AUTH] 管理员功能缺少角色验证 +[AUTH] JWT令牌使用前未验证 +``` + +### 🟠 高优先级问题 + +#### 设计问题 (DSN) + +**定义**:设计/实现应该简单、可用、安全、可靠、可维护、可扩展和高效。 + +**检查清单**: +- [ ] 设计简单直接 +- [ ] 设计可用且直观 +- [ ] 设计默认安全 +- [ ] 设计可靠 +- [ ] 设计可维护 +- [ ] 设计可扩展 +- [ ] 设计高效 +- [ ] 工作流不过于复杂 + +**示例**: +``` +[DSN] 设计过于复杂:工作流有不必要的步骤 +[DSN] 设计不高效:多次处理数据 +[DSN] 设计不灵活:难以扩展新需求 +``` + +#### 健壮性 (RBST) + +**定义**:系统在不崩溃的情况下优雅地处理错误、意外输入或压力条件的能力。 + +**检查清单**: +- [ ] 异常被适当捕获和处理 +- [ ] 无效数据被阻止影响系统 +- [ ] 系统在组件故障时继续运行 +- [ ] 系统能在故障后恢复到稳定状态 +- [ ] 适当的地方实现了优雅降级 + +**示例**: +``` +[RBST] 异常未捕获:网络错误时应用崩溃 +[RBST] 无效输入未验证:导致下游错误 +[RBST] 外部服务不可用时无回退机制 +``` + +#### 事务问题 (TRANS) + +**定义**:事务是作为单个逻辑工作单元执行的操作序列。 + +**检查清单**: +- [ ] 事务边界正确定义 +- [ ] 异常时事务回滚 +- [ ] 避免长时间运行的事务 +- [ ] 有死锁预防措施 +- [ ] 需要数据完整性时使用事务 + +**示例**: +``` +[TRANS] 多步操作缺少事务边界 +[TRANS] 异常时事务未回滚 +[TRANS] 长时间运行的事务阻塞其他操作 +[TRANS] 锁顺序不一致导致死锁风险 +``` + +#### 并发问题 (CONC) + +**定义**:特定于多线程/多任务环境中发生的问题。 + +**检查清单**: +- [ ] 无竞态条件 +- [ ] 无死锁可能性 +- [ ] 正确使用锁 +- [ ] 需要时使用线程安全的数据结构 +- [ ] 需要时使用原子操作 + +**示例**: +``` +[CONC] 竞态条件:共享计数器未同步修改 +[CONC] 死锁:方法A和B以不同顺序获取锁 +[CONC] 多线程环境中使用非线程安全的集合 +``` + +#### 性能问题 (PERF) + +**定义**:不必要地过度消耗CPU、内存、磁盘、网络等资源。 + +**检查清单**: +- [ ] 使用高效算法(检查Big O复杂度) +- [ ] 无过度内存使用 +- [ ] 数据库查询已优化 +- [ ] 使用批量操作而非逐个处理 +- [ ] 数据库查询有适当索引 +- [ ] 无不必要的重复操作 +- [ ] 适当使用缓存 +- [ ] 无N+1查询问题 + +**示例**: +``` +[PERF] 使用O(n²)算法,而O(n log n)是可行的 +[PERF] 循环内数据库查询:应使用批量查询 +[PERF] 频繁过滤的列缺少索引 +[PERF] N+1查询:逐个加载关联实体 +``` + +### 🟡 中优先级问题 + +#### 兼容性 (CPT) + +**定义**:阻止软件与不同版本或环境正确交互的冲突。 + +**检查清单**: +- [ ] 保持向后兼容性 +- [ ] 考虑向前兼容性 +- [ ] API变更适当版本化 +- [ ] 数据库Schema变更迁移安全 +- [ ] 测试浏览器/操作系统兼容性 +- [ ] 库版本兼容 + +**示例**: +``` +[CPT] API字段删除破坏现有客户端 +[CPT] 新功能与旧浏览器版本不兼容 +[CPT] 库升级引入破坏性变更 +``` + +#### 幂等性 (IDE) + +**定义**:多次运行操作产生相同结果。 + +**检查清单**: +- [ ] 重复操作产生相同结果 +- [ ] 重试不产生重复记录 +- [ ] 删除操作处理已删除的情况 +- [ ] 支付/关键操作有幂等键 + +**示例**: +``` +[IDE] 同一订单下单两次产生重复记录 +[IDE] 第二次删除返回错误 +[IDE] 支付操作无幂等键 +``` + +#### 可维护性 (MAIN) + +**定义**:应用程序可被理解、修复或增强的程度。约75%的项目成本是维护! + +**检查清单**: +- [ ] 代码具有良好可读性 +- [ ] 代码模块化且逻辑分离 +- [ ] 复杂度保持较低(无深层嵌套) +- [ ] 代码可测试 +- [ ] 文档保持最新 +- [ ] 使用一致的编码模式 + +**示例**: +``` +[MAIN] 代码紧密耦合:变更需要修改多个文件 +[MAIN] 无单元测试:难以验证变更 +[MAIN] 文档过时:不反映当前行为 +``` + +#### 耦合问题 (CPL) + +**定义**:软件模块之间的相互依赖程度。期望低耦合高内聚。 + +**检查清单**: +- [ ] 无硬编码依赖 +- [ ] 下层不依赖上层 +- [ ] 组件依赖接口而非实现 +- [ ] 相关逻辑集中在一处 +- [ ] 无隐藏依赖(必须先调用A再调用B) +- [ ] 数据通过定义的接口传递,而非共享结构 + +**示例**: +``` +[CPL] 方法接收整个User对象但只使用userId +[CPL] 必须先调用init()再调用start(),但依赖未记录 +[CPL] 业务逻辑分散在多个不相关的模块中 +``` + +### 🟢 普通问题 + +#### 可读性 (READ) + +**定义**:清晰的代码结构、命名约定和文档。 + +**检查清单**: +- [ ] 变量名具有描述性(不是x1、temp、val2) +- [ ] 函数大小合理(不超过100行) +- [ ] 嵌套有限(最多3-4层) +- [ ] 正确的缩进和间距 +- [ ] 简单代码优于"聪明"的技巧 +- [ ] 逻辑清晰地分离到函数/模块中 + +**示例**: +``` +[READ] 变量名'd'不清晰,应该是'data'或更具体 +[READ] 函数超过150行,应该拆分成更小的函数 +[READ] 5层嵌套的if语句,应该重构 +``` + +#### 简洁性 (SIMPL) + +**定义**:设计和实现应尽可能简单,避免不必要的复杂性。 + +**检查清单**: +- [ ] 逻辑直接易懂 +- [ ] 每个函数/类有单一职责 +- [ ] 无过度设计或投机性功能 +- [ ] 删除未使用的代码和注释 +- [ ] 无不必要的通用设计 + +**示例**: +``` +[SIMPL] 简单问题使用过于通用的解决方案 +[SIMPL] 未使用的辅助函数应删除 +[SIMPL] 过早优化使代码难以理解 +``` + +#### 一致性 (CONS) + +**定义**:确保文档、命名、格式、逻辑、注释、日志等的一致性。 + +**检查清单**: +- [ ] 一致的命名约定(camelCase、snake_case等) +- [ ] 注释语言一致 +- [ ] 代码和注释同步 +- [ ] 代码和文档同步 +- [ ] 全文术语一致 + +**示例**: +``` +[CONS] 随意混用camelCase和snake_case +[CONS] 代码变了但注释仍描述旧行为 +[CONS] 代码和文档中对同一概念使用不同术语 +``` + +#### 重复代码 (DUP) + +**定义**:不同位置的重复代码/逻辑导致高耦合和低可维护性。 + +**检查清单**: +- [ ] 无复制粘贴的代码块 +- [ ] 重复表达式提取为变量 +- [ ] 公共逻辑提取为函数 +- [ ] 共享代码放在适当的工具类/辅助类中 + +**示例**: +``` +[DUP] 相同的验证逻辑在3个不同的地方复制粘贴 +[DUP] 重复表达式cameras[i].getStream().getResolution()应提取为变量 +``` + +#### 命名问题 (NAM) + +**定义**:名称应清晰、描述性但简洁、易于理解。 + +**检查清单**: +- [ ] 名称清晰且具有描述性 +- [ ] 名称简洁(不超过5个词) +- [ ] 避免模糊词如"data"、"info"、"stuff" +- [ ] 数组/列表使用复数名称(cameras、cameraList) +- [ ] 方法使用动词,类使用名词 +- [ ] 布尔变量命名为疑问形式(isActive、hasPermission) + +**示例**: +``` +[NAM] 变量'tp'太晦涩,应该是'timeoutPeriod' +[NAM] 数组'camera'应该是'cameras'或'cameraList' +[NAM] 方法'calculation()'应该是'calculate()' +``` + +#### 文档字符串 (DOCS) + +**定义**:解释函数、类或模块功能的特殊注释。 + +**检查清单**: +- [ ] 必要时函数/类有文档字符串 +- [ ] 参数有说明 +- [ ] 返回值有说明 +- [ ] 异常/副作用有说明 +- [ ] 文档字符串与代码同步 + +**示例**: +``` +[DOCS] 公共API方法缺少文档字符串 +[DOCS] 参数'options'未说明 +[DOCS] 返回值类型和含义未指定 +``` + +### 🔵 低优先级问题 + +#### 注释问题 (COMM) + +**定义**:应根据需要添加注释以帮助审查和维护。 + +**检查清单**: +- [ ] 复杂逻辑有解释性注释 +- [ ] 变通方案/技巧有上下文注释 +- [ ] 无注释掉的死代码 +- [ ] 注释清晰简洁 +- [ ] 性能权衡有解释 + +**示例**: +``` +[COMM] 复杂算法缺少解释注释 +[COMM] 变通方案缺少上下文:为什么需要这样做? +[COMM] 注释掉的代码应删除 +``` + +#### 日志问题 (LOGG) + +**定义**:按需记录日志,避免不必要或过多的日志。 + +**检查清单**: +- [ ] 错误条件有日志 +- [ ] 日志包含必要的上下文(ID、类型) +- [ ] 生产环境无过多调试日志 +- [ ] 使用适当的日志级别 +- [ ] 不记录敏感数据 + +**示例**: +``` +[LOGG] 错误情况未记录日志 +[LOGG] 日志消息缺少上下文:缺少请求ID +[LOGG] 调试日志留在生产代码中 +``` + +#### 错误消息 (ERR) + +**定义**:根据需要添加或定义错误,包含清晰、简洁的上下文。 + +**检查清单**: +- [ ] 错误消息具体且有帮助 +- [ ] 需要时定义错误代码/类型 +- [ ] 错误消息包含上下文 +- [ ] 错误被适当记录 + +**示例**: +``` +[ERR] 通用消息"出了点问题"缺少上下文 +[ERR] 面向客户端的API缺少错误代码 +[ERR] 错误消息未指示应采取的操作 +``` + +#### 格式问题 (FOR) + +**定义**:包括编码风格、格式和措辞。 + +**检查清单**: +- [ ] 无拼写错误 +- [ ] 一致的缩进 +- [ ] 无过多空行 +- [ ] 操作符周围有适当间距 +- [ ] 代码符合团队风格指南 + +**示例**: +``` +[FOR] 变量名拼写错误:'recieve'应该是'receive' +[FOR] 缩进不一致:混用制表符和空格 +[FOR] 语句之间空行过多 +``` + +#### 语法问题 (GRAM) + +**定义**:注释、错误消息和文档应语法正确。 + +**检查清单**: +- [ ] 注释语法正确 +- [ ] 错误消息正确书写 +- [ ] 文档句子完整 + +**示例**: +``` +[GRAM] 注释有拼写错误:"the user is login"应该是"the user is logged in" +[GRAM] 文档中句子不完整 +``` + +#### 最佳实践 (PRAC) + +**定义**:符合编程风格、约定、常见用例和团队约定的规则。 + +**检查清单**: +- [ ] 事件处理器遵循命名约定(onXxx) +- [ ] 文件按功能/模块组织 +- [ ] 文件夹结构遵循约定 +- [ ] 无容易造成困惑的模式 + +**示例**: +``` +[PRAC] 事件处理器'click'应命名为'onClick' +[PRAC] 工具文件放在错误的文件夹 +[PRAC] 误导性的变量名造成困惑 +``` + +#### PR描述 (PR) + +**定义**:PR描述应符合团队指南。 + +**检查清单**: +- [ ] PR描述清晰简洁 +- [ ] 链接设计文档(如适用) +- [ ] 链接相关票据/问题 +- [ ] 记录API变更 +- [ ] 列出测试点 +- [ ] 突出显示破坏性变更 + +**示例**: +``` +[PR] 缺少设计文档链接 +[PR] 未描述测试点 +[PR] 未突出显示破坏性变更 +``` + +--- + +## PR提交检查清单 + +提交PR前,请确保: + +1. [ ] **代码遵循指南**:良好的命名、高内聚、低耦合、最少重复 +2. [ ] **完成自审**:差异尽可能小 +3. [ ] **复杂代码有注释**:特别是难以理解的部分 +4. [ ] **日志包含上下文**:ID、reqId等 +5. [ ] **PR描述完整**:包含所有必要的链接 +6. [ ] **文档已更新**:做出相应的变更 +7. [ ] **测试已添加/更新**:证明修复有效或功能正常 +8. [ ] **本地所有测试通过**:新测试和现有测试 +9. [ ] **依赖变更已合并**:下游模块已更新 + +--- + +## 使用指南 + +### 对于审查者 + +1. **系统性审查**:按分类检查项目,避免遗漏 +2. **优先级排序**:首先关注关键和高优先级问题 +3. **明确标注**:使用分类代码如`[LOGI]`、`[SEC]` +4. **提供建议**:不仅指出问题,还要建议改进方案 + +### 对于GitHub Copilot + +协助代码审查时: +1. 识别问题时引用分类代码 +2. 解释问题及其分类 +3. 建议具体修复方案 +4. 考虑每个代码段的多个分类 + +#### 统一评论模板(与 PR_COMMENT_TEMPLATE 对齐) + +发布行级评论时,统一遵循 `docs/review/PR_COMMENT_TEMPLATE.md`,该文档为评论格式的单一事实来源(SSOT)。 + +最小约束如下: + +- 分类必须使用代码标签格式:`[分类代码] 分类名称`(如 `[LOGI] 逻辑问题`)。 +- 等级统一使用:`Blocker / Major / Minor / Nit`。 +- 发布前必须按 `path + line + 分类 + 模块 + 等级 + 问题摘要` 去重。 + +### 对于开发者 + +1. **自查**:提交PR前使用此清单 +2. **理解分类**:了解不同问题类型的严重程度 +3. **持续改进**:根据反馈改进编码习惯 + +### 对于团队 + +1. **定制化**:根据项目需求调整优先级和检查项 +2. **数据追踪**:监控问题频率以识别改进领域 +3. **培训材料**:用于新人入职和技能发展 + +--- + +## 参考资料 + +- [Google代码审查指南](https://google.github.io/eng-practices/review/reviewer/looking-for.html) +- 内部代码规范 +- 安全最佳实践 + +--- + +*最后更新:2026-01-22* \ No newline at end of file diff --git a/docs/review/PR_COMMENT_TEMPLATE.md b/docs/review/PR_COMMENT_TEMPLATE.md new file mode 100644 index 0000000..20508cd --- /dev/null +++ b/docs/review/PR_COMMENT_TEMPLATE.md @@ -0,0 +1,70 @@ +# PR 行级评论统一模板 + +用于在 GitHub PR 行级评论中统一表达问题,减少沟通噪音与误解。 + +## 基础模板(必须) + +```text +分类:<[分类代码] 分类名称> +模块:<所属模块> +等级: +问题:<一句话描述问题> +原因:<说明风险与影响面> +修改意见:<最小可执行修复建议> +``` + +## 字段说明 + +- 分类:用于标记问题类型,必须从 `docs/review/CODE_REVIEW_GUIDE_CN.md` 的“问题分类速查表”中选择,使用代码标签格式: + - `[REQ]` 需求不匹配 + - `[LOGI]` 逻辑问题 + - `[SEC]` 安全问题 + - `[AUTH]` 认证/授权 + - `[DSN]` 设计问题 + - `[RBST]` 健壮性 + - `[TRANS]` 事务问题 + - `[CONC]` 并发问题 + - `[PERF]` 性能问题 + - `[CPT]` 兼容性 + - `[IDE]` 幂等性 + - `[MAIN]` 可维护性 + - `[CPL]` 耦合问题 + - `[READ]` 可读性 + - `[SIMPL]` 简洁性 + - `[CONS]` 一致性 + - `[DUP]` 重复代码 + - `[NAM]` 命名问题 + - `[DOCS]` 文档字符串 + - `[COMM]` 注释问题 + - `[LOGG]` 日志问题 + - `[ERR]` 错误消息 + - `[FOR]` 格式问题 + - `[GRAM]` 语法问题 + - `[PRAC]` 最佳实践 + - `[PR]` PR描述 +- 模块:标记问题所属代码域,如 `command`、`args`、`env_preload`、`help`、`completion`、`docs`。 +- 等级:统一使用 `Blocker / Major / Minor / Nit`。 + +## 书写规范 + +- 默认使用中文。 +- 一条评论只说一个问题,避免把多个问题混在同一条。 +- “原因”应说明影响面(语义风险 / 兼容性 / 可维护性 / 测试缺口)。 +- “修改意见”要可落地,避免空泛建议。 + +## 示例 + +```text +分类:[CPT] 兼容性 +模块:args +等级:Blocker +问题:内部覆盖参数标志使用公开语义名 --args,存在与业务命令自定义 --args 冲突风险。 +原因:同名时会把业务输入误当内部协议,触发参数覆盖,导致既有参数解析语义被破坏。 +修改意见:改为保留内部命名(如 --__redant-internal-args),并仅在内部模式启用;同时补充冲突回归测试。 +``` + +## 去重规则(必须) + +- 发布前先查询 PR 现有评论,按 `path + line + 分类 + 模块 + 等级 + 问题摘要` 去重。 +- 命中同键评论时,不重复发布新评论,直接回传已有评论链接。 +- 仅当“问题摘要”发生实质变化时,才允许新增评论(建议在开头标注“补充说明”)。 \ No newline at end of file diff --git a/docs/review/PR_REVIEW_RUBRIC.md b/docs/review/PR_REVIEW_RUBRIC.md new file mode 100644 index 0000000..8a72ff7 --- /dev/null +++ b/docs/review/PR_REVIEW_RUBRIC.md @@ -0,0 +1,203 @@ +# PR 审查基线(Copilot 分轮审查) + +本文用于指导 Copilot 在 PR 审查时按固定轮次逐步检查,降低遗漏概率。 + +## 默认运行模式(零输入自动全量) + +- 当用户未提供任何参数时,默认自动识别“当前分支对应 PR”。 +- 默认审查模式为 `full-review`(全量覆盖整个 PR 变更模块),而不是仅看最新提交。 +- 仅在用户明确指定“只看增量/仅看最新提交”时,切换为 `incremental-review`。 +- 默认自动执行完整轮次:Round 0 -> Round 1 -> Round 2 -> Round 3 -> Round 4。 +- 默认覆盖: + - 变更涉及的所有模块(代码、测试、文档)。 + - 所有问题分类(如正确性、安全、性能、兼容性、可维护性、测试覆盖、文档一致性)。 +- 若用户提供特定轮次或指标,则在自动全量基线之上收敛范围。 + +## 使用目标 + +- 审查过程可重复、可追踪、可补漏。 +- 每一轮只关注一类问题,避免上下文过载。 +- 每轮输出必须包含:结论、证据、风险级别、后续动作。 + +## 审查输入(每轮都要声明) + +- PR 链接 / PR 编号(可为空;为空时自动识别当前分支对应 PR)。 +- 目标分支与对比分支(可自动推断)。 +- 业务背景与验收标准(若有)。 +- 本轮关注指标(可为空;为空时使用默认全量指标)。 + +## 审查轮次 + +### Round 0:范围与上下文确认 + +检查项: + +- 本次改动涉及哪些模块与文件。 +- 是否识别出行为变更点与非行为变更点。 +- 是否存在超出需求的改动。 + +通过门槛: + +- 给出文件级影响面清单。 +- 给出“高风险路径”列表(列出所有)。 +- 给出“模块覆盖矩阵”:模块 / 变更文件数 / 状态(已检查/未检查)/ 证据。 +- 若存在“未检查模块”,禁止进入 Round 4。 + +### Round 1:正确性与边界 + +检查项: + +- 核心逻辑是否满足需求。 +- 边界条件、空值、错误分支是否处理完整。 +- 与既有语义是否兼容(尤其 CLI 分发、flag/args/env 语义)。 + +通过门槛: + +- 每个高风险路径至少给出 1 条证据。 +- 标记阻断问题(Blocker)与非阻断问题(Major / Minor / Nit)。 +- 每个模块至少提供 1 条证据;高风险模块(command/args/env_preload/web/webtty/webui/mcp/completion)至少 2 条证据。 +- 若模块结论为“无问题”,必须提供低风险依据(如测试覆盖、边界保护、输入校验)。 + +### Round 2:测试覆盖与可验证性 + +检查项: + +- 是否新增/更新测试覆盖行为变化。 +- 是否包含关键边界与失败场景。 +- 测试命名、结构是否清晰(表驱动、子测试优先)。 + +通过门槛: + +- 明确“已覆盖 / 未覆盖”清单。 +- 对未覆盖项给出最小补测建议。 + +### Round 3:可维护性与一致性 + +检查项: + +- 是否存在无关重构或复杂度上升。 +- 命名、注释、模块边界是否一致。 +- 文档与变更记录是否同步(docs/changelog)。 + +通过门槛: + +- 给出技术债项(可延后)与必须本次解决项。 + +### Round 4:发布风险与合入建议 + +检查项: + +- 回滚策略与风险暴露面。 +- 兼容性、性能、稳定性风险。 +- 合入前必须完成事项(Checklist)。 + +通过门槛: + +- 给出最终建议:`Approve` / `Request changes` / `Comment`。 +- 给出 3 行内“合入条件摘要”。 +- 必须先通过以下硬门禁再给最终建议: + - `modules_total == modules_checked` + - 全分类清单完整(`已检查 / N/A` 均有标注) + - 未决 Blocker / Major 已显式统计 + +## 分轮与分类映射(覆盖 26 类) + +> 说明:以下为“主检查轮次”映射;并不限制跨轮补充。若某类与本 PR 无关,可标记为 `N/A`,但需显式说明。 + +| 分类代码 | Round 0 | Round 1 | Round 2 | Round 3 | Round 4 | +| -------- | ------- | ------- | ------- | ------- | ------- | +| `REQ` | 〇 | ✅ | 〇 | 〇 | 复核 | +| `LOGI` | 〇 | ✅ | 复核 | 〇 | 复核 | +| `SEC` | 〇 | ✅ | 〇 | 〇 | 复核 | +| `AUTH` | 〇 | ✅ | 〇 | 〇 | 复核 | +| `DSN` | 〇 | ✅ | 〇 | ✅ | 复核 | +| `RBST` | 〇 | ✅ | 〇 | ✅ | 复核 | +| `TRANS` | 〇 | ✅ | 〇 | 〇 | 复核 | +| `CONC` | 〇 | ✅ | 〇 | 〇 | 复核 | +| `PERF` | 〇 | ✅ | 〇 | 〇 | ✅ | +| `CPT` | 〇 | 复核 | 〇 | 〇 | ✅ | +| `IDE` | 〇 | ✅ | 复核 | 〇 | 复核 | +| `MAIN` | 〇 | 〇 | 〇 | ✅ | 复核 | +| `CPL` | 〇 | 〇 | 〇 | ✅ | 复核 | +| `READ` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `SIMPL` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `CONS` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `DUP` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `NAM` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `DOCS` | 〇 | 〇 | 〇 | ✅ | 复核 | +| `COMM` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `LOGG` | 〇 | ✅ | 〇 | ✅ | 复核 | +| `ERR` | 〇 | ✅ | 〇 | ✅ | 复核 | +| `FOR` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `GRAM` | 〇 | 〇 | 〇 | ✅ | 〇 | +| `PRAC` | 〇 | ✅ | 〇 | ✅ | 复核 | +| `PR` | 〇 | 〇 | ✅ | 〇 | ✅ | + +图例:`✅` 主检查轮次;`复核` 建议在该轮做一致性回看;`〇` 非主检查轮次。 + +## 全分类勾选清单(每次审查必须给出) + +> 要求:Round 4 结论前,必须输出以下清单;每项标记 `已检查 / N/A`,并在需要时附一句证据说明。 + +- [ ] `REQ` 需求不匹配 +- [ ] `LOGI` 逻辑问题 +- [ ] `SEC` 安全问题 +- [ ] `AUTH` 认证/授权 +- [ ] `DSN` 设计问题 +- [ ] `RBST` 健壮性 +- [ ] `TRANS` 事务问题 +- [ ] `CONC` 并发问题 +- [ ] `PERF` 性能问题 +- [ ] `CPT` 兼容性 +- [ ] `IDE` 幂等性 +- [ ] `MAIN` 可维护性 +- [ ] `CPL` 耦合问题 +- [ ] `READ` 可读性 +- [ ] `SIMPL` 简洁性 +- [ ] `CONS` 一致性 +- [ ] `DUP` 重复代码 +- [ ] `NAM` 命名问题 +- [ ] `DOCS` 文档字符串 +- [ ] `COMM` 注释问题 +- [ ] `LOGG` 日志问题 +- [ ] `ERR` 错误消息 +- [ ] `FOR` 格式问题 +- [ ] `GRAM` 语法问题 +- [ ] `PRAC` 最佳实践 +- [ ] `PR` PR 描述 + +## 输出格式(每轮固定) + +- 轮次: +- 结论: +- 证据(文件 + 片段): +- 问题分级:Blocker / Major / Minor / Nit +- 未决问题: +- 下一轮输入要求: + +Round 4 追加“门禁自检”字段(必须): + +- `modules_total`: +- `modules_checked`: +- `missing_modules`: +- `categories_total`: +- `categories_checked`: +- `unresolved_blockers`: +- `unresolved_majors`: + +若 `modules_total != modules_checked`,仅允许输出“未完成审查 + 所缺清单”,禁止给出最终结论。 + +> 自动模式下,若无阻塞项,“下一轮输入要求”应为“无”。 + +## 指标建议(可按项目调整) + +- 漏检率(后续人工发现问题数 / 代理已发现问题数)。 +- 阻断问题命中率(Blocker 命中占比)。 +- 审查轮次完成率(Round 0~4 是否完整执行)。 +- 建议采纳率(被采纳建议 / 总建议)。 + +## 执行约束 + +- 不允许跳轮合并结论。 +- 不允许无证据给结论。 +- 不确定项必须标记“待确认”,禁止拍脑袋判断。 \ No newline at end of file diff --git a/env_preload.go b/env_preload.go new file mode 100644 index 0000000..f88ba46 --- /dev/null +++ b/env_preload.go @@ -0,0 +1,256 @@ +package redant + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +type envSnapshot struct { + value string + existed bool +} + +// preloadEnvFromArgs scans global env-related flags from raw args, applies them +// to the process environment before normal flag parsing, and returns a restore +// function to avoid leaking state between invocations. +func preloadEnvFromArgs(args []string) (restore func() error, err error) { + snapshots := make(map[string]envSnapshot) + + defer func() { + if err != nil && len(snapshots) > 0 { + _ = restoreEnvSnapshots(snapshots) + } + }() + + setEnv := func(key, value string) error { + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("environment variable name cannot be empty") + } + if _, ok := snapshots[key]; !ok { + prev, existed := os.LookupEnv(key) + snapshots[key] = envSnapshot{value: prev, existed: existed} + } + return os.Setenv(key, value) + } + + for i := 0; i < len(args); i++ { + arg := strings.TrimSpace(args[i]) + if arg == "--" { + break + } + + if flagName, value, ok, parseErr := parseEnvFlagFromArgs(args, i); parseErr != nil { + return nil, parseErr + } else if ok { + if consumesNextArg(arg, flagName) { + i++ + } + + switch flagName { + case "env": + if err := applyEnvAssignmentsCSV(value, setEnv); err != nil { + return nil, fmt.Errorf("invalid --env value %q: %w", value, err) + } + case "env-file": + paths, err := readAsCSV(value) + if err != nil { + return nil, fmt.Errorf("parsing --env-file value %q: %w", value, err) + } + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + if err := loadEnvFile(path, setEnv); err != nil { + return nil, fmt.Errorf("loading --env-file entry %q: %w", path, err) + } + } + } + } + } + + if len(snapshots) == 0 { + return nil, nil + } + + restore = func() error { + return restoreEnvSnapshots(snapshots) + } + + return restore, nil +} + +func restoreEnvSnapshots(snapshots map[string]envSnapshot) error { + var merr error + for key, snap := range snapshots { + var err error + if snap.existed { + err = os.Setenv(key, snap.value) + } else { + err = os.Unsetenv(key) + } + merr = errors.Join(merr, err) + } + return merr +} + +func parseLongFlag(arg string) (name, value string, hasInlineValue, ok bool) { + if !strings.HasPrefix(arg, "--") { + return "", "", false, false + } + token := strings.TrimPrefix(arg, "--") + if token == "" { + return "", "", false, false + } + parts := strings.SplitN(token, "=", 2) + name = parts[0] + if len(parts) == 2 { + value = parts[1] + hasInlineValue = true + } + return name, value, hasInlineValue, true +} + +func parseShortEFlag(arg string) (value string, hasInlineValue, ok bool) { + if strings.HasPrefix(arg, "--") || !strings.HasPrefix(arg, "-e") { + return "", false, false + } + if arg == "-e" { + return "", false, true + } + if strings.HasPrefix(arg, "-e=") { + return strings.TrimPrefix(arg, "-e="), true, true + } + return strings.TrimPrefix(arg, "-e"), true, true +} + +func parseEnvFlagFromArgs(args []string, i int) (name, value string, ok bool, err error) { + arg := strings.TrimSpace(args[i]) + + if flagName, flagValue, hasInlineValue, parsed := parseLongFlag(arg); parsed { + switch flagName { + case "env", "env-file": + if !hasInlineValue { + if i+1 >= len(args) { + return "", "", false, fmt.Errorf("flag --%s requires a value", flagName) + } + flagValue = args[i+1] + } + return flagName, flagValue, true, nil + default: + return "", "", false, nil + } + } + + if flagValue, hasInlineValue, parsed := parseShortEFlag(arg); parsed { + if !hasInlineValue { + if i+1 >= len(args) { + return "", "", false, fmt.Errorf("flag -e requires a value") + } + flagValue = args[i+1] + } + return "env", flagValue, true, nil + } + + return "", "", false, nil +} + +func consumesNextArg(currentArg, flagName string) bool { + if strings.HasPrefix(currentArg, "--") { + return currentArg == "--"+flagName + } + if flagName == "env" { + return currentArg == "-e" + } + return false +} + +func parseEnvAssignment(raw string) (key, value string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("empty environment assignment") + } + idx := strings.Index(raw, "=") + if idx <= 0 { + return "", "", fmt.Errorf("expected KEY=VALUE") + } + key = strings.TrimSpace(raw[:idx]) + value = strings.TrimSpace(raw[idx+1:]) + if key == "" { + return "", "", fmt.Errorf("environment variable name cannot be empty") + } + return key, value, nil +} + +func applyEnvAssignmentsCSV(raw string, setEnv func(key, value string) error) error { + entries, err := readAsCSV(raw) + if err != nil { + return err + } + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + key, value, err := parseEnvAssignment(entry) + if err != nil { + return err + } + if err := setEnv(key, value); err != nil { + return err + } + } + return nil +} + +func loadEnvFile(path string, setEnv func(key, value string) error) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("env file path is empty") + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + content := strings.ReplaceAll(string(data), "\r\n", "\n") + lines := strings.Split(content, "\n") + for i, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + + key, value, err := parseEnvAssignment(line) + if err != nil { + return fmt.Errorf("%s:%d: %w", path, i+1, err) + } + value = normalizeEnvValue(value) + if err := setEnv(key, value); err != nil { + return fmt.Errorf("%s:%d: %w", path, i+1, err) + } + } + return nil +} + +func normalizeEnvValue(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { + return v[1 : len(v)-1] + } + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + if unquoted, err := strconv.Unquote(v); err == nil { + return unquoted + } + } + return v +} diff --git a/env_preload_test.go b/env_preload_test.go new file mode 100644 index 0000000..5791844 --- /dev/null +++ b/env_preload_test.go @@ -0,0 +1,449 @@ +package redant + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseShortEFlag(t *testing.T) { + tests := []struct { + name string + arg string + wantValue string + wantInline bool + wantParsedFlag bool + }{ + {name: "short without inline", arg: "-e", wantValue: "", wantInline: false, wantParsedFlag: true}, + {name: "short attached value", arg: "-eA=1", wantValue: "A=1", wantInline: true, wantParsedFlag: true}, + {name: "short equals value", arg: "-e=A=1", wantValue: "A=1", wantInline: true, wantParsedFlag: true}, + {name: "other short flag", arg: "-x", wantValue: "", wantInline: false, wantParsedFlag: false}, + {name: "long flag should ignore", arg: "--env", wantValue: "", wantInline: false, wantParsedFlag: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, inline, parsed := parseShortEFlag(tt.arg) + if value != tt.wantValue || inline != tt.wantInline || parsed != tt.wantParsedFlag { + t.Fatalf("got value=%q inline=%v parsed=%v, want value=%q inline=%v parsed=%v", + value, inline, parsed, + tt.wantValue, tt.wantInline, tt.wantParsedFlag, + ) + } + }) + } +} + +func TestParseLongFlag(t *testing.T) { + tests := []struct { + name string + arg string + wantName string + wantValue string + wantInline bool + wantParsed bool + }{ + {name: "plain long", arg: "--env", wantName: "env", wantValue: "", wantInline: false, wantParsed: true}, + {name: "long with equals", arg: "--env=A=1", wantName: "env", wantValue: "A=1", wantInline: true, wantParsed: true}, + {name: "single dash", arg: "-e", wantParsed: false}, + {name: "double dash only", arg: "--", wantParsed: false}, + {name: "non flag", arg: "env", wantParsed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, value, inline, parsed := parseLongFlag(tt.arg) + if name != tt.wantName || value != tt.wantValue || inline != tt.wantInline || parsed != tt.wantParsed { + t.Fatalf("got name=%q value=%q inline=%v parsed=%v, want name=%q value=%q inline=%v parsed=%v", + name, value, inline, parsed, + tt.wantName, tt.wantValue, tt.wantInline, tt.wantParsed, + ) + } + }) + } +} + +func TestParseEnvFlagFromArgs(t *testing.T) { + tests := []struct { + name string + args []string + index int + wantName string + wantValue string + wantOK bool + wantErr string + }{ + {name: "long env next arg", args: []string{"--env", "A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "long env inline", args: []string{"--env=A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "short env next arg", args: []string{"-e", "A=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "short env inline", args: []string{"-eA=1"}, index: 0, wantName: "env", wantValue: "A=1", wantOK: true}, + {name: "env file next arg", args: []string{"--env-file", ".env"}, index: 0, wantName: "env-file", wantValue: ".env", wantOK: true}, + {name: "env file inline", args: []string{"--env-file=.env,.env.local"}, index: 0, wantName: "env-file", wantValue: ".env,.env.local", wantOK: true}, + {name: "missing long env value", args: []string{"--env"}, index: 0, wantErr: "requires a value"}, + {name: "missing short env value", args: []string{"-e"}, index: 0, wantErr: "requires a value"}, + {name: "unknown flag", args: []string{"--name", "demo"}, index: 0, wantOK: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + name, value, ok, err := parseEnvFlagFromArgs(tt.args, tt.index) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err=%v, want contains %q", err, tt.wantErr) + } + return + } + + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if name != tt.wantName || value != tt.wantValue || ok != tt.wantOK { + t.Fatalf("got name=%q value=%q ok=%v, want name=%q value=%q ok=%v", + name, value, ok, + tt.wantName, tt.wantValue, tt.wantOK, + ) + } + }) + } +} + +func TestPreloadEnvFromArgs_AppliesAndRestores(t *testing.T) { + const existing = "REDANT_PRELOAD_EXISTING" + const created = "REDANT_PRELOAD_CREATED" + + t.Setenv(existing, "orig") + _ = os.Unsetenv(created) + + restore, err := preloadEnvFromArgs([]string{"-e", existing + "=override", "--env", created + "=1"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore func should not be nil") + } + + if got := os.Getenv(existing); got != "override" { + t.Fatalf("existing=%q, want override", got) + } + if got := os.Getenv(created); got != "1" { + t.Fatalf("created=%q, want 1", got) + } + + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + + if got := os.Getenv(existing); got != "orig" { + t.Fatalf("existing after restore=%q, want orig", got) + } + if _, ok := os.LookupEnv(created); ok { + t.Fatalf("created should be unset after restore") + } +} + +func TestPreloadEnvFromArgs_ShortInlineEquals(t *testing.T) { + const key = "REDANT_PRELOAD_SHORT_INLINE_EQUALS" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e=" + key + "=ok"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore should not be nil") + } + if got := os.Getenv(key); got != "ok" { + t.Fatalf("got %q, want ok", got) + } + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be unset after restore", key) + } +} + +func TestPreloadEnvFromArgs_EnvFileRepeatAndCSV(t *testing.T) { + const inherited = "REDANT_PRELOAD_COMMON" + const only1 = "REDANT_PRELOAD_FILE_A" + const only2 = "REDANT_PRELOAD_FILE_B" + + t.Setenv(inherited, "orig-common") + _ = os.Unsetenv(only1) + _ = os.Unsetenv(only2) + + tmpDir := t.TempDir() + file1 := filepath.Join(tmpDir, "a.env") + file2 := filepath.Join(tmpDir, "b.env") + + if err := os.WriteFile(file1, []byte(inherited+"=from-a\n"+only1+"=one\n"), 0o600); err != nil { + t.Fatalf("write file1: %v", err) + } + if err := os.WriteFile(file2, []byte("export "+inherited+"=\"from-b\"\n"+only2+"='two'\n"), 0o600); err != nil { + t.Fatalf("write file2: %v", err) + } + + restore, err := preloadEnvFromArgs([]string{"--env-file", file1, "--env-file", file2 + "," + file1}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore == nil { + t.Fatalf("restore func should not be nil") + } + + if got := os.Getenv(inherited); got != "from-a" { + t.Fatalf("inherited=%q, want from-a", got) + } + if got := os.Getenv(only1); got != "one" { + t.Fatalf("only1=%q, want one", got) + } + if got := os.Getenv(only2); got != "two" { + t.Fatalf("only2=%q, want two", got) + } + + if err := restore(); err != nil { + t.Fatalf("restore error: %v", err) + } + + if got := os.Getenv(inherited); got != "orig-common" { + t.Fatalf("inherited after restore=%q, want orig-common", got) + } + if _, ok := os.LookupEnv(only1); ok { + t.Fatalf("only1 should be unset after restore") + } + if _, ok := os.LookupEnv(only2); ok { + t.Fatalf("only2 should be unset after restore") + } +} + +func TestPreloadEnvFromArgs_StopAtDoubleDash(t *testing.T) { + const key = "REDANT_PRELOAD_STOP_AT_DASH" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"--", "-e", key + "=1"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore != nil { + t.Fatalf("restore should be nil when no env is changed") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should not be set after --", key) + } +} + +func TestPreloadEnvFromArgs_RollbackOnError(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "-e", "INVALID"}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when preload fails", key) + } +} + +func TestPreloadEnvFromArgs_RollbackOnParseErrorAfterMutation(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK_PARSE" + _ = os.Unsetenv(key) + + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "--env"}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when parse fails", key) + } +} + +func TestParseEnvAssignment(t *testing.T) { + tests := []struct { + name string + raw string + wantKey string + wantValue string + wantErr string + }{ + {name: "basic", raw: "A=1", wantKey: "A", wantValue: "1"}, + {name: "trim spaces", raw: " A = 1 ", wantKey: "A", wantValue: "1"}, + {name: "value includes equals", raw: "A=a=b", wantKey: "A", wantValue: "a=b"}, + {name: "empty raw", raw: "", wantErr: "empty environment assignment"}, + {name: "missing equals", raw: "A", wantErr: "expected KEY=VALUE"}, + {name: "missing key", raw: "=1", wantErr: "expected KEY=VALUE"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value, err := parseEnvAssignment(tt.raw) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err=%v, want contains %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if key != tt.wantKey || value != tt.wantValue { + t.Fatalf("got key=%q value=%q, want key=%q value=%q", key, value, tt.wantKey, tt.wantValue) + } + }) + } +} + +func TestApplyEnvAssignmentsCSV(t *testing.T) { + got := make(map[string]string) + setEnv := func(key, value string) error { + got[key] = value + return nil + } + + err := applyEnvAssignmentsCSV(`A=1,"B=hello,world",C=3`, setEnv) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + + if got["A"] != "1" { + t.Fatalf("A=%q, want 1", got["A"]) + } + if got["B"] != "hello,world" { + t.Fatalf("B=%q, want hello,world", got["B"]) + } + if got["C"] != "3" { + t.Fatalf("C=%q, want 3", got["C"]) + } +} + +func TestLoadEnvFile_InvalidLineIncludesPosition(t *testing.T) { + tmp := filepath.Join(t.TempDir(), ".env") + if err := os.WriteFile(tmp, []byte("A=1\nINVALID\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + err := loadEnvFile(tmp, func(key, value string) error { return nil }) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), ":2:") { + t.Fatalf("error should contain line number, got: %v", err) + } +} + +func TestConsumesNextArg(t *testing.T) { + tests := []struct { + name string + current string + flagName string + want bool + }{ + {name: "long env", current: "--env", flagName: "env", want: true}, + {name: "long env-file", current: "--env-file", flagName: "env-file", want: true}, + {name: "long inline", current: "--env=A=1", flagName: "env", want: false}, + {name: "short e", current: "-e", flagName: "env", want: true}, + {name: "short inline", current: "-eA=1", flagName: "env", want: false}, + {name: "other", current: "--name", flagName: "env", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := consumesNextArg(tt.current, tt.flagName); got != tt.want { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} + +func TestPreloadEnvFromArgs_RollbackOnEnvFileLoadError(t *testing.T) { + const key = "REDANT_PRELOAD_ROLLBACK_FILE_ERROR" + _ = os.Unsetenv(key) + + missing := filepath.Join(t.TempDir(), "not-exists.env") + restore, err := preloadEnvFromArgs([]string{"-e", key + "=1", "--env-file", missing}) + if err == nil { + t.Fatalf("expected error") + } + if restore != nil { + t.Fatalf("restore should be nil on preload error") + } + if _, ok := os.LookupEnv(key); ok { + t.Fatalf("%s should be rolled back when env-file load fails", key) + } +} + +func TestPreloadEnvFromArgs_NoEnvFlagsNoChange(t *testing.T) { + const key = "REDANT_PRELOAD_NO_CHANGE" + t.Setenv(key, "orig") + + restore, err := preloadEnvFromArgs([]string{"--name", "demo", "subcmd"}) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if restore != nil { + t.Fatalf("restore should be nil when env flags are absent") + } + if got := os.Getenv(key); got != "orig" { + t.Fatalf("got %q, want orig", got) + } +} + +func TestNormalizeEnvValue(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "single quoted", in: "'abc'", want: "abc"}, + {name: "double quoted", in: "\"abc\"", want: "abc"}, + {name: "double quoted escape", in: "\"a\\nb\"", want: "a\nb"}, + {name: "invalid double quote keep raw", in: "\"abc", want: "\"abc"}, + {name: "trim spaces", in: " abc ", want: "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := normalizeEnvValue(tt.in); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestRestoreEnvSnapshots(t *testing.T) { + const existing = "REDANT_RESTORE_SNAPSHOT_EXISTING" + const created = "REDANT_RESTORE_SNAPSHOT_CREATED" + + t.Setenv(existing, "current") + _ = os.Unsetenv(created) + + if err := os.Setenv(created, "temp"); err != nil { + t.Fatalf("set created: %v", err) + } + if err := os.Setenv(existing, "override"); err != nil { + t.Fatalf("set existing: %v", err) + } + + snapshots := map[string]envSnapshot{ + existing: {value: "orig", existed: true}, + created: {value: "", existed: false}, + } + + if err := restoreEnvSnapshots(snapshots); err != nil { + t.Fatalf("restoreEnvSnapshots err: %v", err) + } + + if got := os.Getenv(existing); got != "orig" { + t.Fatalf("existing=%q, want orig", got) + } + if _, ok := os.LookupEnv(created); ok { + t.Fatalf("created should be unset") + } +} diff --git a/example/fastcommit/main.go b/example/fastcommit/main.go index 4ed9522..53e96ca 100644 --- a/example/fastcommit/main.go +++ b/example/fastcommit/main.go @@ -2,77 +2,172 @@ package main import ( "context" + "encoding/json" "fmt" + "net/url" "os" + "time" "github.com/pubgo/redant" "github.com/pubgo/redant/cmds/completioncmd" + "github.com/pubgo/redant/cmds/mcpcmd" + "github.com/pubgo/redant/cmds/readlinecmd" + "github.com/pubgo/redant/cmds/richlinecmd" + "github.com/pubgo/redant/cmds/webcmd" + "github.com/pubgo/redant/cmds/webttycmd" ) // mkdir -p ~/.zsh/completions // go run example/fastcommit/main.go completion zsh > ~/.zsh/completions/_fastcommit +type CommitMetadata struct { + Ticket string `json:"ticket" yaml:"ticket"` + Priority string `json:"priority" yaml:"priority"` + Labels []string `json:"labels" yaml:"labels"` + Extra map[string]string `json:"extra" yaml:"extra"` +} + +type ReleasePlan struct { + Strategy string `json:"strategy" yaml:"strategy"` + Canary int `json:"canary" yaml:"canary"` + Services []string `json:"services" yaml:"services"` +} + +type RepoPolicy struct { + ProtectedBranches []string `json:"protectedBranches" yaml:"protectedBranches"` + RequireReview bool `json:"requireReview" yaml:"requireReview"` + MinApprovals int `json:"minApprovals" yaml:"minApprovals"` +} + +func toJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} + func main() { - // Create root command rootCmd := &redant.Command{ Use: "fastcommit", Short: "A fast commit tool.", - Long: "A tool for making fast commits with various options.", + Long: "A tool for making fast commits with rich command tree and complex option types.", } - // Create commit command + var ( + commitMessage string + commitAmend bool + commitFormat string + commitLabels []string + commitReviewers []string + commitTimeout time.Duration + commitWeight float64 + commitMaxRetries int64 + commitWebhookURL url.URL + ) + commitEndpoint := &redant.HostPort{} + commitPattern := &redant.Regexp{} + commitMetadata := &redant.Struct[CommitMetadata]{Value: CommitMetadata{ + Ticket: "JIRA-100", + Priority: "high", + Labels: []string{"feat", "backend"}, + Extra: map[string]string{"source": "cli"}, + }} + commitCmd := &redant.Command{ - Use: "commit", - Short: "Commit changes.", - Long: "Commit changes with a message and other options.", + Use: "commit", + Short: "Commit changes.", + Long: "Commit changes with advanced options and typed values.", + Metadata: map[string]string{}, Options: redant.OptionSet{ { Flag: "message", Shorthand: "m", Description: "Commit message.", - Value: redant.StringOf(new(string)), + Value: redant.StringOf(&commitMessage), + Default: "update: default message", }, { Flag: "amend", Description: "Amend the previous commit.", - Value: redant.BoolOf(new(bool)), + Value: redant.BoolOf(&commitAmend), + }, + { + Flag: "format", + Description: "Output format.", + Value: redant.EnumOf(&commitFormat, "text", "json", "yaml"), + Default: "text", + }, + { + Flag: "labels", + Description: "Commit labels enum-array.", + Value: redant.EnumArrayOf(&commitLabels, "feat", "fix", "docs", "refactor", "test", "chore"), + }, + { + Flag: "reviewers", + Description: "Reviewers list.", + Value: redant.StringArrayOf(&commitReviewers), + }, + { + Flag: "timeout", + Description: "Commit timeout duration.", + Value: redant.DurationOf(&commitTimeout), + Default: "30s", + }, + { + Flag: "weight", + Description: "Commit score weight.", + Value: redant.Float64Of(&commitWeight), + Default: "1.5", + }, + { + Flag: "max-retries", + Description: "Max retry count.", + Value: redant.Int64Of(&commitMaxRetries), + Default: "3", + }, + { + Flag: "webhook", + Description: "Webhook URL.", + Value: redant.URLOf(&commitWebhookURL), + Default: "https://example.com/hook", + }, + { + Flag: "endpoint", + Description: "Commit target endpoint (host:port).", + Value: commitEndpoint, + Default: "127.0.0.1:9000", + }, + { + Flag: "pattern", + Description: "Filter regexp.", + Value: commitPattern, + Default: "^(feat|fix|docs):", + }, + { + Flag: "metadata", + Description: "Commit metadata as struct (JSON/YAML).", + Value: commitMetadata, }, }, Args: redant.ArgSet{ - {Name: "files", Description: "Files to commit."}, + {Name: "files", Description: "Files to commit (positional).", Value: redant.StringOf(new(string))}, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - fmt.Printf("Commit command executed\n") - fmt.Printf("Args: %v\n", inv.Args) - - // Get flag values - if inv.Flags != nil { - // Get values directly from options - var message string - var amend bool - - for _, opt := range inv.Command.Options { - switch opt.Flag { - case "message": - if strVal, ok := opt.Value.(*redant.String); ok { - message = strVal.String() - } - case "amend": - if boolVal, ok := opt.Value.(*redant.Bool); ok { - amend = boolVal.Value() - } - } - } - - fmt.Printf("Message: %s\n", message) - fmt.Printf("Amend: %v\n", amend) - } - + fmt.Printf("[commit] args=%v\n", inv.Args) + fmt.Printf("[commit] message=%q amend=%v format=%s timeout=%s weight=%.2f max-retries=%d\n", commitMessage, commitAmend, commitFormat, commitTimeout, commitWeight, commitMaxRetries) + fmt.Printf("[commit] labels=%v reviewers=%v endpoint=%s webhook=%s pattern=%s\n", commitLabels, commitReviewers, commitEndpoint.String(), redant.URLOf(&commitWebhookURL).String(), commitPattern.String()) + fmt.Printf("[commit] metadata=%s\n", toJSON(commitMetadata.Value)) return nil }, } - // Create detailed subcommand for commit + var ( + detailedAuthor string + detailedVerbose bool + detailedMode string + ) + detailedCmd := &redant.Command{ Use: "detailed", Short: "Detailed commit.", @@ -81,55 +176,199 @@ func main() { { Flag: "author", Description: "Author of the commit.", - Value: redant.StringOf(new(string)), + Value: redant.StringOf(&detailedAuthor), }, { Flag: "verbose", Shorthand: "v", Description: "Verbose output.", - Value: redant.BoolOf(new(bool)), + Value: redant.BoolOf(&detailedVerbose), + }, + { + Flag: "mode", + Description: "Detailed mode.", + Value: redant.EnumOf(&detailedMode, "diff", "stat", "full"), + Default: "diff", }, }, Args: redant.ArgSet{ - {Name: "files", Description: "Files to commit."}, + {Name: "files", Description: "Files to commit.", Value: redant.StringOf(new(string))}, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - fmt.Printf("Detailed commit command executed\n") - fmt.Printf("Args: %v\n", inv.Args) - - // Get flag values - if inv.Flags != nil { - // Get values directly from options - var author string - var verbose bool - - for _, opt := range inv.Command.Options { - switch opt.Flag { - case "author": - if strVal, ok := opt.Value.(*redant.String); ok { - author = strVal.String() - } - case "verbose": - if boolVal, ok := opt.Value.(*redant.Bool); ok { - verbose = boolVal.Value() - } - } - } - - fmt.Printf("Author: %s\n", author) - fmt.Printf("Verbose: %v\n", verbose) - } + fmt.Printf("[commit detailed] args=%v author=%q verbose=%v mode=%s\n", inv.Args, detailedAuthor, detailedVerbose, detailedMode) + return nil + }, + } + var ( + releaseChannel string + releaseRegions []string + releaseBatchSize int64 + releaseWindow time.Duration + releaseDryRun bool + releaseVersion string + ) + releaseFilter := &redant.Regexp{} + releasePlan := &redant.Struct[ReleasePlan]{Value: ReleasePlan{ + Strategy: "canary", + Canary: 10, + Services: []string{"api", "worker"}, + }} + releaseShipCmd := &redant.Command{ + Use: "release ship", + Short: "Ship a release with rollout controls.", + Long: "Ship release with enum, enum-array, duration, struct, regexp and integer options.", + Metadata: map[string]string{}, + Options: redant.OptionSet{ + {Flag: "channel", Description: "Release channel.", Value: redant.EnumOf(&releaseChannel, "alpha", "beta", "stable"), Default: "beta"}, + {Flag: "regions", Description: "Target regions.", Value: redant.EnumArrayOf(&releaseRegions, "cn", "us", "eu", "ap")}, + {Flag: "batch-size", Description: "Batch size.", Value: redant.Int64Of(&releaseBatchSize), Default: "100"}, + {Flag: "window", Description: "Release window.", Value: redant.DurationOf(&releaseWindow), Default: "5m"}, + {Flag: "dry-run", Description: "Preview only.", Value: redant.BoolOf(&releaseDryRun)}, + {Flag: "filter", Description: "Service name filter regexp.", Value: releaseFilter, Default: "^(api|worker)$"}, + {Flag: "plan", Description: "Rollout plan object.", Value: releasePlan}, + }, + Args: redant.ArgSet{ + {Name: "version", Required: true, Description: "Release version.", Value: redant.StringOf(&releaseVersion)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[release ship] version=%s channel=%s dry-run=%v regions=%v batch-size=%d window=%s filter=%s\n", releaseVersion, releaseChannel, releaseDryRun, releaseRegions, releaseBatchSize, releaseWindow, releaseFilter.String()) + fmt.Printf("[release ship] plan=%s\n", toJSON(releasePlan.Value)) + return nil + }, + } + + var ( + repoName string + repoVisibility string + repoTags []string + repoMirrorURL url.URL + ) + repoPolicy := &redant.Struct[RepoPolicy]{Value: RepoPolicy{ + ProtectedBranches: []string{"main", "release"}, + RequireReview: true, + MinApprovals: 2, + }} + repoCreateCmd := &redant.Command{ + Use: "create", + Short: "Create repository with policy.", + Long: "Create repository under project scope with enum and struct options.", + Options: redant.OptionSet{ + {Flag: "visibility", Description: "Repo visibility.", Value: redant.EnumOf(&repoVisibility, "public", "private", "internal"), Default: "private"}, + {Flag: "tags", Description: "Repo tags.", Value: redant.StringArrayOf(&repoTags)}, + {Flag: "policy", Description: "Repo policy object.", Value: repoPolicy}, + {Flag: "mirror", Description: "Mirror upstream URL.", Value: redant.URLOf(&repoMirrorURL), Default: "https://github.com/pubgo/redant"}, + }, + Args: redant.ArgSet{ + {Name: "repo_name", Required: true, Description: "Repository name.", Value: redant.StringOf(&repoName)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project repo create] name=%s visibility=%s tags=%v mirror=%s\n", repoName, repoVisibility, repoTags, redant.URLOf(&repoMirrorURL).String()) + fmt.Printf("[project repo create] policy=%s\n", toJSON(repoPolicy.Value)) + return nil + }, + } + + var ( + mirrorName string + mirrorMode string + mirrorForce bool + ) + projectRepoMirrorCmd := &redant.Command{ + Use: "mirror", + Short: "Mirror repository.", + Long: "Mirror repository with enum mode and bool options.", + Options: redant.OptionSet{ + {Flag: "mode", Description: "Mirror mode.", Value: redant.EnumOf(&mirrorMode, "fetch", "push", "bidirectional"), Default: "fetch"}, + {Flag: "force", Description: "Force mirror sync.", Value: redant.BoolOf(&mirrorForce)}, + }, + Args: redant.ArgSet{{Name: "repo_name", Required: true, Description: "Repository name.", Value: redant.StringOf(&mirrorName)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project repo mirror] name=%s mode=%s force=%v\n", mirrorName, mirrorMode, mirrorForce) + return nil + }, + } + + projectRepoCmd := &redant.Command{ + Use: "repo", + Short: "Repository operations.", + Long: "Repository operations for integration tests.", + Children: []*redant.Command{ + repoCreateCmd, + projectRepoMirrorCmd, + }, + } + + var ( + envName string + envTargets []string + ) + projectEnvPromoteCmd := &redant.Command{ + Use: "promote", + Short: "Promote environment.", + Long: "Promote env with enum-array targets.", + Options: redant.OptionSet{ + {Flag: "targets", Description: "Promotion targets.", Value: redant.EnumArrayOf(&envTargets, "staging", "pre", "prod")}, + }, + Args: redant.ArgSet{{Name: "env", Required: true, Description: "Environment name.", Value: redant.StringOf(&envName)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[project env promote] env=%s targets=%v\n", envName, envTargets) + return nil + }, + } + + projectEnvCmd := &redant.Command{ + Use: "env", + Short: "Environment operations.", + Children: []*redant.Command{ + projectEnvPromoteCmd, + }, + } + + projectCmd := &redant.Command{ + Use: "project", + Short: "Project operations.", + Long: "Project command group with 3-level subcommands for completion and web testing.", + Children: []*redant.Command{ + projectRepoCmd, + projectEnvCmd, + }, + } + + var ( + profileName string + profileContent string + ) + profileCmd := &redant.Command{ + Use: "profile", + Short: "Profile parser playground.", + Long: "Playground command for args formats (query/form/json/positional).", + Metadata: map[string]string{}, + Args: redant.ArgSet{ + {Name: "name", Required: true, Description: "Profile name.", Value: redant.StringOf(&profileName)}, + {Name: "content", Required: false, Description: "Profile content.", Value: redant.StringOf(&profileContent)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + fmt.Printf("[profile] args=%v name=%s content=%s\n", inv.Args, profileName, profileContent) return nil }, } - // Build command tree commitCmd.Children = append(commitCmd.Children, detailedCmd) - rootCmd.Children = append(rootCmd.Children, commitCmd) - rootCmd.Children = append(rootCmd.Children, completioncmd.New()) - // Run command + rootCmd.Children = append(rootCmd.Children, + commitCmd, + releaseShipCmd, + projectCmd, + profileCmd, + completioncmd.New(), + readlinecmd.New(), + richlinecmd.New(), + mcpcmd.New(), + webcmd.New(), + webttycmd.New(), + ) + err := rootCmd.Invoke().WithOS().Run() if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) diff --git a/go.mod b/go.mod index 1bed32c..0380e0b 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,45 @@ module github.com/pubgo/redant go 1.25.0 require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.0 + github.com/chzyer/readline v1.5.1 + github.com/coder/websocket v1.8.14 + github.com/creack/pty v1.1.24 github.com/mitchellh/go-wordwrap v1.0.1 + github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/muesli/termenv v0.16.0 github.com/spf13/pflag v1.0.10 + golang.org/x/sys v0.42.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/sys v0.42.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect ) diff --git a/go.sum b/go.sum index 7dfd336..3ccfbad 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,50 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= +charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= +github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= +github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= +github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= +github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -8,27 +52,50 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/gitshell/gitshell.go b/internal/gitshell/gitshell.go new file mode 100644 index 0000000..8d38b71 --- /dev/null +++ b/internal/gitshell/gitshell.go @@ -0,0 +1,74 @@ +package gitshell + +import ( + "bytes" + "errors" + "os/exec" + "strings" +) + +// RunInDir executes git command in the provided directory and returns trimmed stdout. +func RunInDir(dir string, args ...string) (string, error) { + if strings.TrimSpace(dir) == "" { + return "", errors.New("empty start dir") + } + if len(args) == 0 { + return "", errors.New("empty git args") + } + + if _, err := exec.LookPath("git"); err != nil { + return "", err + } + + cmdArgs := make([]string, 0, len(args)+2) + cmdArgs = append(cmdArgs, "-C", dir) + cmdArgs = append(cmdArgs, args...) + + cmd := exec.Command("git", cmdArgs...) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return "", err + } + + return strings.TrimSpace(out.String()), nil +} + +// DetectBranch returns current branch name when available. +// In detached HEAD mode, it returns "detached@". +// For non-repository directories, it returns an empty string. +func DetectBranch(startDir string) string { + startDir = strings.TrimSpace(startDir) + if startDir == "" { + return "" + } + + branch, err := RunInDir(startDir, "branch", "--show-current") + if err == nil && branch != "" { + return branch + } + + head, err := RunInDir(startDir, "rev-parse", "--short=12", "HEAD") + if err != nil || head == "" { + return "" + } + + return "detached@" + head +} + +// IsDirty reports whether the git working tree contains uncommitted changes. +// For non-repository directories, it returns false. +func IsDirty(startDir string) bool { + startDir = strings.TrimSpace(startDir) + if startDir == "" { + return false + } + + output, err := RunInDir(startDir, "status", "--porcelain") + if err != nil { + return false + } + + return strings.TrimSpace(output) != "" +} diff --git a/internal/gitshell/gitshell_test.go b/internal/gitshell/gitshell_test.go new file mode 100644 index 0000000..f30a23d --- /dev/null +++ b/internal/gitshell/gitshell_test.go @@ -0,0 +1,129 @@ +package gitshell + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestDetectBranch_WithGitCLIRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + tmp := t.TempDir() + runGitForTest(t, tmp, "init") + runGitForTest(t, tmp, "checkout", "-b", "feat/ctx") + + got := DetectBranch(tmp) + if got != "feat/ctx" { + t.Fatalf("expected feat/ctx, got %q", got) + } + + nested := filepath.Join(tmp, "a", "b") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir nested failed: %v", err) + } + got = DetectBranch(nested) + if got != "feat/ctx" { + t.Fatalf("expected nested path detect feat/ctx, got %q", got) + } +} + +func TestDetectBranch_DetachedHead(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + tmp := t.TempDir() + runGitForTest(t, tmp, "init") + runGitForTest(t, tmp, "config", "user.email", "gitshell-test@example.com") + runGitForTest(t, tmp, "config", "user.name", "gitshell-test") + if err := os.WriteFile(filepath.Join(tmp, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README failed: %v", err) + } + runGitForTest(t, tmp, "add", "README.md") + runGitForTest(t, tmp, "commit", "-m", "init") + runGitForTest(t, tmp, "checkout", "--detach") + + got := DetectBranch(tmp) + if !strings.HasPrefix(got, "detached@") { + t.Fatalf("expected detached@ prefix, got %q", got) + } +} + +func TestDetectBranch_NotRepo(t *testing.T) { + tmp := t.TempDir() + got := DetectBranch(tmp) + if got != "" { + t.Fatalf("expected empty branch for non-repo path, got %q", got) + } +} + +func TestRunInDir_EmptyArgs(t *testing.T) { + if _, err := RunInDir(t.TempDir()); err == nil { + t.Fatalf("expected error when args are empty") + } +} + +func TestIsDirty_CleanRepo(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + tmp := t.TempDir() + runGitForTest(t, tmp, "init") + runGitForTest(t, tmp, "config", "user.email", "gitshell-test@example.com") + runGitForTest(t, tmp, "config", "user.name", "gitshell-test") + if err := os.WriteFile(filepath.Join(tmp, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README failed: %v", err) + } + runGitForTest(t, tmp, "add", "README.md") + runGitForTest(t, tmp, "commit", "-m", "init") + + if IsDirty(tmp) { + t.Fatalf("expected clean repo to be not dirty") + } +} + +func TestIsDirty_WithWorkingTreeChanges(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + tmp := t.TempDir() + runGitForTest(t, tmp, "init") + runGitForTest(t, tmp, "config", "user.email", "gitshell-test@example.com") + runGitForTest(t, tmp, "config", "user.name", "gitshell-test") + if err := os.WriteFile(filepath.Join(tmp, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README failed: %v", err) + } + runGitForTest(t, tmp, "add", "README.md") + runGitForTest(t, tmp, "commit", "-m", "init") + if err := os.WriteFile(filepath.Join(tmp, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatalf("update README failed: %v", err) + } + + if !IsDirty(tmp) { + t.Fatalf("expected repo with working tree changes to be dirty") + } +} + +func TestIsDirty_NotRepo(t *testing.T) { + if IsDirty(t.TempDir()) { + t.Fatalf("expected non-repo path to be not dirty") + } +} + +func runGitForTest(t *testing.T, dir string, args ...string) { + t.Helper() + cmdArgs := make([]string, 0, len(args)+2) + cmdArgs = append(cmdArgs, "-C", dir) + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command("git", cmdArgs...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v failed: %v, output=%s", args, err, strings.TrimSpace(string(out))) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..4580f7a --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,188 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/pubgo/redant" +) + +const ( + defaultMCPServerName = "redant-mcp" + mcpServerVersionPrefix = "v" +) + +type Server struct { + root *redant.Command + tools []toolDef + server *mcp.Server +} + +type ToolInfo struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Path []string `json:"path"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema"` +} + +func ListToolInfos(root *redant.Command) []ToolInfo { + defs := collectTools(root) + out := make([]ToolInfo, 0, len(defs)) + for _, td := range defs { + out = append(out, ToolInfo{ + Name: td.Name, + Description: td.Description, + Path: append([]string(nil), td.PathTokens...), + InputSchema: td.InputSchema, + OutputSchema: td.OutputSchema, + }) + } + return out +} + +func New(root *redant.Command) *Server { + s := &Server{ + root: root, + tools: collectTools(root), + server: mcp.NewServer(&mcp.Implementation{ + Name: serverNameFromRoot(root), + Version: mcpServerVersionPrefix + strings.TrimSpace(redant.Version()), + }, &mcp.ServerOptions{}), + } + s.registerTools() + return s +} + +func ServeStdio(ctx context.Context, root *redant.Command, r io.Reader, w io.Writer) error { + return New(root).ServeStdio(ctx, r, w) +} + +func (s *Server) ServeStdio(ctx context.Context, r io.Reader, w io.Writer) error { + if s == nil || s.root == nil || s.server == nil { + return errors.New("mcp server root command is nil") + } + if r == nil { + r = strings.NewReader("") + } + if w == nil { + w = io.Discard + } + + transport := &mcp.IOTransport{ + Reader: nopReadCloser{Reader: r}, + Writer: nopWriteCloser{Writer: w}, + } + return s.server.Run(ctx, transport) +} + +func (s *Server) registerTools() { + if s == nil || s.server == nil { + return + } + + for _, td := range s.tools { + tool := td + s.server.AddTool(&mcp.Tool{ + Name: tool.Name, + Description: tool.Description, + InputSchema: tool.InputSchema, + OutputSchema: tool.OutputSchema, + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + args := map[string]any{} + if raw := req.Params.Arguments; len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("invalid tool arguments: %v", err)}}, + IsError: true, + }, nil + } + } + + result, err := s.callTool(ctx, toolsCallParams{ + Name: tool.Name, + Arguments: args, + }) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}}, + IsError: true, + }, nil + } + + return mapToolResultToSDK(result), nil + }) + } +} + +func mapToolResultToSDK(result map[string]any) *mcp.CallToolResult { + text := "ok" + var structured any + if content, ok := result["content"]; ok { + switch vv := content.(type) { + case []map[string]any: + if len(vv) > 0 { + if t, ok := vv[0]["text"].(string); ok && t != "" { + text = t + } + } + case []any: + if len(vv) > 0 { + if m, ok := vv[0].(map[string]any); ok { + if t, ok := m["text"].(string); ok && t != "" { + text = t + } + } + } + } + } + if sc, ok := result["structuredContent"]; ok { + structured = sc + } + + isErr, _ := result["isError"].(bool) + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + StructuredContent: structured, + IsError: isErr, + } +} + +type nopReadCloser struct { + io.Reader +} + +func (nopReadCloser) Close() error { + return nil +} + +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { + return nil +} + +type toolsCallParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +func serverNameFromRoot(root *redant.Command) string { + if root == nil { + return defaultMCPServerName + } + + name := strings.TrimSpace(root.Name()) + if name == "" { + return defaultMCPServerName + } + return name +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..0f92721 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,585 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/pubgo/redant" +) + +func TestCollectToolsAndSchema(t *testing.T) { + var msg string + var upper bool + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Short: "echo message", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Options: redant.OptionSet{ + {Flag: "upper", Value: redant.BoolOf(&upper), Description: "uppercase"}, + {Flag: "secret", Value: redant.StringOf(new(string)), Hidden: true}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + s := New(root) + if len(s.tools) != 1 { + t.Fatalf("tools count = %d, want 1", len(s.tools)) + } + + tool := s.tools[0] + if tool.Name != "echo" { + t.Fatalf("tool name = %q, want %q", tool.Name, "echo") + } + + flags, ok := tool.InputSchema["properties"].(map[string]any)["flags"].(map[string]any) + if !ok { + t.Fatalf("flags schema missing") + } + flagProps, ok := flags["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + if _, exists := flagProps["upper"]; !exists { + t.Fatalf("expected upper flag in schema") + } + if _, exists := flagProps["secret"]; exists { + t.Fatalf("hidden flag should not be exposed") + } +} + +func TestCallToolSuccess(t *testing.T) { + var msg string + var upper bool + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Options: redant.OptionSet{ + {Flag: "upper", Value: redant.BoolOf(&upper)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if upper { + _, _ = fmt.Fprint(inv.Stdout, strings.ToUpper(msg)) + return nil + } + _, _ = fmt.Fprint(inv.Stdout, msg) + return nil + }, + }) + + s := New(root) + result, err := s.callTool(context.Background(), toolsCallParams{ + Name: "echo", + Arguments: map[string]any{ + "args": map[string]any{"message": "hello"}, + "flags": map[string]any{"upper": true}, + }, + }) + if err != nil { + t.Fatalf("callTool error: %v", err) + } + + content, ok := result["content"].([]map[string]any) + if !ok || len(content) == 0 { + t.Fatalf("invalid content payload: %#v", result["content"]) + } + text, _ := content[0]["text"].(string) + if !strings.Contains(text, "HELLO") { + t.Fatalf("content text = %q, want contains HELLO", text) + } + + isError, _ := result["isError"].(bool) + if isError { + t.Fatalf("expected success result, got error") + } +} + +func TestServeSDKClientListAndCallTool(t *testing.T) { + var msg string + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Required: true, Value: redant.StringOf(&msg)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprint(inv.Stdout, strings.ToUpper(msg)) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + initRes := session.InitializeResult() + serverInfoName := "" + if initRes != nil && initRes.ServerInfo != nil { + serverInfoName = initRes.ServerInfo.Name + } + if serverInfoName == "" { + t.Fatalf("initialize result or server info is nil") + } + if serverInfoName != "app" { + t.Fatalf("server info name = %q, want %q", serverInfoName, "app") + } + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + if len(listRes.Tools) == 0 { + t.Fatalf("expected at least one tool") + } + if listRes.Tools[0] == nil { + t.Fatalf("first tool is nil") + } + if listRes.Tools[0].Name != "echo" { + t.Fatalf("tool name = %q, want %q", listRes.Tools[0].Name, "echo") + } + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "echo", + Arguments: map[string]any{ + "args": map[string]any{"message": "hello"}, + }, + }) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if callRes.IsError { + t.Fatalf("call tool returned error result") + } + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + if callRes.Content[0] == nil { + t.Fatalf("first content is nil") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "HELLO") { + t.Fatalf("content text = %q, want contains HELLO", text.Text) + } + + structured, ok := callRes.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content is not object: %#v", callRes.StructuredContent) + } + if okVal, _ := structured["ok"].(bool); !okVal { + t.Fatalf("structured ok = %#v, want true", structured["ok"]) + } + if stdout, _ := structured["stdout"].(string); !strings.Contains(stdout, "HELLO") { + t.Fatalf("structured stdout = %q, want contains HELLO", stdout) + } + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func TestServeSDKClientValidatesToolDescriptionAndParameters(t *testing.T) { + var ( + service string + stage string + dryRun bool + ) + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "deploy", + Short: "deploy service", + Long: "deploy service to target environment", + Args: redant.ArgSet{ + {Name: "service", Required: true, Value: redant.StringOf(&service), Description: "service name"}, + }, + Options: redant.OptionSet{ + {Flag: "stage", Value: redant.EnumOf(&stage, "dev", "prod"), Required: true, Description: "target environment"}, + {Flag: "dry-run", Value: redant.BoolOf(&dryRun), Description: "only print action"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if dryRun { + _, _ = fmt.Fprintf(inv.Stdout, "dry-run deploy %s to %s", service, stage) + return nil + } + _, _ = fmt.Fprintf(inv.Stdout, "deploy %s to %s", service, stage) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + var ( + deployTool mcp.Tool + deployFound bool + ) + for i := range listRes.Tools { + if listRes.Tools[i] != nil && listRes.Tools[i].Name == "deploy" { + deployTool = *listRes.Tools[i] + deployFound = true + break + } + } + if !deployFound { + t.Fatalf("tool deploy not found in %#v", listRes.Tools) + } + + if deployTool.Description != "deploy service\n\ndeploy service to target environment" { + t.Fatalf("description = %q", deployTool.Description) + } + + assertJSONSubset(t, deployTool.InputSchema, `{ + "type": "object", + "additionalProperties": false, + "properties": { + "args": { + "type": "object", + "required": ["service"], + "properties": { + "service": {"type": "string", "description": "service name"} + } + }, + "flags": { + "type": "object", + "required": ["stage"], + "properties": { + "stage": {"type": "string", "enum": ["dev", "prod"], "description": "target environment"}, + "dry-run": {"type": "boolean", "description": "only print action"} + } + } + } + }`) + + assertJSONSubset(t, deployTool.OutputSchema, `{ + "type": "object", + "required": ["ok", "stdout", "stderr", "error", "combined"], + "properties": { + "ok": {"type": "boolean"}, + "stdout": {"type": "string"}, + "stderr": {"type": "string"}, + "error": {"type": "string"}, + "combined": {"type": "string"} + } + }`) + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "deploy", + Arguments: map[string]any{ + "args": map[string]any{"service": "api"}, + "flags": map[string]any{ + "stage": "dev", + "dry-run": true, + }, + }, + }) + if err != nil { + t.Fatalf("call tool valid params: %v", err) + } + if callRes.IsError { + t.Fatalf("valid call should not be error, got: %q", firstText(callRes.Content)) + } + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "dry-run deploy api to dev") { + t.Fatalf("content text = %q", text.Text) + } + + assertJSONSubset(t, callRes.StructuredContent, `{ + "ok": true, + "stdout": "dry-run deploy api to dev", + "stderr": "", + "error": "", + "combined": "dry-run deploy api to dev" + }`) + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func TestServeSDKClientStructFlagAndArg(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + argPayload := &redant.Struct[payload]{} + flagPayload := &redant.Struct[payload]{} + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "apply", + Short: "apply config", + Long: "apply config with structured arg and structured flag", + Args: redant.ArgSet{ + {Name: "config", Required: true, Value: argPayload, Description: "config payload"}, + }, + Options: redant.OptionSet{ + {Flag: "meta", Value: flagPayload, Required: true, Description: "meta payload"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprintf(inv.Stdout, + "arg=%s:%d flag=%s:%d", + argPayload.Value.Name, + argPayload.Value.Port, + flagPayload.Value.Name, + flagPayload.Value.Port, + ) + return nil + }, + }) + + srv := New(root) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + serverErrCh := make(chan error, 1) + go func() { + serverErrCh <- srv.server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer func() { _ = session.Close() }() + + listRes, err := session.ListTools(ctx, &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + var ( + applyTool mcp.Tool + applyFound bool + ) + for i := range listRes.Tools { + if listRes.Tools[i] != nil && listRes.Tools[i].Name == "apply" { + applyTool = *listRes.Tools[i] + applyFound = true + break + } + } + if !applyFound { + t.Fatalf("tool apply not found in %#v", listRes.Tools) + } + + assertJSONSubset(t, applyTool.InputSchema, `{ + "type": "object", + "additionalProperties": false, + "properties": { + "args": { + "type": "object", + "required": ["config"], + "properties": { + "config": {"type": "object", "description": "config payload"} + } + }, + "flags": { + "type": "object", + "required": ["meta"], + "properties": { + "meta": {"type": "object", "description": "meta payload"} + } + } + } + }`) + + callRes, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "apply", + Arguments: map[string]any{ + "args": map[string]any{ + "config": map[string]any{ + "name": "api", + "port": 8080, + }, + }, + "flags": map[string]any{ + "meta": map[string]any{ + "name": "prod", + "port": 9000, + }, + }, + }, + }) + if err != nil { + t.Fatalf("call tool: %v", err) + } + if callRes.IsError { + t.Fatalf("call with struct payloads should succeed, got: %q", firstText(callRes.Content)) + } + + if len(callRes.Content) == 0 { + t.Fatalf("call tool content is empty") + } + text, ok := callRes.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("first content is not text") + } + if !strings.Contains(text.Text, "arg=api:8080 flag=prod:9000") { + t.Fatalf("content text = %q", text.Text) + } + + assertJSONSubset(t, callRes.StructuredContent, `{ + "ok": true, + "stdout": "arg=api:8080 flag=prod:9000", + "stderr": "", + "error": "", + "combined": "arg=api:8080 flag=prod:9000" + }`) + + cancel() + if err := <-serverErrCh; err != nil && !strings.Contains(err.Error(), "context canceled") { + t.Fatalf("server run error: %v", err) + } +} + +func firstText(content []mcp.Content) string { + if len(content) == 0 { + return "" + } + t, ok := content[0].(*mcp.TextContent) + if !ok { + return "" + } + return t.Text +} + +func assertJSONSubset(t *testing.T, got any, wantJSON string) { + t.Helper() + + gotNormalized := normalizeJSONLike(t, got) + + var want any + if err := json.Unmarshal([]byte(wantJSON), &want); err != nil { + t.Fatalf("invalid expected json: %v\n%s", err, wantJSON) + } + + if err := checkJSONSubset(gotNormalized, want, "$", true); err != nil { + t.Fatalf("json contract mismatch: %v\nwant subset:\n%s\ngot:\n%s", err, prettyJSON(want), prettyJSON(gotNormalized)) + } +} + +func normalizeJSONLike(t *testing.T, v any) any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal json-like value failed: %v", err) + } + var out any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal json-like value failed: %v", err) + } + return out +} + +func checkJSONSubset(got, want any, path string, exactArray bool) error { + switch wantTyped := want.(type) { + case map[string]any: + gotMap, ok := got.(map[string]any) + if !ok { + return fmt.Errorf("%s expected object, got %T", path, got) + } + for k, wantV := range wantTyped { + gotV, exists := gotMap[k] + if !exists { + return fmt.Errorf("%s.%s missing", path, k) + } + if err := checkJSONSubset(gotV, wantV, path+"."+k, exactArray); err != nil { + return err + } + } + return nil + + case []any: + gotArr, ok := got.([]any) + if !ok { + return fmt.Errorf("%s expected array, got %T", path, got) + } + if exactArray && len(gotArr) != len(wantTyped) { + return fmt.Errorf("%s expected array len %d, got %d", path, len(wantTyped), len(gotArr)) + } + for i := range wantTyped { + if i >= len(gotArr) { + return fmt.Errorf("%s[%d] missing", path, i) + } + if err := checkJSONSubset(gotArr[i], wantTyped[i], fmt.Sprintf("%s[%d]", path, i), exactArray); err != nil { + return err + } + } + return nil + + default: + if !reflect.DeepEqual(got, want) { + return fmt.Errorf("%s expected %#v, got %#v", path, want, got) + } + return nil + } +} + +func prettyJSON(v any) string { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Sprintf("", err) + } + return string(b) +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..82dc221 --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,557 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/pubgo/redant" +) + +type toolDef struct { + Name string + Description string + PathTokens []string + Command *redant.Command + Options redant.OptionSet + InputSchema map[string]any + OutputSchema map[string]any +} + +func collectTools(root *redant.Command) []toolDef { + if root == nil { + return nil + } + + var tools []toolDef + var walk func(cmd *redant.Command, path []string, inheritedOptions redant.OptionSet) + walk = func(cmd *redant.Command, path []string, inheritedOptions redant.OptionSet) { + if cmd == nil || cmd.Hidden { + return + } + + effectiveOptions := make(redant.OptionSet, 0, len(inheritedOptions)+len(cmd.Options)) + effectiveOptions = append(effectiveOptions, inheritedOptions...) + effectiveOptions = append(effectiveOptions, cmd.Options...) + + if cmd.Handler != nil { + tools = append(tools, toolDef{ + Name: strings.Join(path, "."), + Description: commandDescription(cmd), + PathTokens: append([]string(nil), path...), + Command: cmd, + Options: append(redant.OptionSet(nil), effectiveOptions...), + InputSchema: buildInputSchema(cmd.Args, effectiveOptions), + OutputSchema: buildOutputSchema(), + }) + } + + for _, child := range cmd.Children { + walk(child, append(path, child.Name()), effectiveOptions) + } + } + + for _, child := range root.Children { + walk(child, []string{child.Name()}, root.Options) + } + + return tools +} + +func commandDescription(cmd *redant.Command) string { + short := strings.TrimSpace(cmd.Short) + long := strings.TrimSpace(cmd.Long) + switch { + case short != "" && long != "": + return short + "\n\n" + long + case short != "": + return short + case long != "": + return long + default: + return "" + } +} + +func buildInputSchema(args redant.ArgSet, options redant.OptionSet) map[string]any { + argsSchema := buildArgsSchema(args) + flagsSchema := buildFlagsSchema(options) + + properties := map[string]any{"flags": flagsSchema} + + if len(args) > 0 { + properties["args"] = argsSchema + } else { + properties["args"] = map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + "description": "Positional args array for commands without ArgSet definition.", + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": properties, + } + return schema +} + +func buildOutputSchema() map[string]any { + return map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": map[string]any{ + "ok": map[string]any{ + "type": "boolean", + }, + "stdout": map[string]any{ + "type": "string", + }, + "stderr": map[string]any{ + "type": "string", + }, + "error": map[string]any{ + "type": "string", + }, + "combined": map[string]any{ + "type": "string", + }, + }, + "required": []string{"ok", "stdout", "stderr", "error", "combined"}, + } +} + +func buildArgsSchema(args redant.ArgSet) map[string]any { + props := map[string]any{} + var required []string + + for i, arg := range args { + name := arg.Name + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + + argSchema := valueTypeToSchema(typeOfValue(arg.Value)) + if arg.Description != "" { + argSchema["description"] = arg.Description + } + if arg.Default != "" { + argSchema["default"] = arg.Default + } + + props[name] = argSchema + if arg.Required && arg.Default == "" { + required = append(required, name) + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": props, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func buildFlagsSchema(opts redant.OptionSet) map[string]any { + props := map[string]any{} + var required []string + + for _, opt := range opts { + if opt.Flag == "" || opt.Hidden || isSystemFlag(opt.Flag) { + continue + } + + flagSchema := valueTypeToSchema(opt.Type()) + if opt.Description != "" { + flagSchema["description"] = opt.Description + } + if opt.Default != "" { + flagSchema["default"] = opt.Default + } + if len(opt.Envs) > 0 { + flagSchema["x-env"] = opt.Envs + } + + props[opt.Flag] = flagSchema + if opt.Required && opt.Default == "" && len(opt.Envs) == 0 { + required = append(required, opt.Flag) + } + } + + schema := map[string]any{ + "type": "object", + "additionalProperties": false, + "properties": props, + } + if len(required) > 0 { + schema["required"] = required + } + return schema +} + +func typeOfValue(v any) string { + if v == nil { + return "string" + } + t, ok := v.(interface{ Type() string }) + if !ok { + return "string" + } + return t.Type() +} + +func valueTypeToSchema(typ string) map[string]any { + typ = strings.TrimSpace(typ) + if typ == "" { + return map[string]any{"type": "string"} + } + + if strings.HasPrefix(typ, "enum[") && strings.HasSuffix(typ, "]") { + choices := strings.TrimSuffix(strings.TrimPrefix(typ, "enum["), "]") + return map[string]any{ + "type": "string", + "enum": splitEnumChoices(choices), + } + } + + if strings.HasPrefix(typ, "enum-array[") && strings.HasSuffix(typ, "]") { + choices := strings.TrimSuffix(strings.TrimPrefix(typ, "enum-array["), "]") + return map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + "enum": splitEnumChoices(choices), + }, + } + } + + if strings.HasPrefix(typ, "struct[") && strings.HasSuffix(typ, "]") { + return map[string]any{ + "type": "object", + "additionalProperties": true, + "x-redant-value-type": typ, + } + } + + switch typ { + case "int", "int64": + return map[string]any{"type": "integer"} + case "float", "float64": + return map[string]any{"type": "number"} + case "bool": + return map[string]any{"type": "boolean"} + case "string-array": + return map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + } + default: + return map[string]any{"type": "string"} + } +} + +func splitEnumChoices(raw string) []string { + parts := strings.Split(raw, "\\|") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +func (s *Server) callTool(ctx context.Context, params toolsCallParams) (map[string]any, error) { + if params.Name == "" { + return nil, errorsNew("missing tool name") + } + + tool, err := s.findTool(params.Name) + if err != nil { + return nil, err + } + + argv, err := buildArgv(tool, params.Arguments) + if err != nil { + return nil, err + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + + inv := s.root.Invoke(argv...) + inv.Stdout = &stdout + inv.Stderr = &stderr + inv.Stdin = bytes.NewReader(nil) + + runErr := inv.WithContext(ctx).Run() + return buildToolResult(stdout.String(), stderr.String(), runErr), nil +} + +func (s *Server) findTool(name string) (toolDef, error) { + for _, t := range s.tools { + if t.Name == name { + return t, nil + } + } + return toolDef{}, fmt.Errorf("tool %q not found", name) +} + +func buildArgv(tool toolDef, input map[string]any) ([]string, error) { + argv := append([]string(nil), tool.PathTokens...) + + flagsInput := map[string]any{} + if raw, ok := input["flags"]; ok { + m, ok := raw.(map[string]any) + if !ok { + return nil, errorsNew("arguments.flags must be an object") + } + flagsInput = m + } + + flagByName := map[string]redant.Option{} + for _, opt := range tool.Options { + if opt.Flag == "" || opt.Hidden || isSystemFlag(opt.Flag) { + continue + } + flagByName[opt.Flag] = opt + } + + keys := make([]string, 0, len(flagsInput)) + for k := range flagsInput { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, k := range keys { + v := flagsInput[k] + opt, ok := flagByName[k] + if !ok { + return nil, fmt.Errorf("unknown flag %q for tool %q", k, tool.Name) + } + + flagTokens, err := serializeFlag(opt, v) + if err != nil { + return nil, fmt.Errorf("flag %q: %w", k, err) + } + argv = append(argv, flagTokens...) + } + + argsTokens, err := serializeArgs(tool.Command.Args, input["args"]) + if err != nil { + return nil, err + } + argv = append(argv, argsTokens...) + + return argv, nil +} + +func serializeArgs(def redant.ArgSet, raw any) ([]string, error) { + if len(def) == 0 { + if raw == nil { + return nil, nil + } + vals, ok := raw.([]any) + if !ok { + return nil, errorsNew("arguments.args must be an array for commands without ArgSet") + } + out := make([]string, 0, len(vals)) + for _, v := range vals { + out = append(out, toString(v)) + } + return out, nil + } + + if raw == nil { + return nil, nil + } + argMap, ok := raw.(map[string]any) + if !ok { + return nil, errorsNew("arguments.args must be an object") + } + + out := make([]string, 0, len(def)) + for i, arg := range def { + name := arg.Name + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + + v, ok := argMap[name] + if !ok { + if arg.Required && arg.Default == "" { + return nil, fmt.Errorf("missing required arg %q", name) + } + continue + } + + encoded, err := serializeValueByType(typeOfValue(arg.Value), v) + if err != nil { + return nil, fmt.Errorf("arg %q: %w", name, err) + } + out = append(out, encoded) + } + + return out, nil +} + +func serializeFlag(opt redant.Option, v any) ([]string, error) { + flag := "--" + opt.Flag + schema := valueTypeToSchema(opt.Type()) + typ, _ := schema["type"].(string) + + switch typ { + case "boolean": + bv, ok := v.(bool) + if !ok { + return nil, errorsNew("expected boolean") + } + if bv { + return []string{flag}, nil + } + return []string{flag + "=false"}, nil + + case "array": + arr, ok := v.([]any) + if !ok { + return nil, errorsNew("expected array") + } + out := make([]string, 0, len(arr)*2) + for _, item := range arr { + out = append(out, flag, toString(item)) + } + return out, nil + + case "object": + encoded, err := serializeObjectLike(v) + if err != nil { + return nil, err + } + return []string{flag, encoded}, nil + + default: + encoded, err := serializeValueByType(opt.Type(), v) + if err != nil { + return nil, err + } + return []string{flag, encoded}, nil + } +} + +func serializeValueByType(valueType string, v any) (string, error) { + typeSchema := valueTypeToSchema(valueType) + typeName, _ := typeSchema["type"].(string) + + switch typeName { + case "object": + return serializeObjectLike(v) + default: + return toString(v), nil + } +} + +func serializeObjectLike(v any) (string, error) { + if s, ok := v.(string); ok { + return s, nil + } + + b, err := json.Marshal(v) + if err != nil { + return "", fmt.Errorf("expected object-compatible value: %w", err) + } + + return string(b), nil +} + +func toString(v any) string { + switch vv := v.(type) { + case nil: + return "" + case string: + return vv + case bool: + return strconv.FormatBool(vv) + case float64: + if vv == float64(int64(vv)) { + return strconv.FormatInt(int64(vv), 10) + } + return strconv.FormatFloat(vv, 'f', -1, 64) + case json.Number: + return vv.String() + default: + return fmt.Sprintf("%v", vv) + } +} + +func errorsNew(msg string) error { + return errors.New(msg) +} + +func buildToolResult(stdout, stderr string, runErr error) map[string]any { + var out bytes.Buffer + errText := "" + if stdout != "" { + _, _ = out.WriteString(stdout) + } + if stderr != "" { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("stderr:\n") + _, _ = out.WriteString(stderr) + } + if runErr != nil { + errText = runErr.Error() + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("error:\n") + _, _ = out.WriteString(errText) + } + if out.Len() == 0 { + _, _ = out.WriteString("ok") + } + + combined := out.String() + structured := map[string]any{ + "ok": runErr == nil, + "stdout": stdout, + "stderr": stderr, + "error": errText, + "combined": combined, + } + + return map[string]any{ + "content": []map[string]any{{ + "type": "text", + "text": combined, + }}, + "isError": runErr != nil, + "structuredContent": structured, + } +} + +func isSystemFlag(flag string) bool { + switch flag { + case "help", "list-commands", "list-flags", "args": + return true + default: + return false + } +} diff --git a/internal/mcpserver/tools_mapping_test.go b/internal/mcpserver/tools_mapping_test.go new file mode 100644 index 0000000..37b39b0 --- /dev/null +++ b/internal/mcpserver/tools_mapping_test.go @@ -0,0 +1,529 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/pubgo/redant" +) + +func TestCollectToolsCommandToToolDefComprehensive(t *testing.T) { + var ( + verbose bool + parentVal string + runVal string + target string + ) + + root := &redant.Command{ + Use: "app", + Options: redant.OptionSet{ + {Flag: "verbose", Value: redant.BoolOf(&verbose), Description: "enable verbose output"}, + {Flag: "internal", Value: redant.StringOf(new(string)), Hidden: true}, + }, + } + + group := &redant.Command{ + Use: "group", + Short: "group command", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal), Description: "inherited from parent"}, + }, + } + + run := &redant.Command{ + Use: "run", + Short: "run short", + Long: "run long description", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target), Description: "target name"}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal), Description: "child flag"}, + {Flag: "hidden-child", Value: redant.StringOf(new(string)), Hidden: true}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } + + hidden := &redant.Command{ + Use: "hidden", + Hidden: true, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + } + + echo := &redant.Command{ + Use: "echo", + Short: "echo short", + Aliases: []string{"e"}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + } + + group.Children = append(group.Children, run, hidden) + root.Children = append(root.Children, group, echo) + + tools := collectTools(root) + if len(tools) != 2 { + t.Fatalf("tools len = %d, want 2", len(tools)) + } + + runTool := mustFindToolByName(t, tools, "group.run") + if runTool.Description != "run short\n\nrun long description" { + t.Fatalf("run tool description = %q", runTool.Description) + } + + if got := runTool.PathTokens; !reflect.DeepEqual(got, []string{"group", "run"}) { + t.Fatalf("run tool path tokens = %#v", got) + } + + flagsSchema, ok := runTool.InputSchema["properties"].(map[string]any)["flags"].(map[string]any) + if !ok { + t.Fatalf("run tool flags schema missing") + } + flagProps, ok := flagsSchema["properties"].(map[string]any) + if !ok { + t.Fatalf("run tool flags properties missing") + } + + for _, want := range []string{"verbose", "parent-flag", "run-flag"} { + if _, exists := flagProps[want]; !exists { + t.Fatalf("missing expected flag %q in schema", want) + } + } + for _, notWant := range []string{"internal", "hidden-child", "help", "list-commands", "list-flags", "args"} { + if _, exists := flagProps[notWant]; exists { + t.Fatalf("unexpected flag %q in schema", notWant) + } + } + + argsSchema, ok := runTool.InputSchema["properties"].(map[string]any)["args"].(map[string]any) + if !ok { + t.Fatalf("run tool args schema missing") + } + required, ok := argsSchema["required"].([]string) + if !ok || len(required) != 1 || required[0] != "target" { + t.Fatalf("args required = %#v, want [target]", argsSchema["required"]) + } + + echoTool := mustFindToolByName(t, tools, "echo") + if got := echoTool.PathTokens; !reflect.DeepEqual(got, []string{"echo"}) { + t.Fatalf("echo tool path tokens = %#v", got) + } + if _, exists := echoTool.InputSchema["properties"].(map[string]any)["args"]; !exists { + t.Fatalf("echo tool args schema missing") + } +} + +func TestBuildArgvDeterministicAndInheritedFlags(t *testing.T) { + var ( + verbose bool + parentVal string + runVal string + target string + ) + + root := &redant.Command{ + Use: "app", + Options: redant.OptionSet{ + {Flag: "verbose", Value: redant.BoolOf(&verbose)}, + }, + } + group := &redant.Command{ + Use: "group", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal)}, + }, + } + run := &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } + group.Children = append(group.Children, run) + root.Children = append(root.Children, group) + + runTool := mustFindToolByName(t, collectTools(root), "group.run") + argv, err := buildArgv(runTool, map[string]any{ + "flags": map[string]any{ + "run-flag": "rv", + "parent-flag": "pv", + "verbose": true, + }, + "args": map[string]any{"target": "svc"}, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + want := []string{"group", "run", "--parent-flag", "pv", "--run-flag", "rv", "--verbose", "svc"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } +} + +func TestBuildArgvRejectsUnknownFlag(t *testing.T) { + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{ + {Name: "message", Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + echoTool := mustFindToolByName(t, collectTools(root), "echo") + _, err := buildArgv(echoTool, map[string]any{ + "flags": map[string]any{"not-exists": "x"}, + }) + if err == nil { + t.Fatalf("expected unknown flag error, got nil") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCallToolWithInheritedFlags(t *testing.T) { + var ( + parentVal string + runVal string + target string + ) + + root := &redant.Command{Use: "app"} + group := &redant.Command{ + Use: "group", + Options: redant.OptionSet{ + {Flag: "parent-flag", Value: redant.StringOf(&parentVal)}, + }, + } + run := &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Options: redant.OptionSet{ + {Flag: "run-flag", Value: redant.StringOf(&runVal)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = fmt.Fprintf(inv.Stdout, "parent=%s run=%s target=%s", parentVal, runVal, target) + return nil + }, + } + group.Children = append(group.Children, run) + root.Children = append(root.Children, group) + + s := New(root) + result, err := s.callTool(context.Background(), toolsCallParams{ + Name: "group.run", + Arguments: map[string]any{ + "flags": map[string]any{ + "parent-flag": "pv", + "run-flag": "rv", + }, + "args": map[string]any{"target": "svc"}, + }, + }) + if err != nil { + t.Fatalf("callTool error: %v", err) + } + + structured, ok := result["structuredContent"].(map[string]any) + if !ok { + t.Fatalf("structuredContent missing: %#v", result) + } + stdout, _ := structured["stdout"].(string) + if !strings.Contains(stdout, "parent=pv run=rv target=svc") { + t.Fatalf("stdout = %q", stdout) + } +} + +func TestBuildFlagsSchemaComplexTypesAndRequiredRules(t *testing.T) { + var ( + count int64 + ratio float64 + enable bool + items []string + mode string + tags []string + token string + port string + ) + + schema := buildFlagsSchema(redant.OptionSet{ + {Flag: "count", Value: redant.Int64Of(&count), Required: true}, + {Flag: "ratio", Value: redant.Float64Of(&ratio)}, + {Flag: "enable", Value: redant.BoolOf(&enable)}, + {Flag: "items", Value: redant.StringArrayOf(&items)}, + {Flag: "mode", Value: redant.EnumOf(&mode, "fast", "slow")}, + {Flag: "tags", Value: redant.EnumArrayOf(&tags, "a", "b")}, + {Flag: "token", Value: redant.StringOf(&token), Required: true, Envs: []string{"TOKEN"}}, + {Flag: "port", Value: redant.StringOf(&port), Required: true, Default: "8080"}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + + assertSchemaType(t, props["count"], "integer") + assertSchemaType(t, props["ratio"], "number") + assertSchemaType(t, props["enable"], "boolean") + assertSchemaType(t, props["items"], "array") + assertSchemaType(t, props["mode"], "string") + assertSchemaType(t, props["tags"], "array") + + modeSchema := props["mode"].(map[string]any) + modeEnum, ok := modeSchema["enum"].([]string) + if !ok || !reflect.DeepEqual(modeEnum, []string{"fast", "slow"}) { + t.Fatalf("mode enum = %#v", modeSchema["enum"]) + } + + tagsItems := props["tags"].(map[string]any)["items"].(map[string]any) + tagsEnum, ok := tagsItems["enum"].([]string) + if !ok || !reflect.DeepEqual(tagsEnum, []string{"a", "b"}) { + t.Fatalf("tags enum = %#v", tagsItems["enum"]) + } + + tokenSchema := props["token"].(map[string]any) + xenv, ok := tokenSchema["x-env"].([]string) + if !ok || !reflect.DeepEqual(xenv, []string{"TOKEN"}) { + t.Fatalf("token x-env = %#v", tokenSchema["x-env"]) + } + + portSchema := props["port"].(map[string]any) + if got, _ := portSchema["default"].(string); got != "8080" { + t.Fatalf("port default = %q", got) + } + + required, ok := schema["required"].([]string) + if !ok { + t.Fatalf("required missing") + } + if !reflect.DeepEqual(required, []string{"count"}) { + t.Fatalf("required = %#v, want [count]", required) + } +} + +func TestBuildArgsSchemaUnnamedAndDefault(t *testing.T) { + var ( + first string + mode string + ) + + schema := buildArgsSchema(redant.ArgSet{ + {Name: "", Required: true, Value: redant.StringOf(&first), Description: "first positional"}, + {Name: "mode", Required: true, Default: "auto", Value: redant.StringOf(&mode)}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("args properties missing") + } + + arg1, ok := props["arg1"].(map[string]any) + if !ok { + t.Fatalf("arg1 schema missing") + } + if got, _ := arg1["description"].(string); got != "first positional" { + t.Fatalf("arg1 description = %q", got) + } + + modeSchema, ok := props["mode"].(map[string]any) + if !ok { + t.Fatalf("mode schema missing") + } + if got, _ := modeSchema["default"].(string); got != "auto" { + t.Fatalf("mode default = %q", got) + } + + required, ok := schema["required"].([]string) + if !ok { + t.Fatalf("required missing") + } + if !reflect.DeepEqual(required, []string{"arg1"}) { + t.Fatalf("required = %#v, want [arg1]", required) + } +} + +func TestBuildArgvArrayFlagAndNoArgSetArgs(t *testing.T) { + var tags []string + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "scan", + Options: redant.OptionSet{ + {Flag: "tags", Value: redant.StringArrayOf(&tags)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "scan") + argv, err := buildArgv(tool, map[string]any{ + "flags": map[string]any{"tags": []any{"x", "y"}}, + "args": []any{"p1", "p2"}, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + want := []string{"scan", "--tags", "x", "--tags", "y", "p1", "p2"} + if !reflect.DeepEqual(argv, want) { + t.Fatalf("argv = %#v, want %#v", argv, want) + } +} + +func TestBuildArgvMissingRequiredArg(t *testing.T) { + var target string + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "run", + Args: redant.ArgSet{ + {Name: "target", Required: true, Value: redant.StringOf(&target)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "run") + _, err := buildArgv(tool, map[string]any{ + "args": map[string]any{}, + }) + if err == nil { + t.Fatalf("expected missing required arg error, got nil") + } + if !strings.Contains(err.Error(), "missing required arg") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildFlagsSchemaStructGeneric(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + var cfg payload + schema := buildFlagsSchema(redant.OptionSet{ + {Flag: "config", Value: &redant.Struct[payload]{Value: cfg}, Description: "service config"}, + }) + + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("flags properties missing") + } + + configSchema, ok := props["config"].(map[string]any) + if !ok { + t.Fatalf("config schema missing") + } + if got, _ := configSchema["type"].(string); got != "object" { + t.Fatalf("config type = %q, want object", got) + } + if ap, _ := configSchema["additionalProperties"].(bool); !ap { + t.Fatalf("config additionalProperties = %#v, want true", configSchema["additionalProperties"]) + } + if xvt, _ := configSchema["x-redant-value-type"].(string); !strings.HasPrefix(xvt, "struct[") { + t.Fatalf("x-redant-value-type = %q, want prefix struct[", xvt) + } +} + +func TestBuildArgvSupportsStructFlagAndArg(t *testing.T) { + type payload struct { + Name string `json:"name" yaml:"name"` + Port int `json:"port" yaml:"port"` + } + + var ( + argCfg payload + flagCfg payload + ) + + root := &redant.Command{Use: "app"} + root.Children = append(root.Children, &redant.Command{ + Use: "apply", + Args: redant.ArgSet{ + {Name: "config", Required: true, Value: &redant.Struct[payload]{Value: argCfg}}, + }, + Options: redant.OptionSet{ + {Flag: "meta", Value: &redant.Struct[payload]{Value: flagCfg}}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + }) + + tool := mustFindToolByName(t, collectTools(root), "apply") + argv, err := buildArgv(tool, map[string]any{ + "flags": map[string]any{ + "meta": map[string]any{"name": "svc", "port": 9000}, + }, + "args": map[string]any{ + "config": map[string]any{"name": "api", "port": 8080}, + }, + }) + if err != nil { + t.Fatalf("buildArgv error: %v", err) + } + + if len(argv) != 4 { + t.Fatalf("argv len = %d, want 4, argv=%#v", len(argv), argv) + } + if argv[0] != "apply" || argv[1] != "--meta" { + t.Fatalf("argv prefix = %#v, want [apply --meta ...]", argv[:2]) + } + + assertJSONStringObjectContains(t, argv[2], map[string]any{"name": "svc", "port": float64(9000)}) + assertJSONStringObjectContains(t, argv[3], map[string]any{"name": "api", "port": float64(8080)}) +} + +func assertJSONStringObjectContains(t *testing.T, raw string, want map[string]any) { + t.Helper() + + var got map[string]any + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("expected JSON object string, got %q (err: %v)", raw, err) + } + + for k, v := range want { + gv, ok := got[k] + if !ok { + t.Fatalf("json key %q missing in %q", k, raw) + } + if !reflect.DeepEqual(gv, v) { + t.Fatalf("json key %q value = %#v, want %#v", k, gv, v) + } + } +} + +func assertSchemaType(t *testing.T, raw any, want string) { + t.Helper() + schema, ok := raw.(map[string]any) + if !ok { + t.Fatalf("schema is not object: %#v", raw) + } + if got, _ := schema["type"].(string); got != want { + t.Fatalf("schema type = %q, want %q", got, want) + } +} + +func mustFindToolByName(t *testing.T, tools []toolDef, name string) toolDef { + t.Helper() + for _, td := range tools { + if td.Name == name { + return td + } + } + t.Fatalf("tool %q not found in %#v", name, tools) + return toolDef{} +} diff --git a/internal/pretty/README.md b/internal/pretty/README.md index 48bbf50..d0c5467 100644 --- a/internal/pretty/README.md +++ b/internal/pretty/README.md @@ -42,7 +42,7 @@ 1. 明确变更动机(兼容性、性能、可维护性)。 2. 评估是否影响 `help.go` 现有输出。 3. 完成代码改动后执行格式化与测试。 -4. 更新 `docs/CHANGELOG.md` 记录变更。 +4. 更新 `.version/changelog/Unreleased.md` 记录变更。 ## 6. 验证建议 diff --git a/internal/webui/assets.go b/internal/webui/assets.go new file mode 100644 index 0000000..7b45f32 --- /dev/null +++ b/internal/webui/assets.go @@ -0,0 +1,6 @@ +package webui + +import _ "embed" + +//go:embed static/index.html +var indexHTML string diff --git a/internal/webui/pty_signal_unix.go b/internal/webui/pty_signal_unix.go new file mode 100644 index 0000000..e1dba2e --- /dev/null +++ b/internal/webui/pty_signal_unix.go @@ -0,0 +1,82 @@ +//go:build !windows + +package webui + +import ( + "fmt" + "os" + "os/exec" + "syscall" + + "golang.org/x/sys/unix" +) + +func prepareInteractiveShellCmd(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func signalPTYForegroundProcessGroup(ptmx *os.File, signalName string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + + pgid, err := unix.IoctlGetInt(int(ptmx.Fd()), unix.TIOCGPGRP) + if err != nil { + return fmt.Errorf("read pty foreground process group failed: %w", err) + } + if pgid <= 0 { + return fmt.Errorf("invalid pty foreground process group: %d", pgid) + } + + var sig unix.Signal + switch signalName { + case "INT": + sig = unix.SIGINT + case "TSTP": + sig = unix.SIGTSTP + default: + return fmt.Errorf("unsupported signal name: %s", signalName) + } + + if err := unix.Kill(-pgid, sig); err != nil { + return fmt.Errorf("send signal to foreground process group failed: %w", err) + } + + return nil +} + +func signalProcessGroupByPID(pid int, signalName string) error { + if pid <= 0 { + return fmt.Errorf("invalid pid: %d", pid) + } + + pgid, err := unix.Getpgid(pid) + if err != nil { + return fmt.Errorf("get process group by pid failed: %w", err) + } + if pgid <= 0 { + return fmt.Errorf("invalid process group id: %d", pgid) + } + + var sig unix.Signal + switch signalName { + case "INT": + sig = unix.SIGINT + case "TSTP": + sig = unix.SIGTSTP + default: + return fmt.Errorf("unsupported signal name: %s", signalName) + } + + if err := unix.Kill(-pgid, sig); err != nil { + return fmt.Errorf("send signal to process group failed: %w", err) + } + + return nil +} diff --git a/internal/webui/pty_signal_windows.go b/internal/webui/pty_signal_windows.go new file mode 100644 index 0000000..03e0481 --- /dev/null +++ b/internal/webui/pty_signal_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package webui + +import ( + "fmt" + "os" + "os/exec" +) + +func prepareInteractiveShellCmd(cmd *exec.Cmd) { + _ = cmd +} + +func signalPTYForegroundProcessGroup(ptmx *os.File, signalName string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + return fmt.Errorf("signal forwarding is not supported on windows") +} + +func signalProcessGroupByPID(pid int, signalName string) error { + if pid <= 0 { + return fmt.Errorf("invalid pid: %d", pid) + } + return fmt.Errorf("signal forwarding is not supported on windows") +} diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..2b2108f --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,1290 @@ +package webui + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/creack/pty" + "github.com/spf13/pflag" + + "github.com/pubgo/redant" +) + +type ArgMeta struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + EnumValues []string `json:"enumValues,omitempty"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` +} + +type FlagMeta struct { + Name string `json:"name"` + Shorthand string `json:"shorthand,omitempty"` + Envs []string `json:"envs,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + EnumValues []string `json:"enumValues,omitempty"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` +} + +type CommandMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Use string `json:"use"` + Aliases []string `json:"aliases,omitempty"` + Short string `json:"short,omitempty"` + Long string `json:"long,omitempty"` + Deprecated string `json:"deprecated,omitempty"` + RawArgs bool `json:"rawArgs"` + Path []string `json:"path"` + Description string `json:"description,omitempty"` + Flags []FlagMeta `json:"flags"` + Args []ArgMeta `json:"args"` +} + +type RunRequest struct { + Command string `json:"command"` + Flags map[string]any `json:"flags,omitempty"` + Args map[string]any `json:"args,omitempty"` + RawArgs []string `json:"rawArgs,omitempty"` + Stdin string `json:"stdin,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` +} + +type RunResponse struct { + OK bool `json:"ok"` + TimedOut bool `json:"timedOut,omitempty"` + Command string `json:"command"` + Program string `json:"program,omitempty"` + Argv []string `json:"argv,omitempty"` + Invocation string `json:"invocation"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Error string `json:"error"` + Combined string `json:"combined"` +} + +type commandListResponse struct { + Commands []CommandMeta `json:"commands"` +} + +type wsRunMessage struct { + Type string `json:"type"` + Request *RunRequest `json:"request,omitempty"` + Data string `json:"data,omitempty"` + Rows int `json:"rows,omitempty"` + Cols int `json:"cols,omitempty"` + OK bool `json:"ok,omitempty"` + Error string `json:"error,omitempty"` + TimedOut bool `json:"timedOut,omitempty"` + Command string `json:"command,omitempty"` + Program string `json:"program,omitempty"` + WorkingDir string `json:"workingDir,omitempty"` + Argv []string `json:"argv,omitempty"` + Invocation string `json:"invocation,omitempty"` +} + +const ( + defaultRunTimeout = 30 * time.Second + maxRunTimeout = 10 * time.Minute + wsStartTimeout = 30 * time.Second +) + +type App struct { + root *redant.Command + commands []CommandMeta + byID map[string]CommandMeta + mu sync.Mutex +} + +func New(root *redant.Command) *App { + cmds := collectCommands(root) + byID := make(map[string]CommandMeta, len(cmds)) + for _, c := range cmds { + byID[c.ID] = c + } + return &App{root: root, commands: cmds, byID: byID} +} + +func (a *App) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", a.handleIndex) + mux.HandleFunc("/api/commands", a.handleCommands) + mux.HandleFunc("/api/run", a.handleRun) + mux.HandleFunc("/api/run/ws", a.handleRunWS) + mux.HandleFunc("/api/terminal/ws", a.handleTerminalWS) + return mux +} + +func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +func (a *App) handleCommands(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(commandListResponse{Commands: a.commands}) +} + +func (a *App) handleRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req RunRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request: %v", err), http.StatusBadRequest) + return + } + + meta, ok := a.byID[strings.TrimSpace(req.Command)] + if !ok { + http.Error(w, fmt.Sprintf("unknown command: %q", req.Command), http.StatusBadRequest) + return + } + + argv, program, invocation, err := buildInvocation(meta, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + runCtx, cancel := context.WithTimeout(r.Context(), resolveRunTimeout(req.TimeoutSeconds)) + defer cancel() + + a.mu.Lock() + runErr := func() error { + root := cloneCommandTree(a.root) + inv := root.Invoke(argv...) + inv.Stdout = &stdout + inv.Stderr = &stderr + inv.Stdin = bytes.NewReader([]byte(req.Stdin)) + return inv.WithContext(runCtx).Run() + }() + a.mu.Unlock() + + timedOut := errors.Is(runErr, context.DeadlineExceeded) + displayErr := withNonTTYTimeoutHint(runErr, timedOut) + + resp := RunResponse{ + OK: runErr == nil, + TimedOut: timedOut, + Command: meta.ID, + Program: program, + Argv: append([]string(nil), argv...), + Invocation: invocation, + Stdout: stdout.String(), + Stderr: stderr.String(), + Combined: combineOutput(stdout.String(), stderr.String(), displayErr), + } + if displayErr != nil { + resp.Error = displayErr.Error() + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func (a *App) handleRunWS(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "done") + }() + + var sendMu sync.Mutex + send := func(msg wsRunMessage) error { + sendMu.Lock() + defer sendMu.Unlock() + + writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return wsjson.Write(writeCtx, conn, msg) + } + + startCtx, cancelStart := context.WithTimeout(r.Context(), wsStartTimeout) + defer cancelStart() + + var start wsRunMessage + if err := wsjson.Read(startCtx, conn, &start); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("read start message failed: %v", err)}) + _ = conn.Close(websocket.StatusPolicyViolation, "invalid start message") + return + } + + if start.Type != "start" || start.Request == nil { + _ = send(wsRunMessage{Type: "error", Error: "first message must be {type:start, request:{...}}"}) + _ = conn.Close(websocket.StatusPolicyViolation, "missing start request") + return + } + + req := *start.Request + meta, ok := a.byID[strings.TrimSpace(req.Command)] + if !ok { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("unknown command: %q", req.Command)}) + _ = conn.Close(websocket.StatusPolicyViolation, "unknown command") + return + } + + argv, program, invocation, err := buildInvocation(meta, req) + if err != nil { + _ = send(wsRunMessage{Type: "error", Error: err.Error()}) + _ = conn.Close(websocket.StatusPolicyViolation, "invalid invocation") + return + } + + runCtx := r.Context() + var cancelRun context.CancelFunc + if req.TimeoutSeconds > 0 { + var cancel context.CancelFunc + runCtx, cancel = context.WithTimeout(r.Context(), resolveRunTimeout(req.TimeoutSeconds)) + cancelRun = cancel + } else { + var cancel context.CancelFunc + runCtx, cancel = context.WithCancel(r.Context()) + cancelRun = cancel + } + defer cancelRun() + + ptmx, pts, err := pty.Open() + if err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("open pty failed: %v", err)}) + _ = conn.Close(websocket.StatusInternalError, "open pty failed") + return + } + var closePTMXOnce sync.Once + closePTMX := func() { + closePTMXOnce.Do(func() { + _ = ptmx.Close() + }) + } + var closePTSOnce sync.Once + closePTS := func() { + closePTSOnce.Do(func() { + _ = pts.Close() + }) + } + closePTYFiles := func() { + closePTS() + closePTMX() + } + defer closePTYFiles() + + if err := setPTYSize(ptmx, start.Cols, start.Rows); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("set terminal size failed: %v", err)}) + } + + if err := send(wsRunMessage{ + Type: "started", + Command: meta.ID, + Program: program, + Argv: append([]string(nil), argv...), + Invocation: invocation, + }); err != nil { + return + } + + if req.Stdin != "" { + if _, err := ptmx.Write([]byte(req.Stdin)); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("write initial stdin failed: %v", err)}) + _ = conn.Close(websocket.StatusInternalError, "write stdin failed") + return + } + } + + root := cloneCommandTree(a.root) + inv := root.Invoke(argv...) + inv.Stdin = pts + inv.Stdout = pts + inv.Stderr = pts + + runErrCh := make(chan error, 1) + go func() { + a.mu.Lock() + defer a.mu.Unlock() + runErrCh <- inv.WithContext(runCtx).Run() + }() + + readErrCh := make(chan error, 1) + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + if sendErr := send(wsRunMessage{Type: "output", Data: string(buf[:n])}); sendErr != nil { + readErrCh <- sendErr + return + } + } + if err != nil { + if isExpectedPTYReadClose(err) { + readErrCh <- nil + } else { + readErrCh <- err + } + return + } + } + }() + + inMsgCh := make(chan wsRunMessage, 8) + inErrCh := make(chan error, 1) + go func() { + for { + var msg wsRunMessage + if err := wsjson.Read(runCtx, conn, &msg); err != nil { + inErrCh <- err + return + } + inMsgCh <- msg + } + }() + + var extraErr error + readPumpDone := false + for { + select { + case msg := <-inMsgCh: + switch msg.Type { + case "stdin": + if msg.Data == "" { + continue + } + if err := writePTYInput(ptmx, pts, 0, msg.Data); err != nil { + extraErr = errors.Join(extraErr, err) + cancelRun() + closePTS() + } + case "resize": + if err := setPTYSize(ptmx, msg.Cols, msg.Rows); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("resize terminal failed: %v", err)}) + } + case "close": + cancelRun() + closePTS() + default: + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("unknown message type: %s", msg.Type)}) + } + case err := <-inErrCh: + status := websocket.CloseStatus(err) + if status != websocket.StatusNormalClosure && status != websocket.StatusGoingAway && status != websocket.StatusNoStatusRcvd && !errors.Is(err, context.Canceled) { + extraErr = errors.Join(extraErr, err) + } + cancelRun() + closePTS() + case err := <-readErrCh: + readPumpDone = true + if err != nil && websocket.CloseStatus(err) == -1 && !errors.Is(err, context.Canceled) { + extraErr = errors.Join(extraErr, err) + } + case err := <-runErrCh: + if !readPumpDone { + select { + case readErr := <-readErrCh: + if readErr != nil && websocket.CloseStatus(readErr) == -1 && !errors.Is(readErr, context.Canceled) { + extraErr = errors.Join(extraErr, readErr) + } + case <-time.After(300 * time.Millisecond): + closePTS() + closePTMX() + } + } + closePTS() + closePTMX() + joinedErr := errors.Join(err, extraErr) + timedOut := errors.Is(err, context.DeadlineExceeded) || errors.Is(runCtx.Err(), context.DeadlineExceeded) + displayErr := withInteractiveWSTimeoutHint(joinedErr, timedOut) + + exitMsg := wsRunMessage{Type: "exit", OK: displayErr == nil, TimedOut: timedOut} + if displayErr != nil { + exitMsg.Error = displayErr.Error() + } + _ = send(exitMsg) + if displayErr != nil { + _ = conn.Close(websocket.StatusInternalError, "command failed") + } else { + _ = conn.Close(websocket.StatusNormalClosure, "command completed") + } + return + } + } +} + +func (a *App) handleTerminalWS(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer func() { + _ = conn.Close(websocket.StatusNormalClosure, "done") + }() + + var sendMu sync.Mutex + send := func(msg wsRunMessage) error { + sendMu.Lock() + defer sendMu.Unlock() + + writeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return wsjson.Write(writeCtx, conn, msg) + } + + startCtx, cancelStart := context.WithTimeout(r.Context(), wsStartTimeout) + defer cancelStart() + + var start wsRunMessage + if err := wsjson.Read(startCtx, conn, &start); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("read start message failed: %v", err)}) + _ = conn.Close(websocket.StatusPolicyViolation, "invalid start message") + return + } + + if start.Type != "start" { + _ = send(wsRunMessage{Type: "error", Error: "first message must be {type:start}"}) + _ = conn.Close(websocket.StatusPolicyViolation, "missing start request") + return + } + + shellPath, shellArgs := detectInteractiveShell() + + runCtx, cancelRun := context.WithCancel(r.Context()) + defer cancelRun() + + ptmx, pts, err := pty.Open() + if err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("open pty failed: %v", err)}) + _ = conn.Close(websocket.StatusInternalError, "open pty failed") + return + } + var closePTYOnce sync.Once + closePTYFiles := func() { + closePTYOnce.Do(func() { + _ = ptmx.Close() + _ = pts.Close() + }) + } + defer closePTYFiles() + + if err := setPTYSize(ptmx, start.Cols, start.Rows); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("set terminal size failed: %v", err)}) + } + + cmd := exec.CommandContext(runCtx, shellPath, shellArgs...) + cmd.Stdin = pts + cmd.Stdout = pts + cmd.Stderr = pts + prepareInteractiveShellCmd(cmd) + cmd.Env = append(os.Environ(), "TERM=xterm-256color") + workingDir := "" + if wd, wdErr := os.Getwd(); wdErr == nil { + cmd.Dir = wd + workingDir = wd + } + + if err := cmd.Start(); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("start shell failed: %v", err)}) + _ = conn.Close(websocket.StatusInternalError, "start shell failed") + return + } + + if err := send(wsRunMessage{Type: "started", Program: shellPath, WorkingDir: workingDir, Invocation: shellPath}); err != nil { + return + } + + if start.Request != nil { + if err := a.execRequestInTerminal(ptmx, *start.Request, send); err != nil { + _ = send(wsRunMessage{Type: "error", Error: err.Error()}) + } + } + + shellErrCh := make(chan error, 1) + go func() { + shellErrCh <- cmd.Wait() + }() + + readErrCh := make(chan error, 1) + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + if sendErr := send(wsRunMessage{Type: "output", Data: string(buf[:n])}); sendErr != nil { + readErrCh <- sendErr + return + } + } + if err != nil { + if isExpectedPTYReadClose(err) { + readErrCh <- nil + } else { + readErrCh <- err + } + return + } + } + }() + + inMsgCh := make(chan wsRunMessage, 8) + inErrCh := make(chan error, 1) + go func() { + for { + var msg wsRunMessage + if err := wsjson.Read(runCtx, conn, &msg); err != nil { + inErrCh <- err + return + } + inMsgCh <- msg + } + }() + + var extraErr error + for { + select { + case msg := <-inMsgCh: + switch msg.Type { + case "stdin": + if msg.Data == "" { + continue + } + if err := writePTYInput(ptmx, pts, shellProcessPID(cmd), msg.Data); err != nil { + extraErr = errors.Join(extraErr, err) + cancelRun() + closePTYFiles() + } + case "resize": + if err := setPTYSize(ptmx, msg.Cols, msg.Rows); err != nil { + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("resize terminal failed: %v", err)}) + } + case "exec": + if msg.Request == nil { + _ = send(wsRunMessage{Type: "error", Error: "exec message requires request payload"}) + continue + } + if err := a.execRequestInTerminal(ptmx, *msg.Request, send); err != nil { + _ = send(wsRunMessage{Type: "error", Error: err.Error()}) + } + case "close": + cancelRun() + closePTYFiles() + default: + _ = send(wsRunMessage{Type: "error", Error: fmt.Sprintf("unknown message type: %s", msg.Type)}) + } + case err := <-inErrCh: + status := websocket.CloseStatus(err) + if status != websocket.StatusNormalClosure && status != websocket.StatusGoingAway && status != websocket.StatusNoStatusRcvd && !errors.Is(err, context.Canceled) { + extraErr = errors.Join(extraErr, err) + } + cancelRun() + closePTYFiles() + case err := <-readErrCh: + if err != nil && websocket.CloseStatus(err) == -1 && !errors.Is(err, context.Canceled) { + extraErr = errors.Join(extraErr, err) + } + case err := <-shellErrCh: + closePTYFiles() + joinedErr := errors.Join(err, extraErr) + exitMsg := wsRunMessage{Type: "exit", OK: joinedErr == nil} + if joinedErr != nil { + exitMsg.Error = joinedErr.Error() + } + _ = send(exitMsg) + if joinedErr != nil { + _ = conn.Close(websocket.StatusInternalError, "shell exited with error") + } else { + _ = conn.Close(websocket.StatusNormalClosure, "shell exited") + } + return + } + } +} + +func (a *App) execRequestInTerminal(ptmx *os.File, req RunRequest, send func(wsRunMessage) error) error { + meta, ok := a.byID[strings.TrimSpace(req.Command)] + if !ok { + return fmt.Errorf("unknown command: %q", req.Command) + } + + argv, program, invocation, err := buildInvocation(meta, req) + if err != nil { + return err + } + + line, err := buildExecutableCommandLine(argv) + if err != nil { + return err + } + + if err := send(wsRunMessage{ + Type: "invocation", + Command: meta.ID, + Program: program, + Argv: append([]string(nil), argv...), + Invocation: invocation, + }); err != nil { + return err + } + + if _, err := ptmx.Write([]byte(line + "\n")); err != nil { + return fmt.Errorf("write command to terminal failed: %w", err) + } + + return nil +} + +func buildExecutableCommandLine(argv []string) (string, error) { + exePath, err := os.Executable() + if err != nil { + if len(os.Args) == 0 || strings.TrimSpace(os.Args[0]) == "" { + return "", fmt.Errorf("resolve executable path failed: %w", err) + } + exePath = os.Args[0] + } + + tokens := make([]string, 0, len(argv)+1) + tokens = append(tokens, shellQuote(exePath)) + for _, arg := range argv { + tokens = append(tokens, shellQuote(arg)) + } + return strings.Join(tokens, " "), nil +} + +func detectInteractiveShell() (string, []string) { + if runtime.GOOS == "windows" { + if shell := strings.TrimSpace(os.Getenv("COMSPEC")); shell != "" { + return shell, nil + } + return "cmd.exe", nil + } + + if shell := strings.TrimSpace(os.Getenv("SHELL")); shell != "" { + return shell, []string{"-i"} + } + + return "/bin/sh", []string{"-i"} +} + +func buildInvocation(meta CommandMeta, req RunRequest) ([]string, string, string, error) { + argv := append([]string(nil), meta.Path...) + + for _, flag := range meta.Flags { + v, ok := req.Flags[flag.Name] + if !ok { + continue + } + tokens, err := serializeFlag(flag, v) + if err != nil { + return nil, "", "", fmt.Errorf("flag %q: %w", flag.Name, err) + } + argv = append(argv, tokens...) + } + + if len(meta.Args) > 0 { + for i, arg := range meta.Args { + v, ok := lookupArgValue(req.Args, arg.Name) + if !ok && i < len(req.RawArgs) { + v = req.RawArgs[i] + ok = true + } + if !ok { + if arg.Required && arg.Default == "" { + return nil, "", "", fmt.Errorf("missing required arg %q", arg.Name) + } + continue + } + val, err := serializeValueByType(arg.Type, v) + if err != nil { + return nil, "", "", fmt.Errorf("arg %q: %w", arg.Name, err) + } + argv = append(argv, val) + } + } else if len(req.RawArgs) > 0 { + argv = append(argv, req.RawArgs...) + } + + prog := filepath.Base(os.Args[0]) + invocation := prog + for _, token := range argv { + invocation += " " + shellQuote(token) + } + + return argv, prog, invocation, nil +} + +func lookupArgValue(args map[string]any, name string) (any, bool) { + if len(args) == 0 { + return nil, false + } + + if v, ok := args[name]; ok { + return v, true + } + + trimmed := strings.TrimSpace(name) + if trimmed != name { + if v, ok := args[trimmed]; ok { + return v, true + } + } + + for k, v := range args { + if strings.TrimSpace(k) == trimmed { + return v, true + } + } + + return nil, false +} + +func serializeFlag(flag FlagMeta, raw any) ([]string, error) { + name := "--" + flag.Name + if flag.Type == "bool" { + b, ok := parseBool(raw) + if !ok { + return nil, fmt.Errorf("expected boolean") + } + if b { + return []string{name}, nil + } + return []string{name + "=false"}, nil + } + + if isArrayType(flag.Type) { + vals, err := toStringSlice(raw) + if err != nil { + return nil, err + } + out := make([]string, 0, len(vals)*2) + for _, v := range vals { + out = append(out, name, v) + } + return out, nil + } + + value, err := serializeValueByType(flag.Type, raw) + if err != nil { + return nil, err + } + return []string{name, value}, nil +} + +func serializeValueByType(typ string, raw any) (string, error) { + if strings.HasPrefix(typ, "struct[") { + if s, ok := raw.(string); ok { + return s, nil + } + b, err := json.Marshal(raw) + if err != nil { + return "", fmt.Errorf("expected object-compatible value: %w", err) + } + return string(b), nil + } + return toString(raw), nil +} + +func toString(raw any) string { + switch v := raw.(type) { + case nil: + return "" + case string: + return v + case bool: + return strconv.FormatBool(v) + case float64: + if v == float64(int64(v)) { + return strconv.FormatInt(int64(v), 10) + } + return strconv.FormatFloat(v, 'f', -1, 64) + case json.Number: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} + +func parseBool(raw any) (bool, bool) { + switch v := raw.(type) { + case bool: + return v, true + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + return b, err == nil + default: + return false, false + } +} + +func toStringSlice(raw any) ([]string, error) { + switch v := raw.(type) { + case []string: + return append([]string(nil), v...), nil + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + out = append(out, toString(item)) + } + return out, nil + case string: + parts := strings.Split(v, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out, nil + default: + return nil, fmt.Errorf("expected array") + } +} + +func isArrayType(typ string) bool { + return typ == "string-array" || strings.HasPrefix(typ, "enum-array[") +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + needQuote := false + for _, ch := range s { + if !(ch == '_' || ch == '-' || ch == '.' || ch == '/' || ch == ':' || ch == '=' || (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) { + needQuote = true + break + } + } + if !needQuote { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} + +func combineOutput(stdout, stderr string, runErr error) string { + var out bytes.Buffer + if stdout != "" { + _, _ = out.WriteString(stdout) + } + if stderr != "" { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("stderr:\n") + _, _ = out.WriteString(stderr) + } + if runErr != nil { + if out.Len() > 0 { + _, _ = out.WriteString("\n") + } + _, _ = out.WriteString("error:\n") + _, _ = out.WriteString(runErr.Error()) + } + if out.Len() == 0 { + return "ok" + } + return out.String() +} + +func resolveRunTimeout(seconds int) time.Duration { + if seconds <= 0 { + return defaultRunTimeout + } + d := time.Duration(seconds) * time.Second + if d > maxRunTimeout { + return maxRunTimeout + } + return d +} + +func withNonTTYTimeoutHint(runErr error, timedOut bool) error { + if runErr == nil || !timedOut { + return runErr + } + + return fmt.Errorf( + "%w\nhint: command timed out; webcmd 不提供 TTY 交互。对于 bash/zsh/fish/vim/top 等交互式 shell,请在终端直接执行,或通过 stdin 传入一次性输入", + runErr, + ) +} + +func withInteractiveWSTimeoutHint(runErr error, timedOut bool) error { + if runErr == nil || !timedOut { + return runErr + } + + return fmt.Errorf( + "%w\nhint: interactive session timed out; 可增大 timeoutSeconds,或设为 0 表示不设超时", + runErr, + ) +} + +func setPTYSize(f *os.File, cols, rows int) error { + if f == nil { + return fmt.Errorf("pty file is nil") + } + if cols <= 0 { + cols = 80 + } + if rows <= 0 { + rows = 24 + } + if cols > 65535 { + cols = 65535 + } + if rows > 65535 { + rows = 65535 + } + + return pty.Setsize(f, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)}) +} + +func isExpectedPTYReadClose(err error) bool { + if err == nil { + return false + } + + return errors.Is(err, io.EOF) || + errors.Is(err, os.ErrClosed) || + errors.Is(err, syscall.EIO) +} + +func writePTYInput(ptmx, pts *os.File, processPID int, data string) error { + if ptmx == nil { + return fmt.Errorf("pty file is nil") + } + if data == "" { + return nil + } + + controlName, isControl := controlInputName(data) + if isControl && ttyDebugEnabled() { + log.Printf("[webui tty] received control input=%s", controlName) + } + + if handled, err := trySignalFromControlInput(pts, processPID, data); handled { + if err == nil { + if ttyDebugEnabled() { + log.Printf("[webui tty] signal path ok for control input=%s", controlName) + } + return nil + } + log.Printf("[webui tty] signal path failed for control input=%s: %v; fallback to raw write", controlName, err) + // fallback: 若信号路径不可用,继续走原始字节写入 + } + + _, err := ptmx.Write([]byte(data)) + if err == nil && isControl && ttyDebugEnabled() { + log.Printf("[webui tty] raw write fallback ok for control input=%s", controlName) + } + return err +} + +func controlInputName(data string) (string, bool) { + b := []byte(data) + if len(b) != 1 { + return "", false + } + switch b[0] { + case 0x03: + return "Ctrl+C", true + case 0x04: + return "Ctrl+D", true + case 0x1a: + return "Ctrl+Z", true + default: + return "", false + } +} + +func ttyDebugEnabled() bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv("REDANT_WEB_TTY_DEBUG"))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} + +func trySignalFromControlInput(pts *os.File, processPID int, data string) (bool, error) { + b := []byte(data) + if len(b) != 1 { + return false, nil + } + + switch b[0] { + case 0x03: // Ctrl+C + return true, signalFromControlInput(pts, processPID, "INT") + case 0x1a: // Ctrl+Z + return true, signalFromControlInput(pts, processPID, "TSTP") + default: + return false, nil + } +} + +func signalFromControlInput(pts *os.File, processPID int, signalName string) error { + err := signalPTYForegroundProcessGroup(pts, signalName) + if err == nil { + return nil + } + + if processPID <= 0 { + return err + } + + fallbackErr := signalProcessGroupByPID(processPID, signalName) + if fallbackErr == nil { + if ttyDebugEnabled() { + log.Printf("[webui tty] foreground pgrp signal failed (%v), fallback process group signal ok pid=%d signal=%s", err, processPID, signalName) + } + return nil + } + + return errors.Join(err, fmt.Errorf("fallback process group signal failed: %w", fallbackErr)) +} + +func shellProcessPID(cmd *exec.Cmd) int { + if cmd == nil || cmd.Process == nil { + return 0 + } + return cmd.Process.Pid +} + +func cloneCommandTree(cmd *redant.Command) *redant.Command { + if cmd == nil { + return nil + } + cpy := *cmd + cpy.Options = append(redant.OptionSet(nil), cmd.Options...) + cpy.Args = append(redant.ArgSet(nil), cmd.Args...) + cpy.Children = make([]*redant.Command, 0, len(cmd.Children)) + for _, child := range cmd.Children { + cpy.Children = append(cpy.Children, cloneCommandTree(child)) + } + return &cpy +} + +func collectCommands(root *redant.Command) []CommandMeta { + if root == nil { + return nil + } + + var out []CommandMeta + var walk func(cmd *redant.Command, path []string, inherited redant.OptionSet) + walk = func(cmd *redant.Command, path []string, inherited redant.OptionSet) { + if cmd == nil || cmd.Hidden { + return + } + + effective := make(redant.OptionSet, 0, len(inherited)+len(cmd.Options)) + effective = append(effective, inherited...) + effective = append(effective, cmd.Options...) + + if cmd.Handler != nil && len(path) > 0 && path[0] != "web" { + out = append(out, toCommandMeta(cmd, path, effective)) + } + + for _, child := range cmd.Children { + walk(child, append(path, child.Name()), effective) + } + } + + for _, child := range root.Children { + walk(child, []string{child.Name()}, root.Options) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +func toCommandMeta(cmd *redant.Command, path []string, opts redant.OptionSet) CommandMeta { + return CommandMeta{ + ID: strings.Join(path, " "), + Name: cmd.Name(), + Use: cmd.Use, + Aliases: append([]string(nil), cmd.Aliases...), + Short: strings.TrimSpace(cmd.Short), + Long: strings.TrimSpace(cmd.Long), + Deprecated: strings.TrimSpace(cmd.Deprecated), + RawArgs: cmd.RawArgs, + Path: append([]string(nil), path...), + Description: commandDescription(cmd), + Flags: toFlagMeta(opts), + Args: toArgMeta(cmd.Args), + } +} + +func toFlagMeta(opts redant.OptionSet) []FlagMeta { + byName := map[string]redant.Option{} + for _, opt := range opts { + if opt.Hidden || opt.Flag == "" || isSystemFlag(opt.Flag) { + continue + } + byName[opt.Flag] = opt + } + + names := make([]string, 0, len(byName)) + for n := range byName { + names = append(names, n) + } + sort.Strings(names) + + out := make([]FlagMeta, 0, len(names)) + for _, n := range names { + opt := byName[n] + out = append(out, FlagMeta{ + Name: opt.Flag, + Shorthand: opt.Shorthand, + Envs: append([]string(nil), opt.Envs...), + Description: strings.TrimSpace(opt.Description), + Type: opt.Type(), + EnumValues: extractEnumValues(opt.Value, opt.Type()), + Required: opt.Required, + Default: opt.Default, + }) + } + return out +} + +func toArgMeta(args redant.ArgSet) []ArgMeta { + out := make([]ArgMeta, 0, len(args)) + for i, arg := range args { + name := strings.TrimSpace(arg.Name) + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + typ := "string" + if arg.Value != nil { + if v, ok := arg.Value.(interface{ Type() string }); ok { + typ = v.Type() + } + } + out = append(out, ArgMeta{ + Name: name, + Description: strings.TrimSpace(arg.Description), + Type: typ, + EnumValues: extractEnumValues(arg.Value, typ), + Required: arg.Required, + Default: arg.Default, + }) + } + return out +} + +func extractEnumValues(value pflag.Value, typ string) []string { + vals := extractEnumValuesFromValue(value) + if len(vals) == 0 { + vals = parseEnumValuesFromType(typ) + } + return normalizeEnumValues(vals) +} + +func extractEnumValuesFromValue(value pflag.Value) []string { + if value == nil { + return nil + } + + switch v := value.(type) { + case *redant.Enum: + return append([]string(nil), v.Choices...) + case *redant.EnumArray: + return append([]string(nil), v.Choices...) + case interface{ Underlying() pflag.Value }: + return extractEnumValuesFromValue(v.Underlying()) + default: + return nil + } +} + +func parseEnumValuesFromType(typ string) []string { + if !(strings.HasPrefix(typ, "enum[") || strings.HasPrefix(typ, "enum-array[")) || !strings.HasSuffix(typ, "]") { + return nil + } + + start := strings.IndexByte(typ, '[') + if start < 0 || start+1 >= len(typ)-1 { + return nil + } + inner := typ[start+1 : len(typ)-1] + + var parts []string + if strings.Contains(inner, `\|`) { + parts = strings.Split(inner, `\|`) + } else { + parts = strings.Split(inner, "|") + } + return parts +} + +func normalizeEnumValues(values []string) []string { + if len(values) == 0 { + return nil + } + + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, raw := range values { + v := normalizeEnumValue(raw) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func normalizeEnumValue(value string) string { + v := strings.TrimSpace(value) + v = strings.ReplaceAll(v, `\|`, "|") + v = strings.Trim(v, " \\|,;[](){}\"'`") + return strings.TrimSpace(v) +} + +func commandDescription(cmd *redant.Command) string { + short := strings.TrimSpace(cmd.Short) + long := strings.TrimSpace(cmd.Long) + switch { + case short != "" && long != "": + return short + "\n\n" + long + case short != "": + return short + case long != "": + return long + default: + return "" + } +} + +func isSystemFlag(flag string) bool { + switch flag { + case "help", "list-commands", "list-flags", "args": + return true + default: + return false + } +} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go new file mode 100644 index 0000000..a24aa9f --- /dev/null +++ b/internal/webui/server_test.go @@ -0,0 +1,552 @@ +package webui + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + + "github.com/pubgo/redant" + "github.com/pubgo/redant/cmds/completioncmd" +) + +func TestCommandsEndpoint(t *testing.T) { + var global string + var local string + var format string + + root := &redant.Command{ + Use: "testapp", + Options: redant.OptionSet{ + {Flag: "global", Description: "global flag", Envs: []string{"GLOBAL_ENV"}, Value: redant.StringOf(&global)}, + }, + } + + echoCmd := &redant.Command{ + Use: "echo [text]", + Aliases: []string{"e"}, + Short: "echo text", + Long: "echo text long description", + Options: redant.OptionSet{ + {Flag: "local", Description: "local flag", Value: redant.StringOf(&local)}, + {Flag: "format", Description: "output format", Value: redant.EnumOf(&format, "json", "text")}, + }, + Args: redant.ArgSet{ + {Name: "text", Description: "text to print", Required: true, Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte("ok")) + return nil + }, + } + + webCmd := &redant.Command{Use: "web", Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }} + root.Children = append(root.Children, echoCmd, webCmd) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/api/commands") + if err != nil { + t.Fatalf("request commands: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var payload commandListResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("decode commands response: %v", err) + } + + if len(payload.Commands) != 1 { + t.Fatalf("expected 1 command (web should be excluded), got %d", len(payload.Commands)) + } + + cmd := payload.Commands[0] + if cmd.ID != "echo" { + t.Fatalf("expected command id echo, got %s", cmd.ID) + } + if cmd.Use != "echo [text]" { + t.Fatalf("expected use echo [text], got %s", cmd.Use) + } + if !slices.Equal(cmd.Aliases, []string{"e"}) { + t.Fatalf("unexpected aliases: %+v", cmd.Aliases) + } + if cmd.Short != "echo text" { + t.Fatalf("unexpected short: %s", cmd.Short) + } + if cmd.Long != "echo text long description" { + t.Fatalf("unexpected long: %s", cmd.Long) + } + if len(cmd.Args) != 1 || cmd.Args[0].Name != "text" { + t.Fatalf("unexpected args metadata: %+v", cmd.Args) + } + + flagNames := make([]string, 0, len(cmd.Flags)) + flagByName := make(map[string]FlagMeta, len(cmd.Flags)) + for _, f := range cmd.Flags { + flagNames = append(flagNames, f.Name) + flagByName[f.Name] = f + } + if !slices.Contains(flagNames, "global") || !slices.Contains(flagNames, "local") { + t.Fatalf("expected global+local flags, got: %v", flagNames) + } + if !slices.Equal(flagByName["format"].EnumValues, []string{"json", "text"}) { + t.Fatalf("unexpected format enum values: %+v", flagByName["format"].EnumValues) + } + if strings.TrimSpace(flagByName["local"].Description) == "" { + t.Fatalf("expected local flag description, got empty") + } + if !slices.Equal(flagByName["global"].Envs, []string{"GLOBAL_ENV"}) { + t.Fatalf("unexpected global envs: %+v", flagByName["global"].Envs) + } +} + +func TestIndexPageServedFromStatic(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{Use: "echo", Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }}) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/") + if err != nil { + t.Fatalf("request index: %v", err) + } + defer closeResponseBody(t, resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read index body: %v", err) + } + + page := string(body) + if !strings.Contains(page, "cdn.tailwindcss.com") { + t.Fatalf("expected tailwindcdn tag in page") + } + if !strings.Contains(page, "alpinejs") { + t.Fatalf("expected alpinejs tag in page") + } +} + +func TestRunEndpoint(t *testing.T) { + var text string + var upper bool + + root := &redant.Command{Use: "testapp"} + echoCmd := &redant.Command{ + Use: "echo", + Options: redant.OptionSet{ + {Flag: "upper", Description: "uppercase", Value: redant.BoolOf(&upper)}, + }, + Args: redant.ArgSet{ + {Name: "text", Required: true, Value: redant.StringOf(&text)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + out := text + if upper { + out = strings.ToUpper(text) + } + _, _ = inv.Stdout.Write([]byte(out)) + return nil + }, + } + root.Children = append(root.Children, echoCmd) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + requestBody := `{"command":"echo","flags":{"upper":true},"args":{"text":"hello"}}` + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(requestBody)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", resp.StatusCode) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + + if !runResp.OK { + t.Fatalf("expected ok response, got error=%s stderr=%s", runResp.Error, runResp.Stderr) + } + if runResp.Stdout != "HELLO" { + t.Fatalf("expected HELLO, got %q", runResp.Stdout) + } + if !strings.Contains(runResp.Invocation, "echo") || !strings.Contains(runResp.Invocation, "--upper") { + t.Fatalf("unexpected invocation: %s", runResp.Invocation) + } +} + +func TestRunEndpointMissingRequiredArg(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{{Name: "text", Required: true, Value: redant.StringOf(new(string))}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"echo","args":{}}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for missing arg, got %d", resp.StatusCode) + } +} + +func TestRunEndpointUsesRawArgsFallback(t *testing.T) { + var text string + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Args: redant.ArgSet{{Name: "text", Required: true, Value: redant.StringOf(&text)}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte(text)) + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"echo","rawArgs":["fallback-text"]}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(payload)) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + if !runResp.OK { + t.Fatalf("expected ok, got error=%s", runResp.Error) + } + if runResp.Stdout != "fallback-text" { + t.Fatalf("expected fallback-text, got %q", runResp.Stdout) + } + if !strings.Contains(runResp.Invocation, "fallback-text") { + t.Fatalf("expected invocation contains arg, got %q", runResp.Invocation) + } +} + +func TestRunEndpointWithPreInitializedRootNoDuplicateEnvPanic(t *testing.T) { + root := &redant.Command{Use: "testapp"} + completioncmd.AddCompletionCommand(root) + + // 先执行一次命令,模拟 web 子命令启动前根命令已初始化的真实场景。 + pre := root.Invoke("completion", "bash") + pre.Stdout = &bytes.Buffer{} + pre.Stderr = &bytes.Buffer{} + if err := pre.Run(); err != nil { + t.Fatalf("pre-initialize root failed: %v", err) + } + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + body := `{"command":"completion","args":{"shell":"bash"}}` + for i := 0; i < 2; i++ { + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(body)) + if err != nil { + t.Fatalf("run request %d failed: %v", i+1, err) + } + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + t.Fatalf("unexpected status on run %d: %d body=%s", i+1, resp.StatusCode, string(payload)) + } + + var out RunResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + _ = resp.Body.Close() + t.Fatalf("decode run response %d: %v", i+1, err) + } + _ = resp.Body.Close() + + if !out.OK { + t.Fatalf("run %d failed, error=%s stderr=%s", i+1, out.Error, out.Stderr) + } + } +} + +func TestRunEndpointPassesStdin(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "cat", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + in, err := io.ReadAll(inv.Stdin) + if err != nil { + return err + } + _, _ = inv.Stdout.Write(in) + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"cat","stdin":"line1\nline2\n"}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(payload)) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + + if !runResp.OK { + t.Fatalf("expected ok, got error=%s", runResp.Error) + } + + if runResp.Stdout != "line1\nline2\n" { + t.Fatalf("unexpected stdout: %q", runResp.Stdout) + } +} + +func TestRunEndpointTimeoutIncludesInteractiveHint(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "wait", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + <-ctx.Done() + return ctx.Err() + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + resp, err := http.Post(ts.URL+"/api/run", "application/json", bytes.NewBufferString(`{"command":"wait","timeoutSeconds":1}`)) + if err != nil { + t.Fatalf("run command request: %v", err) + } + defer closeResponseBody(t, resp) + + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected status: %d body=%s", resp.StatusCode, string(payload)) + } + + var runResp RunResponse + if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil { + t.Fatalf("decode run response: %v", err) + } + + if runResp.OK { + t.Fatalf("expected timeout error, got ok=true") + } + + if !runResp.TimedOut { + t.Fatalf("expected timedOut=true, got false") + } + + if !strings.Contains(runResp.Error, "webcmd 不提供 TTY 交互") { + t.Fatalf("expected timeout hint in error, got %q", runResp.Error) + } +} + +func TestRunWSEndpointInteractive(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "repl", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + reader := bufio.NewReader(inv.Stdin) + line, err := reader.ReadString('\n') + if err != nil { + return err + } + _, _ = inv.Stdout.Write([]byte("echo:" + line)) + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/run/ws" + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "done") }() + + if err := wsjson.Write(ctx, conn, wsRunMessage{ + Type: "start", + Request: &RunRequest{ + Command: "repl", + }, + Rows: 24, + Cols: 80, + }); err != nil { + t.Fatalf("write start message: %v", err) + } + + var started wsRunMessage + if err := wsjson.Read(ctx, conn, &started); err != nil { + t.Fatalf("read started message: %v", err) + } + if started.Type != "started" { + t.Fatalf("expected started message, got %q", started.Type) + } + + if err := wsjson.Write(ctx, conn, wsRunMessage{Type: "stdin", Data: "hello\n"}); err != nil { + t.Fatalf("write stdin message: %v", err) + } + + var output strings.Builder + for { + var msg wsRunMessage + if err := wsjson.Read(ctx, conn, &msg); err != nil { + t.Fatalf("read ws message: %v", err) + } + + switch msg.Type { + case "output": + output.WriteString(msg.Data) + case "exit": + if !msg.OK { + t.Fatalf("expected ok exit, got error=%s", msg.Error) + } + if !strings.Contains(output.String(), "hello") { + t.Fatalf("expected interactive output contains hello, got %q", output.String()) + } + return + } + } +} + +func TestTerminalWSEndpointStartAndClose(t *testing.T) { + root := &redant.Command{Use: "testapp"} + root.Children = append(root.Children, &redant.Command{ + Use: "echo", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, _ = inv.Stdout.Write([]byte("ok\n")) + return nil + }, + }) + + ts := httptest.NewServer(New(root).Handler()) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/terminal/ws" + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatalf("dial websocket: %v", err) + } + defer func() { _ = conn.Close(websocket.StatusNormalClosure, "done") }() + + if err := wsjson.Write(ctx, conn, wsRunMessage{Type: "start", Rows: 24, Cols: 80}); err != nil { + t.Fatalf("write start message: %v", err) + } + + started := false + var startedMsg wsRunMessage + deadline := time.After(3 * time.Second) + for !started { + select { + case <-deadline: + t.Fatal("did not receive started message in time") + default: + var msg wsRunMessage + if err := wsjson.Read(ctx, conn, &msg); err != nil { + t.Fatalf("read ws message: %v", err) + } + if msg.Type == "started" { + startedMsg = msg + started = true + } + } + } + + if strings.TrimSpace(startedMsg.Program) == "" { + t.Fatalf("expected started message has program, got empty") + } + if strings.TrimSpace(startedMsg.WorkingDir) == "" { + t.Fatalf("expected started message has workingDir, got empty") + } + + if err := wsjson.Write(ctx, conn, wsRunMessage{Type: "close"}); err != nil { + t.Fatalf("write close message: %v", err) + } +} + +func TestIsExpectedPTYReadClose(t *testing.T) { + t.Parallel() + + if !isExpectedPTYReadClose(io.EOF) { + t.Fatalf("expected io.EOF to be treated as expected close") + } + + if !isExpectedPTYReadClose(os.ErrClosed) { + t.Fatalf("expected os.ErrClosed to be treated as expected close") + } + + if !isExpectedPTYReadClose(syscall.EIO) { + t.Fatalf("expected syscall.EIO to be treated as expected close") + } + + if isExpectedPTYReadClose(io.ErrUnexpectedEOF) { + t.Fatalf("unexpected EOF should not be treated as expected close") + } +} + +func closeResponseBody(t *testing.T, resp *http.Response) { + t.Helper() + if resp == nil || resp.Body == nil { + return + } + if err := resp.Body.Close(); err != nil { + t.Errorf("close response body: %v", err) + } +} diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html new file mode 100644 index 0000000..2557dac --- /dev/null +++ b/internal/webui/static/index.html @@ -0,0 +1,1331 @@ + + + + + + + redant web command + + + + + + + + +
+ + +
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/scripts/get-current-pr.sh b/scripts/get-current-pr.sh new file mode 100644 index 0000000..e28e48f --- /dev/null +++ b/scripts/get-current-pr.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash scripts/get-current-pr.sh [owner/repo] +# bash scripts/get-current-pr.sh [owner/repo] --ensure-draft [--base main] + +repo="" +ensure_draft="false" +base_branch="main" + +while [[ $# -gt 0 ]]; do + case "$1" in + --ensure-draft) + ensure_draft="true" + shift + ;; + --base) + if [[ $# -lt 2 ]]; then + echo "❌ --base 需要一个分支名参数。" >&2 + exit 1 + fi + base_branch="$2" + shift 2 + ;; + -h|--help) + cat <<'EOF' +用法: + bash scripts/get-current-pr.sh [owner/repo] + bash scripts/get-current-pr.sh [owner/repo] --ensure-draft [--base main] + +说明: + - 默认模式: 仅查询当前分支对应 PR。 + - --ensure-draft: 若未找到 PR,则自动创建 Draft PR。 + - --base: 创建 Draft PR 时使用的 base 分支,默认 main。 +EOF + exit 0 + ;; + *) + if [[ -z "$repo" ]]; then + repo="$1" + else + echo "❌ 未识别参数: $1" >&2 + exit 1 + fi + shift + ;; + esac +done + +if [[ -z "$repo" ]]; then + # Prefer gh-native repo detection (works with authenticated gh context). + repo="$(GH_PAGER=cat gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || true)" +fi + +if [[ -z "$repo" ]]; then + # Fallback: parse git remote URL. + remote_url="$(git remote get-url origin 2>/dev/null || true)" + if [[ "$remote_url" =~ github.com[:/]([^/]+/[^/.]+)(\.git)?$ ]]; then + repo="${BASH_REMATCH[1]}" + fi +fi + +if [[ -z "$repo" ]]; then + echo "❌ 无法识别仓库,请显式传入 owner/repo,例如: bash scripts/get-current-pr.sh pubgo/redant" >&2 + exit 1 +fi + +branch="$(git branch --show-current 2>/dev/null || true)" +if [[ -z "$branch" ]]; then + echo "❌ 当前不在分支上(可能是 detached HEAD),无法自动定位 PR。" >&2 + exit 1 +fi + +lookup_pr_line() { + GH_PAGER=cat gh pr list \ + --repo "$repo" \ + --head "$branch" \ + --state all \ + --limit 1 \ + --json number,state,headRefName,baseRefName,title,url \ + --jq 'if length==0 then "" else .[0] | "#\(.number)\t\(.state)\t\(.headRefName)->\(.baseRefName)\t\(.title)\t\(.url)" end' +} + +pr_line="$(lookup_pr_line)" + +if [[ -z "$pr_line" ]]; then + if [[ "$ensure_draft" == "true" ]]; then + echo "ℹ️ 当前分支 '$branch' 在 $repo 下没有找到对应 PR,开始创建 Draft PR..." + set +e + create_output="$(GH_PAGER=cat gh pr create --repo "$repo" --head "$branch" --base "$base_branch" --draft --fill 2>&1)" + create_code=$? + set -e + + if [[ $create_code -ne 0 ]]; then + echo "❌ Draft PR 创建失败(退出码: $create_code)。" >&2 + echo "$create_output" >&2 + exit 3 + fi + + pr_line="$(lookup_pr_line)" + if [[ -z "$pr_line" ]]; then + echo "❌ Draft PR 似乎已创建,但未能重新查询到,请稍后重试。" >&2 + exit 4 + fi + echo "✅ Draft PR 已创建并定位成功。" + echo "$pr_line" + exit 0 + fi + + echo "ℹ️ 当前分支 '$branch' 在 $repo 下没有找到对应 PR。" + exit 2 +fi + +echo "$pr_line" diff --git a/taskfile.yml b/taskfile.yml deleted file mode 100644 index 19bde4a..0000000 --- a/taskfile.yml +++ /dev/null @@ -1,26 +0,0 @@ -# https://taskfile.dev - -version: "3" - -dotenv: [".env"] - -tasks: - default: - desc: "default task" - cmds: - - task -a - - vet: - desc: "go vet" - cmds: - - go vet ./... - - test: - desc: "go test" - cmds: - - go test -short -race -v ./... -cover - - lint: - desc: "golangci-lint" - cmds: - - golangci-lint run --verbose ./...