diff --git a/.copilot/profiles.json b/.copilot/profiles.json new file mode 100644 index 0000000..a454d31 --- /dev/null +++ b/.copilot/profiles.json @@ -0,0 +1,23 @@ +{ + "profiles": { + "dev": { + "model": "gpt-5", + "reasoningEffort": "medium", + "workingDirectory": ".", + "skillDirs": [ + "./skills", + "./.copilot/skills" + ], + "disabledSkills": [], + "availableTools": [], + "excludedTools": [], + "mcpServersJSON": "", + "customAgentsJSON": "", + "agent": "", + "customToolsJSON": "", + "streaming": false, + "enableDemoEchoTool": false, + "enableInfiniteSession": true + } + } +} \ No newline at end of file diff --git a/.copilot/skills/README.md b/.copilot/skills/README.md new file mode 100644 index 0000000..f5262e4 --- /dev/null +++ b/.copilot/skills/README.md @@ -0,0 +1,15 @@ +# Local Copilot Skills + +这个目录用于存放仅本仓库使用的 Copilot Skills。 + +建议每个技能单独目录,文件名使用 `SKILL.md`。 + +建议模板: + +- frontmatter:`name`、`description`、`argument-hint`、`metadata` +- 正文:`目标`、`执行步骤`、`约束`、`输出契约` + +说明: + +- `metadata` 中可扩展 `summary/version/tags/use_when/tools`。 +- 顶层 frontmatter 请使用受支持字段,避免解析报错。 diff --git a/.copilot/skills/generic-sample/SKILL.md b/.copilot/skills/generic-sample/SKILL.md new file mode 100644 index 0000000..2d5e390 --- /dev/null +++ b/.copilot/skills/generic-sample/SKILL.md @@ -0,0 +1,35 @@ +--- +name: generic-sample +description: "Use when: 需要通用问题处理模板(分析、实现、验证)时。" +"argument-hint": "输入任务目标、约束、涉及模块与预期输出。" +metadata: + summary: "通用执行技能模板,强调最小变更、可验证与明确输出。" + version: "1.0.0" + tags: ["template", "execution", "copilot"] + use_when: + - "任务尚未有专用 skill" + - "需要统一的分析-实现-验证流程" + tools: ["semantic_search", "grep_search", "read_file", "get_errors", "runTests"] +--- + +# Generic Execution Template + +## 目标 +- 将自然语言需求转为可执行、可验证的改动。 + +## 执行步骤 +1. 明确目标与边界(输入/输出/约束/非目标)。 +2. 收集代码证据(入口、实现点、测试点)。 +3. 制定最小改动计划并分步实施。 +4. 每步改动后执行检查与测试。 +5. 输出结果、风险和后续建议。 + +## 约束 +- 不编造事实,不省略关键假设。 +- 避免一次性大改;优先小步可回归。 +- 涉及接口变更时说明兼容策略。 + +## 输出契约 +- 变更摘要(文件 + 目的) +- 验证结果(测试/检查) +- 已知风险与后续动作 diff --git a/.github/instructions/changelog.instructions.md b/.github/instructions/changelog.instructions.md new file mode 100644 index 0000000..3a50a65 --- /dev/null +++ b/.github/instructions/changelog.instructions.md @@ -0,0 +1,35 @@ +--- +name: Changelog 专项规范 +description: 仅用于维护 .version/changelog,保证 Unreleased 与版本文件结构稳定、分类一致、条目可追溯 +applyTo: ".version/changelog/*.md" +--- + +# Changelog 维护规范 + +本规则仅适用于 `.version/changelog/*.md`。 + +## 结构约束 + +- `Unreleased.md` 推荐分类:`新增` / `修复` / `变更` / `文档`。 +- 若某分类暂无内容,写“暂无”。 + +## 内容约束 + +- 仅基于可见改动编写条目,不杜撰能力或影响。 +- 单条应简洁、可读、可追溯,以动词开头。 +- 重复事项需合并去重,避免同义重复。 +- 非标准分类(如 `优化`、`重构`)必须归入标准四类(通常归 `变更`)。 +- 不改写历史版本文件语义,不重排已发布版本。 + +## 落版约束(release) + +- 版本号来源于 `.version/VERSION`。 +- 落版文件:`.version/changelog/.md`。 +- 文件头格式:`# [] - `。 +- 落版前检查版本文件是否已存在,已存在时提示用户确认。 +- 落版后重建 `.version/changelog/Unreleased.md` 模板(四个分类)。 +- 落版后同步更新 `.version/changelog/README.md` 索引。 + +## 协同建议 + +- 建议通过 agent 提示词执行:`/changelog draft|release`。 diff --git a/.github/instructions/documentation.instructions.md b/.github/instructions/documentation.instructions.md new file mode 100644 index 0000000..a4f4d6d --- /dev/null +++ b/.github/instructions/documentation.instructions.md @@ -0,0 +1,36 @@ +--- +name: 文档专项规范 +description: 适用于仓库文档写作与维护(README/docs/example/internal),确保中文技术文风、结构一致、变更可追溯 +applyTo: "**/*.md" +--- + +# 仓库文档专项规范 + +仅在“项目文档内容”场景生效(如 `README.md`、`docs/**`、`example/**/README.md`、`internal/**/README.md`)。 + +## 基本要求 + +- 默认使用中文技术文风,表达简洁、可执行、可复现。 +- 结构化写作:优先使用二级/三级标题与短列表,避免大段空泛描述。 +- 流程、架构、关系图优先使用 Mermaid。 +- 避免复制粘贴同一段说明到多个文档;优先“引用索引文档”或“链接到单一事实来源”。 + +## 仓库约定(必须遵循) + +- 如果当前仓库存在 `docs/INDEX.md`,新增文档时需补充索引关系(如适用)。 +- 涉及架构或流程变化时,先更新 `docs/DESIGN.md`(如存在),再补示例/说明文档。 +- 行为变更需同步 `.version/changelog/Unreleased.md`;必要时同步其他评估或使用文档。 +- 术语需与当前仓库现有命名保持一致,不擅自发明新概念。 + +## 写作与更新策略 + +- 面向“当前仓库真实实现”写作,不杜撰未实现能力。 +- 描述命令时优先使用仓库中已存在的命令名与任务名。 +- 变更文档时说明“改了什么、为什么改、影响范围”。 +- 若仅做措辞润色,不应改动技术语义与行为结论。 + +## Changelog 联动 + +- 如涉及行为变化,建议同步更新 `.version/changelog/Unreleased.md`。 +- 建议通过相关 prompt 执行 changelog 维护。 + \ No newline at end of file diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md new file mode 100644 index 0000000..ef4bcc3 --- /dev/null +++ b/.github/instructions/release.instructions.md @@ -0,0 +1,25 @@ +--- +name: 发布前变更核对约束 +description: "Use when preparing a release or completing behavior-impacting changes, including changelog updates and release regression checks." +--- + +# 发布前核对规则 + +用于“准备发布”或“完成具备行为影响的改动”时的统一核对。 + +## 发布前检查清单 + +- 变更说明已写入 `.version/changelog/Unreleased.md`,分类正确(新增/修复/变更/文档)。 +- 用户可见行为变化,已同步示例或说明文档。 + +## 质量门槛 + +- 执行完整回归测试并确认通过。 +- 仅基于真实改动与真实测试结果编写发布说明,不杜撰。 + +## 落版流程 + +- 首选通过 `fastgit changelog release` 或等效 agent prompt 执行。 +- 版本号来源于 `.version/VERSION`;若版本文件已存在,需确认是否递增。 +- 落版后重建 `Unreleased` 模板并更新 changelog 索引。 +- changelog 结构与落版细节以 `.github/instructions/changelog.instructions.md` 为准。 diff --git a/.github/prompts/changelog.prompt.md b/.github/prompts/changelog.prompt.md new file mode 100644 index 0000000..1844dbc --- /dev/null +++ b/.github/prompts/changelog.prompt.md @@ -0,0 +1,59 @@ +--- +name: changelog +description: 维护 .version/changelog(更新 Unreleased 或执行版本落版) +argument-hint: "模式:draft(更新 Unreleased)或 release(按 .version/VERSION 落版)" +agent: agent +--- + +你是当前仓库的 Changelog 维护助手。 + +## 目标 + +- `draft` 模式:根据当前改动更新 `.version/changelog/Unreleased.md`。 +- `release` 模式:将 `Unreleased.md` 落版为版本文件,并重建空模板。 + +## 必读上下文 + +在开始前先读取: + +- `.version/changelog/Unreleased.md` +- `.version/VERSION` + +## 通用规则 + +1. 只基于可见改动生成条目,不杜撰。 +2. **标准分类**:`新增` / `修复` / `变更` / `文档`。 + - 非标准分类(如 `优化`、`重构`)必须归入上述四类(通常归 `变更`)。 +3. 语言使用中文技术文风,单条以动词开头,简洁可追溯。 +4. 去重:同类项合并,避免语义重复。 +5. 不改写历史版本文件语义与顺序。 + +## draft 模式 + +1. 获取工作区 diff:运行 `git diff --stat` 和 `git diff --name-only` 确认改动范围。 +2. 仅更新 `.version/changelog/Unreleased.md`。 +3. 若缺少分类小节则补齐;无内容的小节写“暂无”。 +4. 归类规则: + - feat / 新增能力 → `新增` + - fix / bug 修正 → `修复` + - 重构、依赖迁移、行为调整、优化 → `变更` + - README、docs、注释更新 → `文档` + +## release 模式 + +1. 读取 `.version/VERSION` 获取目标版本号(如 `v0.3.0`)。 +2. **版本冲突检查**:若 `.version/changelog/.md` 已存在,提示用户确认是否需要递增版本号,不自行覆盖。 +3. 创建版本文件 `.version/changelog/.md`: + - 标题格式:`# [] - `。 + - 将 `Unreleased.md` 的内容迁移过去(分类统一为标准四类)。 +4. 重建 `Unreleased.md` 空模板(四个分类均写“暂无”)。 +5. 更新 `.version/changelog/README.md` 索引:在列表顶部(`Unreleased` 之后)插入新版本链接。 +6. 更新 `.version/VERSION` 为下一个预期版本号(**仅在用户确认后**,否则保持当前值)。 + +## 输出要求 + +- 直接给出文件修改结果。 +- 末尾附一段简短自检: + - 是否仅改动 `.version/` 范围内的文件; + - 分类是否统一为标准四类,是否完成去重; + - 历史版本文件是否未被修改。 diff --git a/.github/prompts/commit-message.prompt.md b/.github/prompts/commit-message.prompt.md new file mode 100644 index 0000000..d733cfa --- /dev/null +++ b/.github/prompts/commit-message.prompt.md @@ -0,0 +1,182 @@ +--- +name: commit-message +description: 基于当前代码改动生成提交信息并直接执行本地 git commit,默认继续 push 到当前分支 +argument-hint: "可选:补充本次提交的核心意图、偏好的 type/scope;默认直接完成本地提交并推送远程" +agent: agent +--- + +你是当前仓库的 Git 提交信息助手。 + +## 目标 + +基于当前代码改动生成高质量的 git commit message,并**直接完成本地提交与远程推送**。 + +你的首要职责不是解释 diff,而是: + +1. 判断本次应提交哪些改动; +2. 生成最合适的 commit message; +3. 执行本地 `git commit`; +4. 执行 `git push`; +5. 报告结果。 + +除非用户明确要求不要推送,否则**默认执行 `git push`**。 + +## 必读上下文 + +优先按以下顺序读取或获取: + +- `git diff --cached --stat` +- `git diff --cached --name-only` +- 如有必要,再查看 `git diff --cached` + +如果 staged diff 为空,不要立即结束;继续读取: + +- `git status --short` +- `git diff --stat` +- `git diff --name-only` +- 如有必要,再查看 `git diff` + +处理规则: + +- 如果 **有 staged diff**: + - 仅基于 staged diff 生成 commit message; + - 仅提交 staged 内容; + - 不要自动把 unstaged 改动加入提交。 +- 如果 **没有 staged diff,但有 unstaged diff**: + - 基于 working tree 改动生成 commit message; + - 自动执行 `git add -A` 将当前改动加入暂存区; + - 再执行本地提交。 +- 如果 staged / unstaged 都为空: + - 明确提示当前没有可用于生成提交信息的代码改动。 + - 此时不要杜撰任何 commit message,也不要执行提交。 + +## 执行规则 + +在确定 commit message 后: + +1. 如果 staged diff 非空:直接执行 `git commit -m ""`。 +2. 如果 staged diff 为空但 unstaged diff 非空:先执行 `git add -A`,再执行 `git commit -m ""`。 +3. 提交成功后,默认继续执行 `git push`。 +4. 优先推送当前分支的上游;如果没有上游,则推送当前分支到同名远程分支。 +5. 如果 `git commit` 失败,应输出失败原因,而不是假装成功。 +6. 如果 `git push` 失败,应输出真实失败原因。 +7. 不要编造 commit hash;只能使用真实执行结果。 + +## 判定优先级 + +生成提交信息时,按以下优先级判断: + +1. **用户可见新能力** 优先于内部重构细节。 + - 例如:新增命令、子命令、交互入口、脚手架、repo prompt、工作流能力,优先考虑 `feat`。 +2. **真实行为修复** 优先于实现细节调整。 + - 例如:修复发布流程、修复输出错误、修复空发布,优先考虑 `fix`。 +3. **纯结构调整且无新增用户能力** 才优先考虑 `refactor`。 +4. **纯文档更新** 才优先考虑 `docs`。 + +如果一次改动同时包含“用户可见新能力”和“内部重构”,应优先围绕**最核心的用户可见变化**选择 `type`。 + +## 通用规则 + +1. 只基于可见改动生成提交信息,不杜撰。 +2. 使用 **Conventional Commits** 规范: + - `feat` + - `fix` + - `docs` + - `refactor` + - `test` + - `chore` + - `perf` + - `build` + - `ci` +3. 标题格式: + - `(): ` + - 如果 scope 不明确,可省略 scope,使用 `: `。 +4. `summary` 使用英文,简洁明确,尽量不超过 50 个字符。 +5. 优先描述本次改动的**核心行为变化**,不要机械罗列所有文件。 +6. 如果主要是新增能力,优先用 `feat`。 +7. 如果主要是修复问题,优先用 `fix`。 +8. 如果主要是无行为变化的结构调整,优先用 `refactor`。 +9. 如果主要是文档、说明、注释更新,优先用 `docs`。 +10. 如果同时存在代码和文档改动,优先按代码主行为决定 type。 +11. 如果改动新增了命令、子命令、prompt、规则文件、脚手架或发布工作流,且这些内容对用户直接可见,优先考虑 `feat`,不要轻易降级成 `refactor`。 +12. 提交信息最终只能选择 1 条最优结果用于实际提交。 + +## scope 选择建议 + +优先根据当前仓库模块推断 scope,例如: + +- `copilot` +- `ggc` +- `changelog` +- `agentline` +- `ssh` +- `skills` +- `push` +- `commit` +- `docs` + +如果这些都不合适,再根据实际改动模块自行推断。 + +## 输出要求 + +如果成功提交并推送,请输出: + +- `mode:` 说明本次基于 `staged` 还是 `unstaged-auto-stage` +- `commit:` 实际执行的 commit message +- `hash:` 实际生成的 commit hash(短 hash 即可) +- `push:` 推送目标或推送结果摘要 +- `reason:` 用中文简短说明为什么这条提交信息最合适(1~2 句) + +输出时必须遵守: + +1. 只输出最终结果,不要展示分析过程。 +2. 不要加标题,不要加 Markdown 段落说明,不要加“已完成 X 个步骤”。 +3. 顶层字段固定使用: + - `mode:` + - `commit:` + - `hash:` + - `push:` + - `reason:` +4. `commit:` 必须是**单行** commit message。 +5. `hash:` 必须来自真实 `git commit` 结果。 +6. `push:` 必须来自真实 `git push` 结果摘要。 +7. `reason:` 只写 1~2 句中文,简洁即可。 + +如果当前没有任何可用改动,请只输出一段简短提示,说明: + +- 当前没有 staged diff +- 当前也没有 unstaged diff(如果确实为空) +- 请先修改代码或执行 `git add` + +如果提交失败,请输出简短失败结果,包含: + +- `mode:` +- `commit:` +- `error:` + +其中 `error:` 必须是真实报错摘要。 + +如果提交成功但推送失败,请输出简短失败结果,包含: + +- `mode:` +- `commit:` +- `hash:` +- `error:` + +其中 `error:` 必须是真实 push 报错摘要。 + +输出格式示例: + +mode: staged +commit: feat(copilot): add interactive session commands +hash: a1b2c3d +push: pushed to origin/current-branch +reason: 本次改动核心是新增 Copilot 交互与会话能力,使用 feat 更准确,scope 选 copilot 更能概括主行为。 + +当没有 staged diff、但存在 unstaged diff 并自动暂存提交时,输出格式示例: + +mode: unstaged-auto-stage +commit: feat(changelog): add prompt-based release workflow +hash: d4e5f6g +push: pushed to origin/current-branch +reason: 当前改动虽然尚未暂存,但核心变化包含用户可见的 changelog 工作流与 prompt 脚手架,因此优先使用 feat,并已自动完成本地提交。 diff --git a/.github/prompts/documentation.prompt.md b/.github/prompts/documentation.prompt.md new file mode 100644 index 0000000..023ecd4 --- /dev/null +++ b/.github/prompts/documentation.prompt.md @@ -0,0 +1,57 @@ +--- +name: documentation +description: 维护 README/docs/example 文档,确保中文技术文风、结构一致、变更可追溯 +argument-hint: "可选:补充要更新的文档范围、主题或目标读者" +agent: agent +--- + +你是当前仓库的文档维护助手。 + +## 目标 + +根据当前仓库真实改动,维护与同步以下文档内容: + +- `README.md` +- `docs/**` +- `example/**/README.md` +- 其他与当前改动直接相关的 Markdown 文档 + +## 必读上下文 + +开始前优先读取: + +- 当前变更涉及的文档文件 +- 与改动直接相关的实现代码 +- 如涉及行为变化,读取 `.version/changelog/Unreleased.md` + +如果改动涉及架构、流程或命令面变化,优先检查: + +- `README.md` +- `docs/DESIGN.md`(如果存在) +- `docs/**` 下对应专题文档 + +## 通用规则 + +1. 只基于当前仓库真实实现写作,不杜撰未实现能力。 +2. 默认使用中文技术文风,表达简洁、可执行、可复现。 +3. 优先使用二级/三级标题和短列表,避免大段空泛描述。 +4. 流程、架构、关系图优先使用 Mermaid。 +5. 避免在多个文档中复制粘贴同一段说明;优先引用单一事实来源。 +6. 若只是措辞润色,不要改动技术语义与行为结论。 +7. 描述命令时,优先使用仓库中真实存在的命令或任务名。 + +## 仓库约定 + +1. 如果当前仓库存在 `docs/INDEX.md`,新增文档时应同步更新索引关系。 +2. 涉及架构或流程变化时,优先更新 `docs/DESIGN.md`(如果存在),再补 README / 示例 / 其他说明文档。 +3. 用户可见行为变化,应同步更新 README 或对应示例文档。 +4. 行为变更通常应同步 `.version/changelog/Unreleased.md`,但本 prompt 默认只修改文档文件;如确需修改 changelog,应单独说明。 + +## 输出要求 + +- 直接给出文档修改结果。 +- 末尾附简短自检: + - 是否仅基于真实改动更新文档; + - 是否保持中文技术文风与结构化表达; + - 是否已同步最关键的入口文档(README / DESIGN / 示例)。 + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 31a41a3..c83632f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ go.work.sum .env /local/ *.log +.local diff --git a/.version/changelog/README.md b/.version/changelog/README.md new file mode 100644 index 0000000..bb45fcf --- /dev/null +++ b/.version/changelog/README.md @@ -0,0 +1,18 @@ +# Changelog 索引 + +本目录保存项目变更记录,采用“一个版本一个文件”的方式维护。 + +## 文件约定 + +- `Unreleased.md`:当前开发中变更(待发布)。 +- `vX.Y.Z.md`:已发布版本变更(例如 `v0.0.5.md`)。 + +## 当前版本文件 + +- [`Unreleased.md`](Unreleased.md) + +## 维护约定 + +- 分类保持:`新增` / `修复` / `变更` / `文档`。 +- 发布时将 `Unreleased.md` 内容迁移到新版本文件,并重建空模板。 +- 历史版本文件只做勘误,不改写语义与顺序。 diff --git a/.version/changelog/Unreleased.md b/.version/changelog/Unreleased.md new file mode 100644 index 0000000..f2b0375 --- /dev/null +++ b/.version/changelog/Unreleased.md @@ -0,0 +1,25 @@ +# [Unreleased] + +> 推荐维护方式:`fastgit changelog draft|release` + +## 新增 + +- 新增 `fastgit copilot` 命令与 `agentline` 交互层,支持会话创建、恢复、状态查看、模型查询及交互式问答流程。 +- 新增 `fastgit ggc` 统一命令面、交互式 workflow/alias 持久化与 fuzzy 搜索执行能力,覆盖常见 Git 操作编排场景。 +- 新增 `fastgit changelog init|draft|release` 工作流,可初始化 `.version/changelog` 并生成 `.github/prompts`、`.github/instructions` 规则文件。 +- 新增 `fastgit docs init` 子命令,可初始化 `documentation.prompt.md` 与 `documentation.instructions.md` 文档模板。 +- 新增 `ssh` 命令、Copilot/agentline 示例工程与 `skills` 模块,补齐仓库级技能发现与远程接口能力。 + +## 修复 + +- 修复 `fastgit changelog release` 在落版时对新建/更新文件输出不准确的问题,并阻止空 `Unreleased.md` 被误发布。 + +## 变更 + +- 调整 `fastgit commit` AI 提交流程,新增 `commit ai` 显式入口,并同步更新主命令注册与依赖接入方式。 +- 引入 Copilot SDK、`gitshell` 与本地 `skills` 支撑模块,统一会话运行时、权限处理与技能加载基础设施。 + +## 文档 + +- 更新 `README.md` 命令概览,补充 `ggc`、`copilot` 与 `changelog` 的使用说明。 +- 新增 `docs/copilot-dcos.md`、示例 README 与技能说明文档,完善 Copilot 融合开发落地指引。 diff --git a/README.md b/README.md index 4695916..5542045 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,66 @@ agentic git commit generate tool ## Command Overview - `fastgit commit`: AI 提交流程(保留原行为) - `fastgit commit ai`: AI 提交流程显式入口(新增) +- `fastgit changelog init`: 初始化 `.version/changelog` 模板,适配任意项目仓库 +- `fastgit changelog draft`: 使用 Copilot 根据当前改动更新 `Unreleased.md` +- `fastgit changelog release`: 将 `Unreleased.md` 落版为版本文件,并可同步推进 `.version/VERSION` +- `fastgit docs init`: 初始化文档维护用的 prompt / instruction 模板 +- `fastgit pull`: 拉取当前分支(支持 `--all`) +- `fastgit push`: 推送当前分支(支持 `--all` / `--force`) - `fastgit ggc list`: 查看统一命令面(ggc 风格) - `fastgit ggc `: 执行统一命令,例如 `fastgit ggc status short` - `fastgit ggc` / `fastgit ggc interactive`: 进入交互模式(增量搜索 + workflow) - `fastgit ggc path`: 查看当前 `ggc.yaml` 的实际路径(按 OS/XDG 规则) +## Repo Prompt Templates + +`fastgit` 现在支持为仓库初始化一组可直接复用的 Copilot prompt / instruction 模板,适合把常用工作流沉淀到项目里,而不是只存在聊天上下文中。 + +### Changelog 模板 + +- 初始化:`fastgit changelog init` +- 生成后会创建: + - `.version/changelog/*` + - `.github/prompts/changelog.prompt.md` + - `.github/instructions/changelog.instructions.md` + - `.github/instructions/release.instructions.md` + +适合场景: + +- 维护 `Unreleased.md` +- 准备版本落版 +- 统一 changelog 分类与发布流程 + +### Documentation 模板 + +- 初始化:`fastgit docs init` +- 生成后会创建: + - `.github/prompts/documentation.prompt.md` + - `.github/prompts/commit-message.prompt.md` + - `.github/instructions/documentation.instructions.md` + +适合场景: + +- 更新 `README.md` +- 同步 `docs/**` +- 维护 `example/**/README.md` +- 生成或沉淀提交信息 / 提交流程 prompt 模板 +- 让文档写作遵循统一中文技术文风与结构规范 + +### Commit Prompt 模板 + +仓库中还可以维护提交辅助 prompt,例如: + +- `.github/prompts/commit-message.prompt.md` + +当前这类 prompt 可用于: + +- 基于 staged / working tree 改动生成提交信息 +- 按模板约束执行本地提交 +- 按模板约束继续推送到远程(如果 prompt 明确要求) + +> 建议:把“生成建议”和“执行提交”区分成不同 prompt,便于在不同风险场景下选择更稳妥的工作流。 + ## New ggc-style command surface (phase 1) - `fastgit ggc status|status short` diff --git a/bootstrap/boot.go b/bootstrap/boot.go index 15e1ab2..ebb9222 100644 --- a/bootstrap/boot.go +++ b/bootstrap/boot.go @@ -12,11 +12,14 @@ import ( "github.com/pubgo/dix/v2/dixcontext" "github.com/pubgo/fastgit/cmds/chglogcmd" "github.com/pubgo/fastgit/cmds/configcmd" + "github.com/pubgo/fastgit/cmds/copilotcmd" + "github.com/pubgo/fastgit/cmds/docscmd" "github.com/pubgo/fastgit/cmds/fastcommitcmd" "github.com/pubgo/fastgit/cmds/ggccmd" "github.com/pubgo/fastgit/cmds/historycmd" "github.com/pubgo/fastgit/cmds/initcmd" "github.com/pubgo/fastgit/cmds/pullcmd" + "github.com/pubgo/fastgit/cmds/pushcmd" "github.com/pubgo/fastgit/cmds/sshcmd" "github.com/pubgo/fastgit/cmds/tagcmd" "github.com/pubgo/fastgit/cmds/upgradecmd" @@ -42,8 +45,11 @@ func Main() { ggccmd.New(), fastcommitcmd.New(), configcmd.New(), + docscmd.New(), pullcmd.New(), + pushcmd.New(), chglogcmd.NewCommand(), + copilotcmd.New(), ) } diff --git a/cmds/agentlineapp/acp/bridge.go b/cmds/agentlineapp/acp/bridge.go new file mode 100644 index 0000000..319999f --- /dev/null +++ b/cmds/agentlineapp/acp/bridge.go @@ -0,0 +1,233 @@ +package agentacp + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "sync" + + acp "github.com/coder/acp-go-sdk" +) + +// SessionUpdater 定义了向 Client 推送 session/update 的最小能力。 +type SessionUpdater interface { + SessionUpdate(ctx context.Context, params acp.SessionNotification) error +} + +// PromptExecutor 由 command 编排层实现,用于驱动多轮/tool 调度。 +type PromptExecutor interface { + ExecutePrompt(ctx context.Context, sessionID acp.SessionId, prompt []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) +} + +// PromptExecutorFunc 允许直接用函数实现 PromptExecutor。 +type PromptExecutorFunc func(ctx context.Context, sessionID acp.SessionId, prompt []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) + +// ExecutePrompt implements PromptExecutor. +func (f PromptExecutorFunc) ExecutePrompt(ctx context.Context, sessionID acp.SessionId, prompt []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) { + return f(ctx, sessionID, prompt, emit) +} + +// PermissionRequester 抽象了 Agent 向 Client 发起 session/request_permission 的能力。 +type PermissionRequester interface { + RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) +} + +// BridgeOptions 定义 ACP AgentBridge 的初始化选项。 +type BridgeOptions struct { + AgentInfo acp.Implementation + Executor PromptExecutor + PermissionRequester PermissionRequester +} + +type sessionState struct { + cancel context.CancelFunc +} + +// AgentBridge 是 ACP Agent 侧的最小实现骨架。 +// +// 设计目标: +// 1) command 负责编排; +// 2) 通过 session/update 向 UI(Client) 发送结构化事件; +// 3) 支持最小可用生命周期(initialize/new/prompt/cancel)。 +type AgentBridge struct { + mu sync.Mutex + updater SessionUpdater + + executor PromptExecutor + agentInfo acp.Implementation + sessions map[acp.SessionId]*sessionState + permissionRequester PermissionRequester +} + +// NewAgentBridge 创建一个最小可用的 ACP AgentBridge。 +func NewAgentBridge(opts BridgeOptions) *AgentBridge { + agentInfo := opts.AgentInfo + if strings.TrimSpace(agentInfo.Name) == "" { + agentInfo.Name = "redant-agent" + } + if strings.TrimSpace(agentInfo.Version) == "" { + agentInfo.Version = "dev" + } + + return &AgentBridge{ + executor: opts.Executor, + agentInfo: agentInfo, + sessions: make(map[acp.SessionId]*sessionState), + permissionRequester: opts.PermissionRequester, + } +} + +// RequestPermission 向 Client 发起权限请求;若未配置 requester,默认 cancelled。 +func (b *AgentBridge) RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + b.mu.Lock() + requester := b.permissionRequester + b.mu.Unlock() + if requester == nil { + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil + } + return requester.RequestPermission(ctx, params) +} + +// SetSessionUpdater 设置 session/update 发送目标。 +func (b *AgentBridge) SetSessionUpdater(updater SessionUpdater) { + b.mu.Lock() + defer b.mu.Unlock() + b.updater = updater +} + +// Authenticate implements acp.Agent. +func (b *AgentBridge) Authenticate(context.Context, acp.AuthenticateRequest) (acp.AuthenticateResponse, error) { + return acp.AuthenticateResponse{}, nil +} + +// Initialize implements acp.Agent. +func (b *AgentBridge) Initialize(_ context.Context, params acp.InitializeRequest) (acp.InitializeResponse, error) { + version := params.ProtocolVersion + if version != acp.ProtocolVersion(acp.ProtocolVersionNumber) { + version = acp.ProtocolVersion(acp.ProtocolVersionNumber) + } + + return acp.InitializeResponse{ + ProtocolVersion: version, + AgentCapabilities: acp.AgentCapabilities{ + LoadSession: false, + PromptCapabilities: acp.PromptCapabilities{ + Audio: false, + Image: false, + EmbeddedContext: false, + }, + McpCapabilities: acp.McpCapabilities{Http: false, Sse: false}, + }, + AgentInfo: &b.agentInfo, + AuthMethods: []acp.AuthMethod{}, + }, nil +} + +// Cancel implements acp.Agent. +func (b *AgentBridge) Cancel(_ context.Context, params acp.CancelNotification) error { + b.mu.Lock() + state, ok := b.sessions[params.SessionId] + if ok && state.cancel != nil { + state.cancel() + state.cancel = nil + } + b.mu.Unlock() + return nil +} + +// NewSession implements acp.Agent. +func (b *AgentBridge) NewSession(_ context.Context, params acp.NewSessionRequest) (acp.NewSessionResponse, error) { + if strings.TrimSpace(params.Cwd) == "" { + return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd is required") + } + if !filepath.IsAbs(params.Cwd) { + return acp.NewSessionResponse{}, acp.NewInvalidParams("cwd must be absolute path") + } + + sid := acp.SessionId(fmt.Sprintf("sess_%s", strings.ReplaceAll(strings.ToLower(strings.TrimSpace(params.Cwd)), string(filepath.Separator), "_"))) + if strings.TrimSpace(string(sid)) == "sess_" { + sid = acp.SessionId("sess_default") + } + + b.mu.Lock() + b.sessions[sid] = &sessionState{} + b.mu.Unlock() + + return acp.NewSessionResponse{SessionId: sid}, nil +} + +// Prompt implements acp.Agent. +func (b *AgentBridge) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) { + b.mu.Lock() + state, ok := b.sessions[params.SessionId] + b.mu.Unlock() + if !ok { + return acp.PromptResponse{}, acp.NewInvalidParams("unknown sessionId") + } + + promptCtx, cancel := context.WithCancel(ctx) + b.mu.Lock() + state.cancel = cancel + b.mu.Unlock() + defer func() { + cancel() + b.mu.Lock() + if current, exists := b.sessions[params.SessionId]; exists { + current.cancel = nil + } + b.mu.Unlock() + }() + + emit := func(update acp.SessionUpdate) error { + return b.emitUpdate(promptCtx, params.SessionId, update) + } + + for _, block := range params.Prompt { + if err := emit(acp.UpdateUserMessage(block)); err != nil { + return acp.PromptResponse{}, err + } + } + + if b.executor == nil { + _ = emit(acp.UpdateAgentMessageText("收到请求,正在由 command 编排层处理。")) + if errors.Is(promptCtx.Err(), context.Canceled) { + return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil + } + return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil + } + + stopReason, err := b.executor.ExecutePrompt(promptCtx, params.SessionId, params.Prompt, emit) + if errors.Is(err, context.Canceled) || errors.Is(promptCtx.Err(), context.Canceled) { + return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil + } + if err != nil { + return acp.PromptResponse{}, err + } + if stopReason == "" { + stopReason = acp.StopReasonEndTurn + } + return acp.PromptResponse{StopReason: stopReason}, nil +} + +// SetSessionMode implements acp.Agent. +func (b *AgentBridge) SetSessionMode(_ context.Context, params acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) { + b.mu.Lock() + _, ok := b.sessions[params.SessionId] + b.mu.Unlock() + if !ok { + return acp.SetSessionModeResponse{}, acp.NewInvalidParams("unknown sessionId") + } + return acp.SetSessionModeResponse{}, nil +} + +func (b *AgentBridge) emitUpdate(ctx context.Context, sessionID acp.SessionId, update acp.SessionUpdate) error { + b.mu.Lock() + updater := b.updater + b.mu.Unlock() + if updater == nil { + return nil + } + return updater.SessionUpdate(ctx, acp.SessionNotification{SessionId: sessionID, Update: update}) +} diff --git a/cmds/agentlineapp/acp/bridge_test.go b/cmds/agentlineapp/acp/bridge_test.go new file mode 100644 index 0000000..33f6913 --- /dev/null +++ b/cmds/agentlineapp/acp/bridge_test.go @@ -0,0 +1,123 @@ +package agentacp + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +type captureUpdater struct { + mu sync.Mutex + updates []acp.SessionNotification +} + +func (c *captureUpdater) SessionUpdate(_ context.Context, params acp.SessionNotification) error { + c.mu.Lock() + defer c.mu.Unlock() + c.updates = append(c.updates, params) + return nil +} + +func (c *captureUpdater) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.updates) +} + +func TestAgentBridgeInitializeAndPrompt(t *testing.T) { + bridge := NewAgentBridge(BridgeOptions{AgentInfo: acp.Implementation{Name: "redant", Version: "test"}}) + updater := &captureUpdater{} + bridge.SetSessionUpdater(updater) + + initResp, err := bridge.Initialize(context.Background(), acp.InitializeRequest{ProtocolVersion: acp.ProtocolVersion(acp.ProtocolVersionNumber)}) + if err != nil { + t.Fatalf("initialize failed: %v", err) + } + if initResp.ProtocolVersion != acp.ProtocolVersion(acp.ProtocolVersionNumber) { + t.Fatalf("unexpected protocol version: %v", initResp.ProtocolVersion) + } + + newResp, err := bridge.NewSession(context.Background(), acp.NewSessionRequest{Cwd: "/tmp", McpServers: nil}) + if err != nil { + t.Fatalf("new session failed: %v", err) + } + + promptResp, err := bridge.Prompt(context.Background(), acp.PromptRequest{ + SessionId: newResp.SessionId, + Prompt: []acp.ContentBlock{acp.TextBlock("hello")}, + }) + if err != nil { + t.Fatalf("prompt failed: %v", err) + } + if promptResp.StopReason != acp.StopReasonEndTurn { + t.Fatalf("unexpected stop reason: %s", promptResp.StopReason) + } + + if updater.count() < 2 { + t.Fatalf("expected at least 2 updates(user+assistant), got %d", updater.count()) + } +} + +func TestAgentBridgeCancelStopsPrompt(t *testing.T) { + start := make(chan struct{}) + exec := PromptExecutorFunc(func(ctx context.Context, sessionID acp.SessionId, prompt []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) { + close(start) + <-ctx.Done() + return acp.StopReasonCancelled, ctx.Err() + }) + + bridge := NewAgentBridge(BridgeOptions{Executor: exec}) + newResp, err := bridge.NewSession(context.Background(), acp.NewSessionRequest{Cwd: "/tmp", McpServers: nil}) + if err != nil { + t.Fatalf("new session failed: %v", err) + } + + respCh := make(chan acp.PromptResponse, 1) + errCh := make(chan error, 1) + go func() { + resp, runErr := bridge.Prompt(context.Background(), acp.PromptRequest{SessionId: newResp.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock("long task")}}) + if runErr != nil { + errCh <- runErr + return + } + respCh <- resp + }() + + select { + case <-start: + case <-time.After(2 * time.Second): + t.Fatal("executor did not start") + } + + if err := bridge.Cancel(context.Background(), acp.CancelNotification{SessionId: newResp.SessionId}); err != nil { + t.Fatalf("cancel failed: %v", err) + } + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("unexpected prompt error: %v", err) + } + case resp := <-respCh: + if resp.StopReason != acp.StopReasonCancelled { + t.Fatalf("expected cancelled stop reason, got %s", resp.StopReason) + } + case <-time.After(2 * time.Second): + t.Fatal("prompt did not finish after cancel") + } +} + +func TestCallbackClientDefaultPermissionCancelled(t *testing.T) { + client := &CallbackClient{} + resp, err := client.RequestPermission(context.Background(), acp.RequestPermissionRequest{}) + if err != nil { + t.Fatalf("request permission failed: %v", err) + } + if resp.Outcome.Cancelled == nil { + t.Fatalf("expected default cancelled outcome") + } +} diff --git a/cmds/agentlineapp/acp/client.go b/cmds/agentlineapp/acp/client.go new file mode 100644 index 0000000..07d3c29 --- /dev/null +++ b/cmds/agentlineapp/acp/client.go @@ -0,0 +1,93 @@ +package agentacp + +import ( + "context" + + acp "github.com/coder/acp-go-sdk" +) + +// CallbackClient 提供一个可插拔的 ACP Client 侧适配器。 +// +// 说明: +// - 优先满足 session/update 与 session/request_permission; +// - 其余 fs/terminal 方法按需挂接; +// - 未提供回调时返回 MethodNotFound(或默认取消权限请求)。 +type CallbackClient struct { + PermissionBroker *PermissionBroker + OnReadTextFile func(ctx context.Context, params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) + OnWriteTextFile func(ctx context.Context, params acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) + OnRequestPermission func(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) + OnSessionUpdate func(ctx context.Context, params acp.SessionNotification) error + OnCreateTerminal func(ctx context.Context, params acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) + OnKillTerminal func(ctx context.Context, params acp.KillTerminalCommandRequest) (acp.KillTerminalCommandResponse, error) + OnTerminalOutput func(ctx context.Context, params acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) + OnReleaseTerminal func(ctx context.Context, params acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) + OnWaitTerminalExit func(ctx context.Context, params acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) +} + +func (c *CallbackClient) ReadTextFile(ctx context.Context, params acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) { + if c != nil && c.OnReadTextFile != nil { + return c.OnReadTextFile(ctx, params) + } + return acp.ReadTextFileResponse{}, acp.NewMethodNotFound(acp.ClientMethodFsReadTextFile) +} + +func (c *CallbackClient) WriteTextFile(ctx context.Context, params acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) { + if c != nil && c.OnWriteTextFile != nil { + return c.OnWriteTextFile(ctx, params) + } + return acp.WriteTextFileResponse{}, acp.NewMethodNotFound(acp.ClientMethodFsWriteTextFile) +} + +func (c *CallbackClient) RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + if c != nil && c.OnRequestPermission != nil { + return c.OnRequestPermission(ctx, params) + } + if c != nil && c.PermissionBroker != nil { + return c.PermissionBroker.RequestPermission(ctx, params) + } + // 默认行为:在无 UI 回调时返回 cancelled,避免卡住 prompt 流程。 + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil +} + +func (c *CallbackClient) SessionUpdate(ctx context.Context, params acp.SessionNotification) error { + if c != nil && c.OnSessionUpdate != nil { + return c.OnSessionUpdate(ctx, params) + } + return nil +} + +func (c *CallbackClient) CreateTerminal(ctx context.Context, params acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) { + if c != nil && c.OnCreateTerminal != nil { + return c.OnCreateTerminal(ctx, params) + } + return acp.CreateTerminalResponse{}, acp.NewMethodNotFound(acp.ClientMethodTerminalCreate) +} + +func (c *CallbackClient) KillTerminalCommand(ctx context.Context, params acp.KillTerminalCommandRequest) (acp.KillTerminalCommandResponse, error) { + if c != nil && c.OnKillTerminal != nil { + return c.OnKillTerminal(ctx, params) + } + return acp.KillTerminalCommandResponse{}, acp.NewMethodNotFound(acp.ClientMethodTerminalKill) +} + +func (c *CallbackClient) TerminalOutput(ctx context.Context, params acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) { + if c != nil && c.OnTerminalOutput != nil { + return c.OnTerminalOutput(ctx, params) + } + return acp.TerminalOutputResponse{}, acp.NewMethodNotFound(acp.ClientMethodTerminalOutput) +} + +func (c *CallbackClient) ReleaseTerminal(ctx context.Context, params acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) { + if c != nil && c.OnReleaseTerminal != nil { + return c.OnReleaseTerminal(ctx, params) + } + return acp.ReleaseTerminalResponse{}, acp.NewMethodNotFound(acp.ClientMethodTerminalRelease) +} + +func (c *CallbackClient) WaitForTerminalExit(ctx context.Context, params acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) { + if c != nil && c.OnWaitTerminalExit != nil { + return c.OnWaitTerminalExit(ctx, params) + } + return acp.WaitForTerminalExitResponse{}, acp.NewMethodNotFound(acp.ClientMethodTerminalWaitForExit) +} diff --git a/cmds/agentlineapp/acp/permission.go b/cmds/agentlineapp/acp/permission.go new file mode 100644 index 0000000..f16c96f --- /dev/null +++ b/cmds/agentlineapp/acp/permission.go @@ -0,0 +1,207 @@ +package agentacp + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + + acp "github.com/coder/acp-go-sdk" +) + +// PendingPermission 表示一个待决策的权限请求快照。 +type PendingPermission struct { + RequestID string + SessionID acp.SessionId + ToolCallID acp.ToolCallId + Title string + Options []acp.PermissionOption +} + +type pendingPermissionRequest struct { + id string + params acp.RequestPermissionRequest + respCh chan acp.RequestPermissionResponse +} + +// PermissionBroker 负责管理 ACP 权限请求生命周期。 +type PermissionBroker struct { + mu sync.Mutex + nextID int64 + pending []*pendingPermissionRequest +} + +// NewPermissionBroker 创建权限 broker。 +func NewPermissionBroker() *PermissionBroker { + return &PermissionBroker{} +} + +// RequestPermission 提交请求并阻塞等待决策。 +func (b *PermissionBroker) RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) { + if b == nil { + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil + } + + req := &pendingPermissionRequest{ + id: b.nextRequestID(), + params: params, + respCh: make(chan acp.RequestPermissionResponse, 1), + } + + b.mu.Lock() + b.pending = append(b.pending, req) + b.mu.Unlock() + + select { + case resp := <-req.respCh: + b.remove(req.id) + return resp, nil + case <-ctx.Done(): + _ = b.ResolveCancelled(req.id) + return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}, nil + } +} + +// Pending 返回当前待处理权限请求快照。 +func (b *PermissionBroker) Pending() []PendingPermission { + if b == nil { + return nil + } + + b.mu.Lock() + defer b.mu.Unlock() + + out := make([]PendingPermission, 0, len(b.pending)) + for _, req := range b.pending { + if req == nil { + continue + } + title := "" + if req.params.ToolCall.Title != nil { + title = strings.TrimSpace(*req.params.ToolCall.Title) + } + out = append(out, PendingPermission{ + RequestID: req.id, + SessionID: req.params.SessionId, + ToolCallID: req.params.ToolCall.ToolCallId, + Title: title, + Options: append([]acp.PermissionOption(nil), req.params.Options...), + }) + } + + return out +} + +// ResolveSelected 将请求决策为 selected。 +func (b *PermissionBroker) ResolveSelected(requestID string, optionID acp.PermissionOptionId) error { + if b == nil { + return errors.New("permission broker is nil") + } + req := b.find(strings.TrimSpace(requestID)) + if req == nil { + return fmt.Errorf("request not found: %s", requestID) + } + select { + case req.respCh <- acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeSelected(optionID)}: + return nil + default: + return errors.New("request already resolved") + } +} + +// ResolveCancelled 将请求决策为 cancelled。 +func (b *PermissionBroker) ResolveCancelled(requestID string) error { + if b == nil { + return errors.New("permission broker is nil") + } + req := b.find(strings.TrimSpace(requestID)) + if req == nil { + return fmt.Errorf("request not found: %s", requestID) + } + select { + case req.respCh <- acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeCancelled()}: + return nil + default: + return errors.New("request already resolved") + } +} + +// ResolveFirstByKind 按 option kind 自动选择第一个匹配项。 +func (b *PermissionBroker) ResolveFirstByKind(requestID string, kinds ...acp.PermissionOptionKind) error { + if b == nil { + return errors.New("permission broker is nil") + } + req := b.find(strings.TrimSpace(requestID)) + if req == nil { + return fmt.Errorf("request not found: %s", requestID) + } + + for _, kind := range kinds { + for _, option := range req.params.Options { + if option.Kind == kind { + return b.ResolveSelected(req.id, option.OptionId) + } + } + } + + return fmt.Errorf("no matching option for request %s", requestID) +} + +// ResolveByIndex 使用 1-based 索引选择选项。 +func (b *PermissionBroker) ResolveByIndex(requestID string, oneBasedIndex int) error { + if b == nil { + return errors.New("permission broker is nil") + } + req := b.find(strings.TrimSpace(requestID)) + if req == nil { + return fmt.Errorf("request not found: %s", requestID) + } + idx := oneBasedIndex - 1 + if idx < 0 || idx >= len(req.params.Options) { + return fmt.Errorf("invalid option index: %d", oneBasedIndex) + } + return b.ResolveSelected(req.id, req.params.Options[idx].OptionId) +} + +// ParseIndexOrOption 尝试将文本解释为索引或 optionId。 +func ParseIndexOrOption(raw string) (index int, optionID acp.PermissionOptionId, isIndex bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, "", false + } + if n, err := strconv.Atoi(raw); err == nil { + return n, "", true + } + return 0, acp.PermissionOptionId(raw), false +} + +func (b *PermissionBroker) nextRequestID() string { + b.mu.Lock() + defer b.mu.Unlock() + b.nextID++ + return fmt.Sprintf("perm_%d", b.nextID) +} + +func (b *PermissionBroker) find(requestID string) *pendingPermissionRequest { + b.mu.Lock() + defer b.mu.Unlock() + for _, req := range b.pending { + if req != nil && req.id == requestID { + return req + } + } + return nil +} + +func (b *PermissionBroker) remove(requestID string) { + b.mu.Lock() + defer b.mu.Unlock() + for i, req := range b.pending { + if req != nil && req.id == requestID { + b.pending = append(b.pending[:i], b.pending[i+1:]...) + return + } + } +} diff --git a/cmds/agentlineapp/acp/permission_test.go b/cmds/agentlineapp/acp/permission_test.go new file mode 100644 index 0000000..572a45e --- /dev/null +++ b/cmds/agentlineapp/acp/permission_test.go @@ -0,0 +1,100 @@ +package agentacp + +import ( + "context" + "testing" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +func TestPermissionBroker_RequestResolveSelected(t *testing.T) { + b := NewPermissionBroker() + respCh := make(chan acp.RequestPermissionResponse, 1) + + go func() { + resp, _ := b.RequestPermission(context.Background(), acp.RequestPermissionRequest{ + SessionId: "sess_1", + ToolCall: acp.RequestPermissionToolCall{ToolCallId: "call_1"}, + Options: []acp.PermissionOption{{OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}}, + }) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(b.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + pending := b.Pending() + if len(pending) != 1 { + t.Fatalf("expected 1 pending request, got %d", len(pending)) + } + + if err := b.ResolveFirstByKind(pending[0].RequestID, acp.PermissionOptionKindAllowOnce, acp.PermissionOptionKindAllowAlways); err != nil { + t.Fatalf("resolve by kind failed: %v", err) + } + + select { + case resp := <-respCh: + if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != "allow-once" { + t.Fatalf("unexpected selected outcome: %+v", resp.Outcome) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting response") + } +} + +func TestPermissionBroker_RequestCancelOnContextDone(t *testing.T) { + b := NewPermissionBroker() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + resp, err := b.RequestPermission(ctx, acp.RequestPermissionRequest{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Outcome.Cancelled == nil { + t.Fatalf("expected cancelled outcome") + } +} + +func TestCallbackClient_UsesPermissionBroker(t *testing.T) { + b := NewPermissionBroker() + client := &CallbackClient{PermissionBroker: b} + + respCh := make(chan acp.RequestPermissionResponse, 1) + go func() { + resp, _ := client.RequestPermission(context.Background(), acp.RequestPermissionRequest{ + SessionId: "sess_1", + ToolCall: acp.RequestPermissionToolCall{ToolCallId: "call_1"}, + Options: []acp.PermissionOption{{OptionId: "reject-once", Name: "Reject once", Kind: acp.PermissionOptionKindRejectOnce}}, + }) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(b.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + pending := b.Pending() + if len(pending) != 1 { + t.Fatalf("expected 1 pending request, got %d", len(pending)) + } + + if err := b.ResolveByIndex(pending[0].RequestID, 1); err != nil { + t.Fatalf("resolve by index failed: %v", err) + } + + select { + case resp := <-respCh: + if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != "reject-once" { + t.Fatalf("unexpected selected outcome: %+v", resp.Outcome) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting broker response") + } +} diff --git a/cmds/agentlineapp/acp/prompt_turn_permission_test.go b/cmds/agentlineapp/acp/prompt_turn_permission_test.go new file mode 100644 index 0000000..15a4b10 --- /dev/null +++ b/cmds/agentlineapp/acp/prompt_turn_permission_test.go @@ -0,0 +1,168 @@ +package agentacp + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +type updateCollector struct { + mu sync.Mutex + updates []acp.SessionNotification +} + +func (c *updateCollector) SessionUpdate(_ context.Context, params acp.SessionNotification) error { + c.mu.Lock() + defer c.mu.Unlock() + c.updates = append(c.updates, params) + return nil +} + +func (c *updateCollector) snapshot() []acp.SessionNotification { + c.mu.Lock() + defer c.mu.Unlock() + return append([]acp.SessionNotification(nil), c.updates...) +} + +func TestPromptTurnPermissionFlow(t *testing.T) { + broker := NewPermissionBroker() + client := &CallbackClient{PermissionBroker: broker} + collector := &updateCollector{} + + var bridge *AgentBridge + exec := PromptExecutorFunc(func(ctx context.Context, sessionID acp.SessionId, prompt []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) { + toolID := acp.ToolCallId("call_approve_1") + title := "apply patch" + if err := emit(acp.StartToolCall(toolID, title, + acp.WithStartKind(acp.ToolKindEdit), + acp.WithStartStatus(acp.ToolCallStatusPending), + )); err != nil { + return "", err + } + + resp, err := bridge.RequestPermission(ctx, acp.RequestPermissionRequest{ + SessionId: sessionID, + ToolCall: acp.RequestPermissionToolCall{ + ToolCallId: toolID, + Title: acp.Ptr(title), + Kind: acp.Ptr(acp.ToolKindEdit), + Status: acp.Ptr(acp.ToolCallStatusPending), + }, + Options: []acp.PermissionOption{ + {OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}, + {OptionId: "reject-once", Name: "Reject once", Kind: acp.PermissionOptionKindRejectOnce}, + }, + }) + if err != nil { + return "", err + } + + if resp.Outcome.Selected == nil || strings.TrimSpace(string(resp.Outcome.Selected.OptionId)) == "" { + if err := emit(acp.UpdateToolCall(toolID, + acp.WithUpdateStatus(acp.ToolCallStatusFailed), + acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("permission denied"))}), + )); err != nil { + return "", err + } + return acp.StopReasonRefusal, nil + } + + if err := emit(acp.UpdateToolCall(toolID, + acp.WithUpdateStatus(acp.ToolCallStatusInProgress), + )); err != nil { + return "", err + } + if err := emit(acp.UpdateToolCall(toolID, + acp.WithUpdateStatus(acp.ToolCallStatusCompleted), + acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("patch applied"))}), + )); err != nil { + return "", err + } + if err := emit(acp.UpdateAgentMessageText("done")); err != nil { + return "", err + } + return acp.StopReasonEndTurn, nil + }) + + bridge = NewAgentBridge(BridgeOptions{Executor: exec, PermissionRequester: client}) + bridge.SetSessionUpdater(collector) + + newResp, err := bridge.NewSession(context.Background(), acp.NewSessionRequest{Cwd: "/tmp", McpServers: nil}) + if err != nil { + t.Fatalf("new session failed: %v", err) + } + + promptRespCh := make(chan acp.PromptResponse, 1) + errCh := make(chan error, 1) + go func() { + resp, runErr := bridge.Prompt(context.Background(), acp.PromptRequest{ + SessionId: newResp.SessionId, + Prompt: []acp.ContentBlock{acp.TextBlock("please edit file")}, + }) + if runErr != nil { + errCh <- runErr + return + } + promptRespCh <- resp + }() + + var pendingID string + for i := 0; i < 100; i++ { + pending := broker.Pending() + if len(pending) > 0 { + pendingID = pending[0].RequestID + break + } + time.Sleep(10 * time.Millisecond) + } + if strings.TrimSpace(pendingID) == "" { + t.Fatal("permission request not observed") + } + + if err := broker.ResolveFirstByKind(pendingID, acp.PermissionOptionKindAllowOnce); err != nil { + t.Fatalf("resolve permission failed: %v", err) + } + + select { + case err := <-errCh: + t.Fatalf("prompt returned error: %v", err) + case resp := <-promptRespCh: + if resp.StopReason != acp.StopReasonEndTurn { + t.Fatalf("unexpected stop reason: %s", resp.StopReason) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting prompt response") + } + + updates := collector.snapshot() + if len(updates) == 0 { + t.Fatal("expected session updates") + } + + joined := make([]string, 0, len(updates)) + for _, u := range updates { + if u.Update.ToolCall != nil { + joined = append(joined, "tool_call:"+string(u.Update.ToolCall.Status)) + } + if u.Update.ToolCallUpdate != nil && u.Update.ToolCallUpdate.Status != nil { + joined = append(joined, "tool_update:"+string(*u.Update.ToolCallUpdate.Status)) + } + if u.Update.AgentMessageChunk != nil && u.Update.AgentMessageChunk.Content.Text != nil { + joined = append(joined, "assistant:"+u.Update.AgentMessageChunk.Content.Text.Text) + } + } + out := strings.Join(joined, " | ") + if !strings.Contains(out, "tool_call:pending") { + t.Fatalf("expected pending tool call status, got: %s", out) + } + if !strings.Contains(out, "tool_update:in_progress") || !strings.Contains(out, "tool_update:completed") { + t.Fatalf("expected in_progress/completed status, got: %s", out) + } + if !strings.Contains(out, "assistant:done") { + t.Fatalf("expected final assistant message, got: %s", out) + } +} diff --git a/cmds/agentlineapp/acp/updates.go b/cmds/agentlineapp/acp/updates.go new file mode 100644 index 0000000..2187e7d --- /dev/null +++ b/cmds/agentlineapp/acp/updates.go @@ -0,0 +1,177 @@ +package agentacp + +import ( + "fmt" + "strings" + + acp "github.com/coder/acp-go-sdk" +) + +// RenderedBlock 是从 ACP 事件提炼出的可展示结构。 +type RenderedBlock struct { + Kind string + Title string + Lines []string +} + +// RenderSessionNotification 将 ACP session/update 转换为可展示块。 +func RenderSessionNotification(params acp.SessionNotification) []RenderedBlock { + update := params.Update + blocks := make([]RenderedBlock, 0, 2) + + if u := update.UserMessageChunk; u != nil { + text := strings.TrimSpace(contentBlockSummary(u.Content)) + if text == "" { + text = "(empty user content)" + } + blocks = append(blocks, RenderedBlock{Kind: "user", Title: "user", Lines: []string{text}}) + } + + if u := update.AgentMessageChunk; u != nil { + text := strings.TrimSpace(contentBlockSummary(u.Content)) + if text == "" { + text = "(empty assistant content)" + } + blocks = append(blocks, RenderedBlock{Kind: "assistant", Title: "assistant", Lines: []string{text}}) + } + + if u := update.AgentThoughtChunk; u != nil { + text := strings.TrimSpace(contentBlockSummary(u.Content)) + if text == "" { + text = "(empty thought content)" + } + blocks = append(blocks, RenderedBlock{Kind: "system", Title: "thought", Lines: []string{text}}) + } + + if u := update.ToolCall; u != nil { + lines := []string{ + fmt.Sprintf("id: %s", strings.TrimSpace(string(u.ToolCallId))), + fmt.Sprintf("status: %s", strings.TrimSpace(string(u.Status))), + fmt.Sprintf("kind: %s", strings.TrimSpace(string(u.Kind))), + } + contentLines := toolContentSummaries(u.Content) + if len(contentLines) > 0 { + lines = append(lines, contentLines...) + } + blocks = append(blocks, RenderedBlock{Kind: "tool", Title: withDefault(strings.TrimSpace(u.Title), "tool_call"), Lines: lines}) + } + + if u := update.ToolCallUpdate; u != nil { + lines := []string{fmt.Sprintf("id: %s", strings.TrimSpace(string(u.ToolCallId)))} + if u.Status != nil { + lines = append(lines, fmt.Sprintf("status: %s", strings.TrimSpace(string(*u.Status)))) + } + if u.Kind != nil { + lines = append(lines, fmt.Sprintf("kind: %s", strings.TrimSpace(string(*u.Kind)))) + } + contentLines := toolContentSummaries(u.Content) + if len(contentLines) > 0 { + lines = append(lines, contentLines...) + } + title := "tool_update" + if u.Title != nil && strings.TrimSpace(*u.Title) != "" { + title = strings.TrimSpace(*u.Title) + } + blocks = append(blocks, RenderedBlock{Kind: "tool", Title: title, Lines: lines}) + } + + if u := update.Plan; u != nil { + lines := make([]string, 0, len(u.Entries)) + for idx, entry := range u.Entries { + lines = append(lines, fmt.Sprintf("%d. [%s/%s] %s", idx+1, strings.TrimSpace(string(entry.Status)), strings.TrimSpace(string(entry.Priority)), strings.TrimSpace(entry.Content))) + } + if len(lines) == 0 { + lines = append(lines, "(empty plan)") + } + blocks = append(blocks, RenderedBlock{Kind: "system", Title: "plan", Lines: lines}) + } + + if u := update.AvailableCommandsUpdate; u != nil { + lines := make([]string, 0, len(u.AvailableCommands)) + for _, c := range u.AvailableCommands { + name := strings.TrimSpace(c.Name) + desc := strings.TrimSpace(c.Description) + if name == "" { + continue + } + if desc == "" { + lines = append(lines, name) + } else { + lines = append(lines, fmt.Sprintf("%s: %s", name, desc)) + } + } + if len(lines) == 0 { + lines = append(lines, "(no available commands)") + } + blocks = append(blocks, RenderedBlock{Kind: "system", Title: "commands", Lines: lines}) + } + + if u := update.CurrentModeUpdate; u != nil { + blocks = append(blocks, RenderedBlock{Kind: "system", Title: "mode", Lines: []string{fmt.Sprintf("current mode: %s", strings.TrimSpace(string(u.CurrentModeId)))}}) + } + + return blocks +} + +func contentBlockSummary(block acp.ContentBlock) string { + if block.Text != nil { + return strings.TrimSpace(block.Text.Text) + } + if block.ResourceLink != nil { + name := strings.TrimSpace(block.ResourceLink.Name) + uri := strings.TrimSpace(block.ResourceLink.Uri) + if name == "" { + name = "resource" + } + if uri == "" { + return name + } + return fmt.Sprintf("%s (%s)", name, uri) + } + if block.Resource != nil { + if block.Resource.Resource.TextResourceContents != nil { + textRes := block.Resource.Resource.TextResourceContents + return fmt.Sprintf("resource text: %s", strings.TrimSpace(textRes.Uri)) + } + if block.Resource.Resource.BlobResourceContents != nil { + blobRes := block.Resource.Resource.BlobResourceContents + return fmt.Sprintf("resource blob: %s", strings.TrimSpace(blobRes.Uri)) + } + return "resource" + } + if block.Image != nil { + return fmt.Sprintf("image: %s", strings.TrimSpace(block.Image.MimeType)) + } + if block.Audio != nil { + return fmt.Sprintf("audio: %s", strings.TrimSpace(block.Audio.MimeType)) + } + return "" +} + +func toolContentSummaries(contents []acp.ToolCallContent) []string { + lines := make([]string, 0, len(contents)) + for _, c := range contents { + if c.Content != nil { + s := strings.TrimSpace(contentBlockSummary(c.Content.Content)) + if s != "" { + lines = append(lines, s) + } + continue + } + if c.Diff != nil { + lines = append(lines, fmt.Sprintf("diff: %s", strings.TrimSpace(c.Diff.Path))) + continue + } + if c.Terminal != nil { + lines = append(lines, fmt.Sprintf("terminal: %s", strings.TrimSpace(c.Terminal.TerminalId))) + } + } + return lines +} + +func withDefault(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return strings.TrimSpace(v) +} diff --git a/cmds/agentlineapp/acp_adapter.go b/cmds/agentlineapp/acp_adapter.go new file mode 100644 index 0000000..02c8ec7 --- /dev/null +++ b/cmds/agentlineapp/acp_adapter.go @@ -0,0 +1,65 @@ +package agentlineapp + +import ( + "strings" + + acp "github.com/coder/acp-go-sdk" + + agentacp "github.com/pubgo/fastgit/cmds/agentlineapp/acp" +) + +// sessionBlocksFromACP 将 ACP session/update 转换为 agentline 输出块。 +func sessionBlocksFromACP(params acp.SessionNotification) []sessionBlock { + rendered := agentacp.RenderSessionNotification(params) + if len(rendered) == 0 { + return nil + } + + out := make([]sessionBlock, 0, len(rendered)) + for _, item := range rendered { + kind := mapACPBlockKind(item.Kind) + title := strings.TrimSpace(item.Title) + if title == "" { + title = string(kind) + } + out = append(out, sessionBlock{ + Kind: kind, + Title: title, + Lines: append([]string(nil), item.Lines...), + }) + } + + return out +} + +// appendACPSessionNotification 将 ACP 事件直接写入当前会话输出。 +func (m *agentlineModel) appendACPSessionNotification(params acp.SessionNotification) { + if m == nil { + return + } + m.recordACPEvent(params) + blocks := sessionBlocksFromACP(params) + if len(blocks) == 0 { + return + } + m.appendBlocks(blocks) + m.outputOffset = 0 + m.normalizeOutputOffset() +} + +func mapACPBlockKind(kind string) blockKind { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "user": + return blockKindUser + case "assistant": + return blockKindAssistant + case "tool": + return blockKindTool + case "result": + return blockKindResult + case "error": + return blockKindError + default: + return blockKindSystem + } +} diff --git a/cmds/agentlineapp/acp_adapter_test.go b/cmds/agentlineapp/acp_adapter_test.go new file mode 100644 index 0000000..1fcdde9 --- /dev/null +++ b/cmds/agentlineapp/acp_adapter_test.go @@ -0,0 +1,91 @@ +package agentlineapp + +import ( + "context" + "strings" + "testing" + + acp "github.com/coder/acp-go-sdk" +) + +func TestSessionBlocksFromACP_AgentAndToolUpdates(t *testing.T) { + notification := acp.SessionNotification{ + SessionId: "sess_1", + Update: acp.UpdateToolCall("call_1", + acp.WithUpdateTitle("run tests"), + acp.WithUpdateKind(acp.ToolKindExecute), + acp.WithUpdateStatus(acp.ToolCallStatusCompleted), + acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("ok"))}), + ), + } + + blocks := sessionBlocksFromACP(notification) + if len(blocks) != 1 { + t.Fatalf("expected one block, got %d", len(blocks)) + } + if blocks[0].Kind != blockKindTool { + t.Fatalf("expected tool block kind, got %s", blocks[0].Kind) + } + joined := strings.Join(blocks[0].Lines, "\n") + if !strings.Contains(joined, "status: completed") { + t.Fatalf("expected completed status, got: %s", joined) + } + if !strings.Contains(joined, "ok") { + t.Fatalf("expected tool content summary, got: %s", joined) + } +} + +func TestAppendACPSessionNotification_AppendsBlocks(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + initial := len(m.blocks) + + m.appendACPSessionNotification(acp.SessionNotification{ + SessionId: "sess_1", + Update: acp.UpdateAgentMessageText("hello from acp"), + }) + + if len(m.blocks) != initial+1 { + t.Fatalf("expected block count +1, got initial=%d current=%d", initial, len(m.blocks)) + } + last := m.blocks[len(m.blocks)-1] + if last.Kind != blockKindAssistant { + t.Fatalf("expected assistant kind, got %s", last.Kind) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "hello from acp") { + t.Fatalf("expected ACP message in output block") + } +} + +func TestSessionBlocksFromACP_PlanAndMode(t *testing.T) { + planUpdate := acp.UpdatePlan(acp.PlanEntry{ + Content: "执行回归测试", + Priority: acp.PlanEntryPriorityHigh, + Status: acp.PlanEntryStatusInProgress, + }) + modeUpdate := acp.SessionUpdate{ + CurrentModeUpdate: &acp.SessionCurrentModeUpdate{ + SessionUpdate: "current_mode_update", + CurrentModeId: "code", + }, + } + + planBlocks := sessionBlocksFromACP(acp.SessionNotification{SessionId: "sess_1", Update: planUpdate}) + if len(planBlocks) != 1 { + t.Fatalf("expected one plan block, got %d", len(planBlocks)) + } + if planBlocks[0].Kind != blockKindSystem { + t.Fatalf("expected system block for plan, got %s", planBlocks[0].Kind) + } + if !strings.Contains(strings.Join(planBlocks[0].Lines, "\n"), "执行回归测试") { + t.Fatalf("expected plan content in plan block") + } + + modeBlocks := sessionBlocksFromACP(acp.SessionNotification{SessionId: "sess_1", Update: modeUpdate}) + if len(modeBlocks) != 1 { + t.Fatalf("expected one mode block, got %d", len(modeBlocks)) + } + if !strings.Contains(strings.Join(modeBlocks[0].Lines, "\n"), "current mode: code") { + t.Fatalf("expected mode summary") + } +} diff --git a/cmds/agentlineapp/acp_events.go b/cmds/agentlineapp/acp_events.go new file mode 100644 index 0000000..3007cc3 --- /dev/null +++ b/cmds/agentlineapp/acp_events.go @@ -0,0 +1,238 @@ +package agentlineapp + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + acp "github.com/coder/acp-go-sdk" +) + +const maxACPEventEntries = 2000 + +type acpEventEntry struct { + Seq int64 + CapturedAt time.Time + Notification acp.SessionNotification +} + +type acpEventExportRecord struct { + Seq int64 `json:"seq"` + CapturedAt string `json:"captured_at"` + SessionID string `json:"session_id"` + Kind string `json:"kind"` + Summary string `json:"summary"` + Raw acp.SessionNotification `json:"raw"` +} + +func (m *agentlineModel) recordACPEvent(params acp.SessionNotification) { + if m == nil { + return + } + m.acpEventSeq++ + m.acpEventEntries = append(m.acpEventEntries, acpEventEntry{ + Seq: m.acpEventSeq, + CapturedAt: time.Now(), + Notification: params, + }) + if len(m.acpEventEntries) > maxACPEventEntries { + extra := len(m.acpEventEntries) - maxACPEventEntries + m.acpEventEntries = append([]acpEventEntry(nil), m.acpEventEntries[extra:]...) + } +} + +func (m *agentlineModel) acpEventsSnapshot() []acpEventEntry { + if m == nil || len(m.acpEventEntries) == 0 { + return nil + } + return append([]acpEventEntry(nil), m.acpEventEntries...) +} + +func (m *agentlineModel) acpEventsTimelineLines(limit int) []string { + events := m.acpEventsSnapshot() + if len(events) == 0 { + return []string{"暂无 ACP 事件。先运行 /acp-demo 触发一轮交互。"} + } + + if limit <= 0 { + limit = 40 + } + start := 0 + if len(events) > limit { + start = len(events) - limit + } + + lines := make([]string, 0, len(events)-start+2) + lines = append(lines, fmt.Sprintf("total: %d, showing: %d-%d", len(events), start+1, len(events))) + for i := start; i < len(events); i++ { + e := events[i] + kind := acpEventKind(e.Notification) + summary := compactEventText(acpEventSummary(e.Notification), 120) + lines = append(lines, fmt.Sprintf("%03d [%s] t=%s session=%s %s", e.Seq, kind, e.CapturedAt.Format("15:04:05.000"), strings.TrimSpace(string(e.Notification.SessionId)), summary)) + } + return lines +} + +func (m *agentlineModel) acpEventsSummaryLines() []string { + events := m.acpEventsSnapshot() + if len(events) == 0 { + return []string{"暂无 ACP 事件。"} + } + + kindCount := map[string]int{} + sessionCount := map[string]int{} + for _, e := range events { + kindCount[acpEventKind(e.Notification)]++ + session := strings.TrimSpace(string(e.Notification.SessionId)) + if session == "" { + session = "(empty)" + } + sessionCount[session]++ + } + + lines := []string{fmt.Sprintf("total events: %d", len(events))} + lines = append(lines, "kind counts:") + for _, item := range sortedCountItems(kindCount) { + lines = append(lines, fmt.Sprintf(" - %s: %d", item.key, item.value)) + } + lines = append(lines, "session counts:") + for _, item := range sortedCountItems(sessionCount) { + lines = append(lines, fmt.Sprintf(" - %s: %d", item.key, item.value)) + } + return lines +} + +type kvCount struct { + key string + value int +} + +func sortedCountItems(m map[string]int) []kvCount { + items := make([]kvCount, 0, len(m)) + for k, v := range m { + items = append(items, kvCount{key: k, value: v}) + } + sort.Slice(items, func(i, j int) bool { + if items[i].value != items[j].value { + return items[i].value > items[j].value + } + return items[i].key < items[j].key + }) + return items +} + +func (m *agentlineModel) exportACPEventsJSONL(path string) (int, error) { + events := m.acpEventsSnapshot() + if len(events) == 0 { + return 0, nil + } + + path = strings.TrimSpace(path) + if path == "" { + path = ".local/data.jsonl" + } + dir := filepath.Dir(path) + if strings.TrimSpace(dir) != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return 0, err + } + } + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return 0, err + } + defer f.Close() + + enc := json.NewEncoder(f) + written := 0 + for _, e := range events { + rec := acpEventExportRecord{ + Seq: e.Seq, + CapturedAt: e.CapturedAt.Format(time.RFC3339Nano), + SessionID: strings.TrimSpace(string(e.Notification.SessionId)), + Kind: acpEventKind(e.Notification), + Summary: acpEventSummary(e.Notification), + Raw: e.Notification, + } + if err := enc.Encode(rec); err != nil { + return written, err + } + written++ + } + return written, nil +} + +func parsePositiveIntOrDefault(raw string, def int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return def + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return def + } + return n +} + +func acpEventKind(n acp.SessionNotification) string { + u := n.Update + switch { + case u.UserMessageChunk != nil: + return "user_message" + case u.AgentMessageChunk != nil: + return "assistant_message" + case u.AgentThoughtChunk != nil: + return "thought" + case u.ToolCall != nil: + return "tool_call" + case u.ToolCallUpdate != nil: + return "tool_update" + case u.Plan != nil: + return "plan" + case u.AvailableCommandsUpdate != nil: + return "commands_update" + case u.CurrentModeUpdate != nil: + return "mode_update" + default: + return "unknown" + } +} + +func acpEventSummary(n acp.SessionNotification) string { + blocks := sessionBlocksFromACP(n) + if len(blocks) == 0 { + return "(no rendered blocks)" + } + first := blocks[0] + line := "" + if len(first.Lines) > 0 { + line = strings.TrimSpace(first.Lines[0]) + } + if line == "" { + line = strings.TrimSpace(first.Title) + } + if line == "" { + line = "(empty)" + } + return line +} + +func compactEventText(s string, max int) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\t", " ") + s = strings.Join(strings.Fields(s), " ") + if max <= 0 || len(s) <= max { + return s + } + if max <= 1 { + return "…" + } + return s[:max-1] + "…" +} diff --git a/cmds/agentlineapp/acp_events_test.go b/cmds/agentlineapp/acp_events_test.go new file mode 100644 index 0000000..b319c7d --- /dev/null +++ b/cmds/agentlineapp/acp_events_test.go @@ -0,0 +1,71 @@ +package agentlineapp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + acp "github.com/coder/acp-go-sdk" +) + +func TestHandleSlashInput_ACPEventsTimeline(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + m.appendACPSessionNotification(acp.SessionNotification{ + SessionId: "sess_1", + Update: acp.UpdateUserMessage(acp.TextBlock("hello")), + }) + m.appendACPSessionNotification(acp.SessionNotification{ + SessionId: "sess_1", + Update: acp.UpdateAgentMessageText("hi"), + }) + + handled, cmd := m.handleSlashInput("/acp-events 10") + if !handled || cmd != nil { + t.Fatalf("expected /acp-events handled without async cmd") + } + + last := m.blocks[len(m.blocks)-1] + if last.Title != "/acp-events" { + t.Fatalf("expected /acp-events block title, got %q", last.Title) + } + joined := strings.Join(last.Lines, "\n") + if !strings.Contains(joined, "total:") { + t.Fatalf("expected timeline total line, got: %s", joined) + } + if !strings.Contains(joined, "assistant_message") { + t.Fatalf("expected assistant_message in timeline, got: %s", joined) + } +} + +func TestHandleSlashInput_ACPEventsExport(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + m.appendACPSessionNotification(acp.SessionNotification{ + SessionId: "sess_export", + Update: acp.UpdateAgentMessageText("export me"), + }) + + dir := t.TempDir() + path := filepath.Join(dir, "data.jsonl") + handled, cmd := m.handleSlashInput("/acp-events-export " + path) + if !handled || cmd != nil { + t.Fatalf("expected /acp-events-export handled without async cmd") + } + + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read exported file failed: %v", err) + } + content := string(b) + if !strings.Contains(content, "sess_export") { + t.Fatalf("expected exported session id in jsonl: %s", content) + } + if !strings.Contains(content, "assistant_message") { + t.Fatalf("expected exported kind in jsonl: %s", content) + } +} diff --git a/cmds/agentlineapp/agentline.go b/cmds/agentlineapp/agentline.go new file mode 100644 index 0000000..6fc4a92 --- /dev/null +++ b/cmds/agentlineapp/agentline.go @@ -0,0 +1,1540 @@ +package agentlineapp + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + acp "github.com/coder/acp-go-sdk" + + agentacp "github.com/pubgo/fastgit/cmds/agentlineapp/acp" + agentlinemodule "github.com/pubgo/fastgit/pkg/agentline" + "github.com/pubgo/redant" +) + +const ( + defaultSuggestionRows = 8 + defaultOutputRows = 20 + defaultInputRows = 4 + minOutputRows = 6 + maxOutputBlocks = 500 + maxOutputLines = 4000 +) + +type mouseRegion string + +const ( + mouseRegionOutput mouseRegion = "output" + mouseRegionInput mouseRegion = "input" +) + +type mouseScrollMsg struct { + Region mouseRegion + Delta int +} + +type mouseFocusMsg struct { + Region mouseRegion +} + +type mouseSelectHistoryMsg struct { + HistoryIndex int +} + +type blockKind string + +const ( + blockKindSystem blockKind = "system" + blockKindUser blockKind = "user" + blockKindAssistant blockKind = "assistant" + blockKindTool blockKind = "tool" + blockKindCommand blockKind = "command" + blockKindResult blockKind = "result" + blockKindError blockKind = "error" +) + +type sessionBlock struct { + Kind blockKind + Title string + Lines []string +} + +type completionItem struct { + Insert string + Description string +} + +type stickyInvocation struct { + BaseArgs []string + PromptFlag string +} + +type interactionMode string + +const ( + interactionModeCommand interactionMode = "command" + interactionModeChat interactionMode = "chat" +) + +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")) + styleSelected = lipgloss.NewStyle().Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")).Bold(true) + styleHistorySelected = lipgloss.NewStyle().Background(lipgloss.Color("60")).Foreground(lipgloss.Color("230")) + styleRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) + styleDesc = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + styleStatusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("114")).Bold(true) + styleStatusBusy = lipgloss.NewStyle().Foreground(lipgloss.Color("220")).Bold(true) + + styleKindSystem = lipgloss.NewStyle().Foreground(lipgloss.Color("110")).Bold(true) + styleKindUser = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) + styleKindAssistant = lipgloss.NewStyle().Foreground(lipgloss.Color("141")).Bold(true) + styleKindTool = lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true) + styleKindCommand = lipgloss.NewStyle().Foreground(lipgloss.Color("150")).Bold(true) + styleKindResult = lipgloss.NewStyle().Foreground(lipgloss.Color("121")).Bold(true) + styleKindError = lipgloss.NewStyle().Foreground(lipgloss.Color("203")).Bold(true) +) + +type RuntimeOptions struct { + Prompt string + HistoryFile string + NoHistory bool + InitialArgv []string + Stdin io.Reader + Stdout io.Writer +} + +func Run(ctx context.Context, root *redant.Command, opts *RuntimeOptions) error { + if root == nil { + return errors.New("agentline runtime requires non-nil root command") + } + + cfg := RuntimeOptions{} + if opts != nil { + cfg = *opts + } + + historyFile := strings.TrimSpace(cfg.HistoryFile) + if historyFile == "" && !cfg.NoHistory { + if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" { + historyFile = filepath.Join(home, ".redant_agentline_history") + } + } + + historyLines := []string{} + if !cfg.NoHistory && historyFile != "" { + historyLines = loadHistory(historyFile) + } + + input := cfg.Stdin + if input == nil { + input = os.Stdin + } + output := cfg.Stdout + if output == nil { + output = os.Stdout + } + + model := newAgentlineModel(ctx, root, strings.TrimSpace(cfg.Prompt), historyLines, historyFile, !cfg.NoHistory, append([]string(nil), cfg.InitialArgv...)) + p := tea.NewProgram(model, tea.WithInput(input), tea.WithOutput(output)) + + done := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + p.Quit() + case <-done: + } + }() + + _, err := p.Run() + close(done) + return err +} + +func (m *agentlineModel) buildStickyCommandLine(prompt string) string { + if m == nil || m.stickyInvocation == nil || m.root == nil { + return "" + } + + prompt = strings.TrimSpace(prompt) + if prompt == "" { + prompt = "继续" + } + + args := append([]string(nil), m.stickyInvocation.BaseArgs...) + args = append(args, m.stickyInvocation.PromptFlag, prompt) + return formatCommandLine(m.root.Name(), args) +} + +func buildStickyInvocation(root *redant.Command, commandLine string, agentOnly bool) (*stickyInvocation, error) { + args, err := splitCommandLine(commandLine) + if err != nil { + return nil, fmt.Errorf("解析命令失败: %w", err) + } + if len(args) == 0 { + return nil, errors.New("/chat 需要指定命令,例如 /chat commit --message hi") + } + + resolvedLine := strings.Join(args, " ") + cmd, ok := resolveCommandLikeInput(root, resolvedLine, false) + if !ok || cmd == nil { + return nil, errors.New("/chat 仅支持可执行命令") + } + if agentOnly && !agentlinemodule.IsAgentCommand(cmd.Metadata) { + return nil, errors.New("当前命令未标记为 agent 命令,无法进入聊天粘性模式") + } + + promptFlag := strings.TrimSpace(agentlinemodule.Meta(cmd.Metadata, "agentline.prompt-flag")) + if promptFlag == "" { + promptFlag = "--prompt" + } + + baseArgs := stripRootIfPresent(root, args) + baseArgs = stripPromptArg(baseArgs, promptFlag) + if len(baseArgs) == 0 { + return nil, errors.New("无法提取聊天粘性命令参数") + } + + return &stickyInvocation{BaseArgs: baseArgs, PromptFlag: promptFlag}, nil +} + +func stripRootIfPresent(root *redant.Command, args []string) []string { + if root == nil || len(args) == 0 { + return append([]string(nil), args...) + } + if strings.TrimSpace(args[0]) == root.Name() { + return append([]string(nil), args[1:]...) + } + return append([]string(nil), args...) +} + +func stripPromptArg(args []string, promptFlag string) []string { + promptFlag = strings.TrimSpace(promptFlag) + if promptFlag == "" { + return append([]string(nil), args...) + } + + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + item := strings.TrimSpace(args[i]) + if item == promptFlag { + if i+1 < len(args) { + i++ + } + continue + } + if strings.HasPrefix(item, promptFlag+"=") { + continue + } + out = append(out, args[i]) + } + return out +} + +type agentlineModel struct { + ctx context.Context + root *redant.Command + input textinput.Model + prompt string + mode interactionMode + sessionCWD string + sessionGitBranch string + sessionGitDirty bool + stickyInvocation *stickyInvocation + history []string + historyPos int + historyFile string + persistHistory bool + blocks []sessionBlock + suggestions []completionItem + selected int + running bool + width int + height int + outputOffset int + outputFocus bool + inputOffset int + selectedHistory int + foldDetails bool + currentCancel context.CancelFunc + initialArgv []string + agentOnlyMode bool + permissionBroker *agentacp.PermissionBroker + questionBroker *QuestionBroker + acpEventSeq int64 + acpEventEntries []acpEventEntry +} + +type runResultMsg struct { + blocks []sessionBlock + quit bool +} + +type acpDemoResultMsg struct { + blocks []sessionBlock + err error +} + +func newAgentlineModel(ctx context.Context, root *redant.Command, prompt string, history []string, historyFile string, persist bool, initialArgv []string) *agentlineModel { + 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 = "agent> " + ti.Prompt = prompt + } + + agentOnlyMode := true + hasAgentCommands := hasAnyAgentCommand(root) + sessionCWD, sessionGitBranch, sessionGitDirty := detectSessionContext() + + m := &agentlineModel{ + ctx: ctx, + root: root, + input: ti, + prompt: prompt, + mode: interactionModeCommand, + sessionCWD: sessionCWD, + sessionGitBranch: sessionGitBranch, + sessionGitDirty: sessionGitDirty, + history: append([]string(nil), history...), + historyPos: len(history), + historyFile: historyFile, + persistHistory: persist, + selectedHistory: -1, + initialArgv: append([]string(nil), initialArgv...), + agentOnlyMode: agentOnlyMode, + permissionBroker: agentacp.NewPermissionBroker(), + questionBroker: NewQuestionBroker(), + blocks: []sessionBlock{{ + Kind: blockKindSystem, + Title: "system", + Lines: []string{ + "agentline started. 默认输入会自动识别为命令执行。", + fmt.Sprintf("cwd: %s", displayPath(sessionCWD)), + fmt.Sprintf("git: %s", displayGitBranch(sessionGitBranch, sessionGitDirty)), + "试试:/run commit --help、/history、/output", + "快捷键:Tab 补全,↑/↓ 选择候选,Ctrl+O 切换输出滚动,Ctrl+C 退出。", + "复制提示:支持直接鼠标拖拽选择并复制。", + }, + }}, + } + if agentOnlyMode { + m.blocks[0].Lines = append(m.blocks[0].Lines, "仅加载显式声明为 agent 的命令(metadata: agent.command=true 或等价 agent entry)。") + if !hasAgentCommands { + m.blocks[0].Lines = append(m.blocks[0].Lines, "当前未检测到任何 agent 命令,请先为目标命令设置 metadata。") + } + } + m.recomputeSuggestions() + return m +} + +func (m *agentlineModel) Init() tea.Cmd { + if len(m.initialArgv) == 0 { + return nil + } + + request := formatCommandLine(m.root.Name(), m.initialArgv) + return m.startCommandRun(request) +} + +func (m *agentlineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case runResultMsg: + m.running = false + m.currentCancel = nil + if len(msg.blocks) > 0 { + m.appendBlocks(msg.blocks) + m.outputOffset = 0 + } + if msg.quit { + return m, tea.Quit + } + m.normalizeOutputOffset() + m.normalizeInputOffset() + m.recomputeSuggestions() + return m, nil + + case acpDemoResultMsg: + m.running = false + m.currentCancel = nil + if len(msg.blocks) > 0 { + m.appendBlocks(msg.blocks) + m.outputOffset = 0 + } + if msg.err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "acp.demo", Lines: []string{fmt.Sprintf("%v", msg.err)}}) + } + m.normalizeOutputOffset() + m.normalizeInputOffset() + m.recomputeSuggestions() + return m, nil + + case mouseScrollMsg: + switch msg.Region { + case mouseRegionInput: + m.outputFocus = false + m.scrollInputLines(msg.Delta) + default: + m.outputFocus = true + m.scrollOutputLines(msg.Delta) + } + return m, nil + + case mouseSelectHistoryMsg: + if msg.HistoryIndex < 0 || msg.HistoryIndex >= len(m.history) { + return m, nil + } + m.outputFocus = false + m.historyPos = msg.HistoryIndex + m.selectedHistory = msg.HistoryIndex + m.input.SetValue(m.history[msg.HistoryIndex]) + m.input.CursorEnd() + m.recomputeSuggestions() + m.normalizeInputOffset() + return m, nil + + case mouseFocusMsg: + switch msg.Region { + case mouseRegionInput: + m.outputFocus = false + case mouseRegionOutput: + m.outputFocus = true + } + 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() + m.normalizeInputOffset() + return m, nil + + case tea.KeyMsg: + key := msg.String() + switch key { + case "ctrl+c": + if m.running { + if m.cancelActiveRun("收到 Ctrl+C,正在尝试中断当前任务...") { + return m, nil + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "interrupt", Lines: []string{"当前任务不支持中断,等待完成中..."}}) + m.normalizeOutputOffset() + return m, nil + } + return m, tea.Quit + case "ctrl+o": + m.outputFocus = !m.outputFocus + return m, nil + case "esc": + if m.outputFocus { + m.outputFocus = false + return m, nil + } + m.suggestions = nil + m.selected = 0 + return m, nil + } + + if m.outputFocus && len(m.suggestions) == 0 { + 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 = collectStarterSlashItems() + m.suggestions = append(m.suggestions, collectCommandSlashItems(m.root, m.agentOnlyMode, "")...) + m.suggestions = uniqueCompletionItems(m.suggestions) + m.selected = 0 + return m, nil + } + 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() + m.normalizeInputOffset() + 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() + m.normalizeInputOffset() + return m, nil + + case "enter": + line := strings.TrimSpace(m.input.Value()) + if line == "" { + return m, nil + } + + if m.running { + if !strings.HasPrefix(line, "/") && m.questionBroker != nil && len(m.questionBroker.Pending()) > 0 { + m.appendHistory(line) + m.input.SetValue("") + m.historyPos = len(m.history) + m.suggestions = nil + m.selected = 0 + m.inputOffset = 0 + m.selectedHistory = -1 + + if err := m.questionBroker.Reply("", line); err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "reply.direct", Lines: []string{err.Error()}}) + } else { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "reply.direct", Lines: []string{"已作为问题回复提交(latest)。"}}) + } + m.normalizeOutputOffset() + return m, nil + } + + if !isAllowedWhileRunning(line) { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "running", Lines: []string{ + "当前任务执行中,仅支持 /permissions、/allow、/deny、/questions、/reply、/skip、/cancel。", + "若存在待回答问题,也可直接输入文本并回车(默认回复最新问题)。", + }}) + m.normalizeOutputOffset() + return m, nil + } + } + + m.appendHistory(line) + m.input.SetValue("") + m.historyPos = len(m.history) + m.suggestions = nil + m.selected = 0 + m.inputOffset = 0 + m.selectedHistory = -1 + + return m, m.dispatchInputLine(line) + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.recomputeSuggestions() + m.normalizeOutputOffset() + m.normalizeInputOffset() + return m, cmd +} + +func (m *agentlineModel) dispatchInputLine(line string) tea.Cmd { + if handled, cmd := m.handleSlashInput(line); handled { + return cmd + } + + if m.shouldRenderChatUserInput(line) { + m.appendBlock(sessionBlock{Kind: blockKindUser, Title: "user", Lines: []string{strings.TrimSpace(line)}}) + m.outputOffset = 0 + m.normalizeOutputOffset() + } + + if request, ok := m.resolveExecutionRequest(line); ok { + return m.startCommandRun(request) + } + + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "input", Lines: []string{ + "当前为精简命令模式:请使用 /run 或 /。", + "输入 /help 查看可用命令。", + }}) + m.outputOffset = 0 + m.normalizeOutputOffset() + return nil +} + +func (m *agentlineModel) shouldRenderChatUserInput(line string) bool { + if m == nil || !m.isChatMode() { + return false + } + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return false + } + if strings.HasPrefix(trimmed, "/") { + return false + } + // 若本身是显式命令输入,则按命令处理,不作为自然语言 chat 轮次回显。 + if isCommandLikeInput(m.root, trimmed, m.agentOnlyMode) { + return false + } + return true +} + +func (m *agentlineModel) resolveExecutionRequest(line string) (string, bool) { + line = strings.TrimSpace(line) + if line == "" { + return "", false + } + + if isCommandLikeInput(m.root, line, m.agentOnlyMode) { + return line, true + } + + if m.isChatMode() { + request := m.buildStickyCommandLine(line) + if strings.TrimSpace(request) != "" { + return request, true + } + } + + return "", false +} + +func (m *agentlineModel) bindStickyInvocation(sticky *stickyInvocation) { + m.stickyInvocation = sticky + if sticky == nil { + m.mode = interactionModeCommand + return + } + m.mode = interactionModeChat +} + +func (m *agentlineModel) unbindStickyInvocation() { + m.stickyInvocation = nil + m.mode = interactionModeCommand +} + +func (m *agentlineModel) isChatMode() bool { + return m.mode == interactionModeChat && m.stickyInvocation != nil +} + +func runSlashRunCmd(ctx context.Context, m *agentlineModel, root *redant.Command, commandLine string) tea.Cmd { + return func() tea.Msg { + startedAt := time.Now() + request := strings.TrimSpace(commandLine) + blocks := []sessionBlock{{ + Kind: blockKindTool, + Title: "tool.run", + Lines: []string{fmt.Sprintf("request: %s", request)}, + }} + + appendResult := func(status string, output []string, runErr error) runResultMsg { + lines := make([]string, 0, len(output)+2) + lines = append(lines, fmt.Sprintf("status: %s", status)) + lines = append(lines, fmt.Sprintf("duration: %s", formatDuration(time.Since(startedAt)))) + if len(output) == 0 { + lines = append(lines, "(no output)") + } else { + lines = append(lines, output...) + } + resultBlocks := append(blocks, sessionBlock{Kind: blockKindResult, Title: "result", Lines: lines}) + if runErr != nil { + resultBlocks = append(resultBlocks, sessionBlock{Kind: blockKindError, Title: "error", Lines: []string{fmt.Sprintf("%v", runErr)}}) + } + return runResultMsg{blocks: resultBlocks} + } + + args, parseErr := splitCommandLine(request) + if parseErr != nil { + blocks = append(blocks, sessionBlock{Kind: blockKindError, Title: "parse", Lines: []string{fmt.Sprintf("parse input failed: %v", parseErr)}}) + return appendResult("failed", nil, parseErr) + } + if len(args) == 0 { + err := errors.New("empty command") + blocks = append(blocks, sessionBlock{Kind: blockKindError, Title: "parse", Lines: []string{err.Error()}}) + return appendResult("failed", nil, err) + } + if args[0] == root.Name() { + args = args[1:] + } + if len(args) == 0 { + err := errors.New("missing target command after root name") + blocks = append(blocks, sessionBlock{Kind: blockKindError, Title: "parse", Lines: []string{err.Error()}}) + return appendResult("failed", nil, err) + } + + blocks = append(blocks, sessionBlock{Kind: blockKindTool, Title: "tool.parse", Lines: []string{ + fmt.Sprintf("argv: %v", args), + fmt.Sprintf("argc: %d", len(args)), + }}) + + title := "$ " + formatCommandLine(root.Name(), args) + blocks = append(blocks, sessionBlock{Kind: blockKindCommand, Title: title, Lines: []string{"dispatching..."}}) + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + + runInv := root.Invoke(args...) + runInv.Stdout = stdout + runInv.Stderr = stderr + runInv.Stdin = strings.NewReader("") + if runInv.Annotations == nil { + runInv.Annotations = map[string]any{} + } + runInv.Annotations[InteractionAnnotationKey] = &runtimeInteractionBridge{ + emitFn: func(_ context.Context, event InteractionEvent) error { + kind := interactionKindToBlockKind(event.Kind) + title := strings.TrimSpace(event.Title) + if title == "" { + title = "interaction" + } + lines := append([]string(nil), event.Lines...) + if len(lines) == 0 { + lines = []string{"(empty)"} + } + blocks = append(blocks, sessionBlock{Kind: kind, Title: title, Lines: lines}) + return nil + }, + askFn: func(ctx context.Context, req AskRequest) (AskResponse, error) { + if m == nil || m.questionBroker == nil { + return AskResponse{Cancelled: true}, nil + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return AskResponse{}, errors.New("ask prompt is empty") + } + blocks = append(blocks, sessionBlock{Kind: blockKindSystem, Title: "ask.pending", Lines: []string{ + prompt, + "使用 /questions 查看待回答问题,使用 /reply [ask_id] 回复,/skip [ask_id] 跳过。", + "也可直接输入文本并回车(默认回复最新问题)。", + }}) + return m.questionBroker.Request(ctx, req) + }, + } + + runErr := runInv.WithContext(ctx).Run() + status := runStatus(runErr) + + resultLines := make([]string, 0, 8) + if out := strings.TrimSpace(stdout.String()); out != "" { + resultLines = append(resultLines, strings.Split(out, "\n")...) + } + if out := strings.TrimSpace(stderr.String()); out != "" { + resultLines = append(resultLines, strings.Split(out, "\n")...) + } + return appendResult(status, resultLines, runErr) + } +} + +func isCommandLikeInput(root *redant.Command, line string, agentOnly bool) bool { + return isCommandLikeInputWithAlias(root, line, agentOnly, true) +} + +func isCommandLikeInputWithAlias(root *redant.Command, line string, agentOnly, allowAlias bool) bool { + cmd, ok := resolveCommandLikeInput(root, line, allowAlias) + if !ok { + return false + } + + if !agentOnly { + return true + } + + return agentlinemodule.IsAgentCommand(cmd.Metadata) +} + +func hasAnyAgentCommand(root *redant.Command) bool { + if root == nil { + return false + } + + for _, child := range root.Children { + if agentlinemodule.IsAgentCommand(child.Metadata) { + return true + } + if hasAnyAgentCommand(child) { + return true + } + } + + return false +} + +func resolveCommandLikeInput(root *redant.Command, line string, allowAlias bool) (*redant.Command, bool) { + args, err := splitCommandLine(line) + if err != nil || len(args) == 0 || root == nil { + return nil, false + } + + if args[0] == root.Name() { + args = args[1:] + if len(args) == 0 { + return nil, false + } + } + + current := root + consumed := 0 + for _, token := range args { + if strings.HasPrefix(token, "-") || strings.HasPrefix(token, "/") || strings.Contains(token, "=") { + break + } + + if strings.Contains(token, ":") { + parts := strings.Split(token, ":") + for _, part := range parts { + next := childByToken(current, part, allowAlias) + if next == nil { + if consumed == 0 { + return nil, false + } + return current, true + } + current = next + consumed++ + } + continue + } + + next := childByToken(current, token, allowAlias) + if next == nil { + break + } + current = next + consumed++ + } + + if consumed == 0 { + return nil, false + } + + return current, true +} + +func childByToken(parent *redant.Command, token string, allowAlias bool) *redant.Command { + if allowAlias { + return childByNameOrAlias(parent, token) + } + return childByName(parent, token) +} + +func childByName(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 + } + } + return nil +} + +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 +} + +func (m *agentlineModel) recomputeSuggestions() { + line := m.input.Value() + trimmed := strings.TrimLeftFunc(line, unicode.IsSpace) + if strings.TrimSpace(line) == "" { + m.suggestions = nil + m.selected = 0 + return + } + + if strings.HasPrefix(trimmed, "/") { + m.suggestions = collectSlashCompletionItems(m.root, line, m.agentOnlyMode) + } else { + m.suggestions = nil + } + + 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 collectStarterSlashItems() []completionItem { + return uniqueCompletionItems([]completionItem{ + {Insert: "/run ", Description: "执行命令"}, + {Insert: "/history", Description: "查看输入历史"}, + {Insert: "/help", Description: "查看 slash 帮助"}, + {Insert: "/output", Description: "进入输出滚动"}, + }) +} + +func collectSlashCompletionItems(root *redant.Command, input string, agentOnly bool) []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.ToLower(strings.TrimSpace(strings.TrimPrefix(first, "/"))) + if firstName == "chat" { + chatPrefix := "" + chatTokens := []string{} + if len(fields) > 1 { + chatTokens = append(chatTokens, fields[1:]...) + chatPrefix = strings.TrimSpace(strings.Join(chatTokens, " ")) + } + + if len(fields) == 1 && len(trimmedRight) == len(input) { + return collectSlashNameSuggestions(root, agentOnly, firstName) + } + + if len(chatTokens) > 0 { + cmd, ok := resolveCommandLikeInput(root, strings.Join(chatTokens, " "), false) + if ok && cmd != nil { + if len(chatTokens) == 1 && strings.EqualFold(strings.TrimSpace(chatTokens[0]), cmd.Name()) { + return collectCommandInputItems(cmd, "") + } + if len(chatTokens) == 1 && len(trimmedRight) < len(input) { + return collectCommandInputItems(cmd, "") + } + + last := chatTokens[len(chatTokens)-1] + if strings.HasPrefix(last, "-") { + return collectCommandInputItems(cmd, last) + } + + if len(trimmedRight) < len(input) { + return collectCommandInputItems(cmd, "") + } + } + } + + return collectChatCommandItems(root, agentOnly, chatPrefix) + } + + if len(fields) > 1 || len(trimmedRight) < len(input) { + probeTokens := append([]string{strings.TrimPrefix(first, "/")}, fields[1:]...) + probeLine := strings.TrimSpace(strings.Join(probeTokens, " ")) + cmd, ok := resolveCommandLikeInput(root, probeLine, false) + + // 命令还未解析成功时,继续给出命令名补全。 + if !ok { + return collectSlashNameSuggestions(root, agentOnly, strings.TrimPrefix(first, "/")) + } + + // 场景:/commit + if len(fields) == 1 && len(trimmedRight) < len(input) { + return collectCommandFlagItems(cmd, "") + } + + // 场景:/commit --m + last := fields[len(fields)-1] + if strings.HasPrefix(last, "-") { + return collectCommandFlagItems(cmd, last) + } + + // 场景:/commit --message hi + if len(trimmedRight) < len(input) { + return collectCommandFlagItems(cmd, "") + } + + return nil + } + + return collectSlashNameSuggestions(root, agentOnly, strings.TrimPrefix(first, "/")) +} + +func collectChatCommandItems(root *redant.Command, agentOnly bool, prefix string) []completionItem { + prefix = strings.TrimSpace(prefix) + cmdItems := collectCommandSlashItems(root, agentOnly, prefix) + if len(cmdItems) == 0 { + return nil + } + + out := make([]completionItem, 0, len(cmdItems)) + for _, item := range cmdItems { + insert := strings.TrimSpace(item.Insert) + insert = strings.TrimPrefix(insert, "/") + if insert == "" { + continue + } + desc := item.Description + if strings.TrimSpace(desc) == "" { + desc = "绑定聊天命令" + } else { + desc = "绑定聊天命令 · " + desc + } + out = append(out, completionItem{Insert: "/chat " + insert, Description: desc}) + } + + return uniqueCompletionItems(out) +} + +func collectSlashNameSuggestions(root *redant.Command, agentOnly bool, prefix string) []completionItem { + prefix = strings.TrimSpace(prefix) + out := make([]completionItem, 0, len(slashCommands)+8) + matchedByPrefix := false + addCandidate := func(name, desc string) { + candidate := "/" + strings.TrimSpace(name) + if candidate == "/" { + return + } + if prefix == "" || strings.HasPrefix(strings.TrimPrefix(candidate, "/"), prefix) { + matchedByPrefix = true + out = append(out, completionItem{Insert: candidate, Description: desc}) + } + } + + for _, sc := range slashCommands { + addCandidate(sc.Name, sc.Description) + } + + if prefix != "" && !matchedByPrefix { + for _, guessed := range suggestClosestSlashNames(prefix, 3) { + out = append(out, completionItem{Insert: "/" + guessed, Description: "你可能想输入这个命令"}) + } + } + + out = append(out, collectCommandSlashItems(root, agentOnly, prefix)...) + + return uniqueCompletionItems(out) +} + +func collectCommandFlagItems(cmd *redant.Command, prefix string) []completionItem { + if cmd == nil { + return nil + } + + prefix = strings.TrimSpace(prefix) + var out []completionItem + for _, opt := range cmd.FullOptions() { + if opt.Hidden || strings.TrimSpace(opt.Flag) == "" { + continue + } + flagName := "--" + strings.TrimSpace(opt.Flag) + if prefix != "" && !strings.HasPrefix(flagName, prefix) { + continue + } + desc := strings.TrimSpace(opt.Description) + if desc == "" { + desc = "命令参数" + } + out = append(out, completionItem{Insert: flagName + " ", Description: desc}) + } + + return uniqueCompletionItems(out) +} + +func collectCommandArgItems(cmd *redant.Command, prefix string) []completionItem { + if cmd == nil || len(cmd.Args) == 0 { + return nil + } + + prefix = strings.TrimSpace(prefix) + var out []completionItem + for i, arg := range cmd.Args { + name := strings.TrimSpace(arg.Name) + if name == "" { + name = fmt.Sprintf("arg%d", i+1) + } + if prefix != "" && !strings.HasPrefix(name, prefix) { + continue + } + + desc := strings.TrimSpace(arg.Description) + if desc == "" { + desc = "命令参数" + } + if arg.Required { + desc = desc + " (required)" + } + if strings.TrimSpace(arg.Default) != "" { + desc = desc + fmt.Sprintf(" (default=%s)", strings.TrimSpace(arg.Default)) + } + + out = append(out, completionItem{Insert: name + " ", Description: desc}) + } + + return uniqueCompletionItems(out) +} + +func collectCommandInputItems(cmd *redant.Command, prefix string) []completionItem { + prefix = strings.TrimSpace(prefix) + if strings.HasPrefix(prefix, "-") { + return collectCommandFlagItems(cmd, prefix) + } + + out := make([]completionItem, 0, 8) + out = append(out, collectCommandArgItems(cmd, prefix)...) + out = append(out, collectCommandFlagItems(cmd, prefix)...) + return uniqueCompletionItems(out) +} + +func collectCommandSlashItems(root *redant.Command, agentOnly bool, prefix string) []completionItem { + if root == nil { + return nil + } + + prefix = strings.TrimSpace(prefix) + var out []completionItem + + var walk func(parent *redant.Command, path []string) + walk = func(parent *redant.Command, path []string) { + if parent == nil { + return + } + + for _, child := range parent.Children { + if child == nil || child.Hidden { + continue + } + if child.Name() == agentlinemodule.CommandName { + continue + } + + cmdPath := append(path, child.Name()) + + if !agentOnly || agentlinemodule.IsAgentCommand(child.Metadata) { + pathText := strings.Join(cmdPath, " ") + if prefix == "" || strings.HasPrefix(pathText, prefix) { + desc := strings.TrimSpace(child.Short) + if desc == "" { + desc = "执行命令" + } + out = append(out, completionItem{ + Insert: "/" + pathText + " ", + Description: desc, + }) + } + } + + walk(child, cmdPath) + } + } + + walk(root, nil) + return out +} + +func (m *agentlineModel) 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 applySelectedCompletion(input, selected string) string { + trimmedRight := strings.TrimRightFunc(input, unicode.IsSpace) + if trimmedRight == "" { + if strings.HasSuffix(selected, " ") { + return selected + } + return selected + " " + } + if strings.HasPrefix(strings.TrimSpace(selected), "/") { + if strings.HasSuffix(selected, " ") { + return selected + } + return selected + " " + } + if len(trimmedRight) < len(input) { + if strings.HasPrefix(selected, "/") { + if strings.HasSuffix(selected, " ") { + return selected + } + return selected + " " + } + if strings.HasSuffix(selected, " ") { + return trimmedRight + " " + selected + } + return trimmedRight + " " + selected + " " + } + idx := strings.LastIndexFunc(trimmedRight, unicode.IsSpace) + if idx < 0 { + if strings.HasSuffix(selected, " ") { + return selected + } + return selected + " " + } + if strings.HasSuffix(selected, " ") { + return trimmedRight[:idx+1] + selected + } + return trimmedRight[:idx+1] + selected + " " +} + +func (m *agentlineModel) appendBlocks(blocks []sessionBlock) { + for _, block := range blocks { + m.appendBlock(block) + } +} + +func (m *agentlineModel) appendBlock(block sessionBlock) { + title := strings.TrimSpace(block.Title) + if title == "" { + title = string(block.Kind) + } + + kind := block.Kind + if kind == "" { + kind = blockKindSystem + } + + normalized := make([]string, 0, len(block.Lines)) + for _, line := range block.Lines { + normalized = append(normalized, normalizeOutputLines(line)...) + } + + m.blocks = append(m.blocks, sessionBlock{Kind: kind, Title: title, Lines: normalized}) + m.trimOutputHistory() +} + +func (m *agentlineModel) 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 > maxOutputLines) { + total -= len(m.blocks[0].Lines) + m.blocks = m.blocks[1:] + } +} + +func (m *agentlineModel) 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 *agentlineModel) historyUp() { + if len(m.history) == 0 { + return + } + if m.historyPos <= 0 { + m.historyPos = 0 + m.selectedHistory = m.historyPos + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() + return + } + m.historyPos-- + m.selectedHistory = m.historyPos + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() +} + +func (m *agentlineModel) historyDown() { + if len(m.history) == 0 { + return + } + if m.historyPos >= len(m.history)-1 { + m.historyPos = len(m.history) + m.selectedHistory = -1 + m.input.SetValue("") + m.input.CursorEnd() + return + } + m.historyPos++ + m.selectedHistory = m.historyPos + m.input.SetValue(m.history[m.historyPos]) + m.input.CursorEnd() +} + +func (m *agentlineModel) historyPreviewLines(limit int) []string { + if len(m.history) == 0 { + return []string{"暂无输入历史。"} + } + + if limit <= 0 { + limit = 20 + } + + start := len(m.history) - limit + if start < 0 { + start = 0 + } + + lines := make([]string, 0, len(m.history)-start+1) + lines = append(lines, fmt.Sprintf("total: %d, showing: %d-%d", len(m.history), start+1, len(m.history))) + for i := start; i < len(m.history); i++ { + lines = append(lines, fmt.Sprintf("%03d %s", i+1, strings.TrimSpace(m.history[i]))) + } + return lines +} + +func (m *agentlineModel) startCommandRun(commandLine string) tea.Cmd { + runCtx := m.ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, cancel := context.WithCancel(runCtx) + m.running = true + m.currentCancel = cancel + m.outputFocus = false + return runSlashRunCmd(runCtx, m, m.root, commandLine) +} + +func (m *agentlineModel) cancelActiveRun(message string) bool { + if !m.running || m.currentCancel == nil { + return false + } + cancel := m.currentCancel + m.currentCancel = nil + cancel() + if strings.TrimSpace(message) != "" { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "cancel", Lines: []string{message}}) + } + return true +} + +func isAllowedWhileRunning(line string) bool { + trimmed := strings.TrimSpace(line) + if trimmed == "" || !strings.HasPrefix(trimmed, "/") { + return false + } + cmdText := strings.TrimSpace(strings.TrimPrefix(trimmed, "/")) + if cmdText == "" { + return false + } + parts := strings.Fields(cmdText) + if len(parts) == 0 { + return false + } + switch strings.ToLower(strings.TrimSpace(parts[0])) { + case "permissions", "perm", "allow", "deny", "cancel", "stop": + return true + case "questions", "reply", "skip": + return true + case "acp-events", "acp-events-summary", "acp-events-export": + return true + default: + return false + } +} + +func (m *agentlineModel) startACPDemoTurn(prompt string) tea.Cmd { + runCtx := m.ctx + if runCtx == nil { + runCtx = context.Background() + } + runCtx, cancel := context.WithCancel(runCtx) + m.running = true + m.currentCancel = cancel + m.outputFocus = false + return runACPDemoTurnCmd(runCtx, strings.TrimSpace(prompt), m.permissionBroker) +} + +func runACPDemoTurnCmd(ctx context.Context, prompt string, broker *agentacp.PermissionBroker) tea.Cmd { + return func() tea.Msg { + if strings.TrimSpace(prompt) == "" { + prompt = "请执行一次需要权限确认的操作" + } + + client := &agentacp.CallbackClient{PermissionBroker: broker} + collector := make([]acp.SessionNotification, 0, 8) + client.OnSessionUpdate = func(_ context.Context, params acp.SessionNotification) error { + collector = append(collector, params) + return nil + } + + var bridge *agentacp.AgentBridge + exec := agentacp.PromptExecutorFunc(func(ctx context.Context, sessionID acp.SessionId, _ []acp.ContentBlock, emit func(update acp.SessionUpdate) error) (acp.StopReason, error) { + toolID := acp.ToolCallId("call_demo_1") + title := "demo edit" + if err := emit(acp.StartToolCall(toolID, title, + acp.WithStartKind(acp.ToolKindEdit), + acp.WithStartStatus(acp.ToolCallStatusPending), + )); err != nil { + return "", err + } + + resp, err := bridge.RequestPermission(ctx, acp.RequestPermissionRequest{ + SessionId: sessionID, + ToolCall: acp.RequestPermissionToolCall{ + ToolCallId: toolID, + Title: acp.Ptr(title), + Kind: acp.Ptr(acp.ToolKindEdit), + Status: acp.Ptr(acp.ToolCallStatusPending), + }, + Options: []acp.PermissionOption{ + {OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}, + {OptionId: "reject-once", Name: "Reject once", Kind: acp.PermissionOptionKindRejectOnce}, + }, + }) + if err != nil { + return "", err + } + + if resp.Outcome.Selected == nil || strings.TrimSpace(string(resp.Outcome.Selected.OptionId)) == "" { + if err := emit(acp.UpdateToolCall(toolID, + acp.WithUpdateStatus(acp.ToolCallStatusFailed), + acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("permission denied"))}), + )); err != nil { + return "", err + } + return acp.StopReasonRefusal, nil + } + + if err := emit(acp.UpdateToolCall(toolID, acp.WithUpdateStatus(acp.ToolCallStatusInProgress))); err != nil { + return "", err + } + if err := emit(acp.UpdateToolCall(toolID, + acp.WithUpdateStatus(acp.ToolCallStatusCompleted), + acp.WithUpdateContent([]acp.ToolCallContent{acp.ToolContent(acp.TextBlock("demo change applied"))}), + )); err != nil { + return "", err + } + if err := emit(acp.UpdateAgentMessageText("ACP demo done")); err != nil { + return "", err + } + return acp.StopReasonEndTurn, nil + }) + + bridge = agentacp.NewAgentBridge(agentacp.BridgeOptions{Executor: exec, PermissionRequester: client}) + bridge.SetSessionUpdater(client) + + newResp, err := bridge.NewSession(ctx, acp.NewSessionRequest{Cwd: "/tmp", McpServers: nil}) + if err != nil { + return acpDemoResultMsg{err: err} + } + + _, err = bridge.Prompt(ctx, acp.PromptRequest{SessionId: newResp.SessionId, Prompt: []acp.ContentBlock{acp.TextBlock(prompt)}}) + blocks := make([]sessionBlock, 0, len(collector)+1) + for _, n := range collector { + blocks = append(blocks, sessionBlocksFromACP(n)...) + } + if len(blocks) == 0 { + blocks = append(blocks, sessionBlock{Kind: blockKindSystem, Title: "acp.demo", Lines: []string{"no updates received"}}) + } + return acpDemoResultMsg{blocks: blocks, err: err} + } +} diff --git a/cmds/agentlineapp/agentline_helpers.go b/cmds/agentlineapp/agentline_helpers.go new file mode 100644 index 0000000..b14094a --- /dev/null +++ b/cmds/agentlineapp/agentline_helpers.go @@ -0,0 +1,200 @@ +package agentlineapp + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode" + + "github.com/pubgo/fastgit/pkg/gitshell" +) + +func runStatus(err error) string { + if err == nil { + return "ok" + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return "canceled" + } + return "failed" +} + +func formatDuration(d time.Duration) string { + if d < time.Millisecond { + return d.String() + } + return d.Round(time.Millisecond).String() +} + +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 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 (m *agentlineModel) sessionContextLine() string { + return fmt.Sprintf("cwd=%s · git=%s", displayPath(m.sessionCWD), displayGitBranch(m.sessionGitBranch, m.sessionGitDirty)) +} + +func (m *agentlineModel) chatBindingLine() string { + if m == nil || !m.isChatMode() || m.stickyInvocation == nil { + return "" + } + + parts := append([]string(nil), m.stickyInvocation.BaseArgs...) + parts = append(parts, m.stickyInvocation.PromptFlag, "") + return fmt.Sprintf("chat=%s", strings.Join(parts, " ")) +} + +func displayPath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "(unknown)" + } + home, err := os.UserHomeDir() + if err == nil && strings.TrimSpace(home) != "" { + home = filepath.Clean(home) + cleanPath := filepath.Clean(path) + if cleanPath == home { + return "~" + } + prefix := home + string(os.PathSeparator) + if strings.HasPrefix(cleanPath, prefix) { + return "~" + string(os.PathSeparator) + strings.TrimPrefix(cleanPath, prefix) + } + } + return path +} + +func displayGitBranch(branch string, dirty bool) string { + branch = strings.TrimSpace(branch) + if branch == "" { + return "(not repo)" + } + if dirty { + return branch + "*" + } + return branch +} + +func detectSessionContext() (cwd, gitBranch string, gitDirty bool) { + wd, err := os.Getwd() + if err != nil { + return "", "", false + } + return wd, gitshell.DetectBranch(wd), gitshell.IsDirty(wd) +} + +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/agentlineapp/agentline_layout_utils.go b/cmds/agentlineapp/agentline_layout_utils.go new file mode 100644 index 0000000..72c6356 --- /dev/null +++ b/cmds/agentlineapp/agentline_layout_utils.go @@ -0,0 +1,259 @@ +package agentlineapp + +import ( + "sort" + "strings" + + "charm.land/lipgloss/v2" +) + +func displayStart(start, end int) int { + if end == 0 { + return 0 + } + return start + 1 +} + +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 visibleInputRange(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 = clampInputOffset(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 clampInputOffset(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 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 { + if out[i].Insert != out[j].Insert { + return out[i].Insert < out[j].Insert + } + return out[i].Description < out[j].Description + }) + return out +} + +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 renderBlockHeader(kind blockKind, text string) string { + switch kind { + case blockKindSystem: + return styleKindSystem.Render(text) + case blockKindUser: + return styleKindUser.Render(text) + case blockKindAssistant: + return styleKindAssistant.Render(text) + case blockKindTool: + return styleKindTool.Render(text) + case blockKindCommand: + return styleKindCommand.Render(text) + case blockKindResult: + return styleKindResult.Render(text) + case blockKindError: + return styleKindError.Render(text) + default: + return text + } +} diff --git a/cmds/agentlineapp/agentline_permission_test.go b/cmds/agentlineapp/agentline_permission_test.go new file mode 100644 index 0000000..9350fa0 --- /dev/null +++ b/cmds/agentlineapp/agentline_permission_test.go @@ -0,0 +1,176 @@ +package agentlineapp + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + acp "github.com/coder/acp-go-sdk" +) + +func TestHandleSlashInput_PermissionsList(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan acp.RequestPermissionResponse, 1) + go func() { + resp, _ := m.permissionBroker.RequestPermission(context.Background(), acp.RequestPermissionRequest{ + SessionId: "sess_1", + ToolCall: acp.RequestPermissionToolCall{ToolCallId: "call_1", Title: acp.Ptr("edit file")}, + Options: []acp.PermissionOption{{OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}}, + }) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.permissionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + handled, cmd := m.handleSlashInput("/permissions") + if !handled || cmd != nil { + t.Fatalf("expected /permissions handled without async cmd") + } + last := m.blocks[len(m.blocks)-1] + if last.Title != "/permissions" { + t.Fatalf("expected /permissions block title, got %q", last.Title) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "request=perm_") { + t.Fatalf("expected pending permission lines") + } + + // 清理请求,避免 goroutine 泄漏。 + _ = m.permissionBroker.ResolveCancelled(m.permissionBroker.Pending()[0].RequestID) + select { + case <-respCh: + case <-time.After(time.Second): + t.Fatal("timeout waiting permission response") + } +} + +func TestHandleSlashInput_AllowResolvesRequest(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan acp.RequestPermissionResponse, 1) + go func() { + resp, _ := m.permissionBroker.RequestPermission(context.Background(), acp.RequestPermissionRequest{ + SessionId: "sess_1", + ToolCall: acp.RequestPermissionToolCall{ToolCallId: "call_1"}, + Options: []acp.PermissionOption{{OptionId: "allow-once", Name: "Allow once", Kind: acp.PermissionOptionKindAllowOnce}}, + }) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.permissionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + handled, cmd := m.handleSlashInput("/allow") + if !handled || cmd != nil { + t.Fatalf("expected /allow handled without async cmd") + } + + select { + case resp := <-respCh: + if resp.Outcome.Selected == nil { + t.Fatalf("expected selected outcome") + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting allow response") + } +} + +func TestHandleSlashInput_DenyResolvesRequest(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan acp.RequestPermissionResponse, 1) + go func() { + resp, _ := m.permissionBroker.RequestPermission(context.Background(), acp.RequestPermissionRequest{ + SessionId: "sess_1", + ToolCall: acp.RequestPermissionToolCall{ToolCallId: "call_1"}, + Options: []acp.PermissionOption{{OptionId: "reject-once", Name: "Reject once", Kind: acp.PermissionOptionKindRejectOnce}}, + }) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.permissionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + handled, cmd := m.handleSlashInput("/deny") + if !handled || cmd != nil { + t.Fatalf("expected /deny handled without async cmd") + } + + select { + case resp := <-respCh: + if resp.Outcome.Selected == nil { + t.Fatalf("expected selected reject outcome") + } + if resp.Outcome.Selected.OptionId != "reject-once" { + t.Fatalf("expected reject-once, got %s", resp.Outcome.Selected.OptionId) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting deny response") + } +} + +func TestIsAllowedWhileRunning(t *testing.T) { + if !isAllowedWhileRunning("/allow") { + t.Fatalf("expected /allow allowed while running") + } + if !isAllowedWhileRunning("/permissions") { + t.Fatalf("expected /permissions allowed while running") + } + if isAllowedWhileRunning("/run commit") { + t.Fatalf("expected /run not allowed while running") + } + if isAllowedWhileRunning("plain text") { + t.Fatalf("expected plain text not allowed while running") + } +} + +func TestEnterWhileRunningBlocksNonPermissionSlash(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.running = true + m.input.SetValue("/run commit --message hi") + + model, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd != nil { + t.Fatalf("expected no async cmd when blocked by running guard") + } + last := m.blocks[len(m.blocks)-1] + if last.Title != "running" { + t.Fatalf("expected running hint block, got %q", last.Title) + } +} + +func TestHandleSlashInput_ACPDemoStartsRunning(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/acp-demo test prompt") + if !handled { + t.Fatalf("expected /acp-demo handled") + } + if cmd == nil { + t.Fatalf("expected /acp-demo to return async cmd") + } + if !m.running { + t.Fatalf("expected running=true after /acp-demo") + } +} diff --git a/cmds/agentlineapp/agentline_slash.go b/cmds/agentlineapp/agentline_slash.go new file mode 100644 index 0000000..282ce18 --- /dev/null +++ b/cmds/agentlineapp/agentline_slash.go @@ -0,0 +1,701 @@ +package agentlineapp + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + acp "github.com/coder/acp-go-sdk" + + agentacp "github.com/pubgo/fastgit/cmds/agentlineapp/acp" + "github.com/pubgo/redant" +) + +type slashCommand struct { + Name string + Aliases []string + Description string +} + +type slashHandler func(m *agentlineModel, raw, cmdText, argText string) tea.Cmd + +type slashBuiltin struct { + Name string + Aliases []string + Description string + Handler slashHandler +} + +var slashBuiltins = []slashBuiltin{ + { + Name: "chat", + Description: "绑定聊天粘性模式(/chat )", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + sticky, err := buildStickyInvocation(m.root, argText, m.agentOnlyMode) + if err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/chat", Lines: []string{err.Error()}}) + return nil + } + m.bindStickyInvocation(sticky) + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/chat", Lines: []string{ + "已进入聊天粘性模式。", + fmt.Sprintf("后续普通输入将自动补全为: %s <文本>", strings.Join(append(append([]string(nil), sticky.BaseArgs...), sticky.PromptFlag), " ")), + "输入 /unbind 退出聊天粘性模式。", + }}) + return nil + }, + }, + { + Name: "run", + Aliases: []string{"r"}, + Description: "执行 redant 命令(tool -> command -> result)", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + if argText == "" { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/run", Lines: []string{"用法:/run "}}) + return nil + } + return m.startCommandRun(argText) + }, + }, + { + Name: "acp-demo", + Description: "启动 ACP 权限回合演示(可配合 /permissions /allow /deny)", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + prompt := strings.TrimSpace(argText) + if prompt == "" { + prompt = "请执行一次需要权限确认的文件修改" + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/acp-demo", Lines: []string{ + "ACP demo 已启动:可用 /permissions 查看待审批项。", + "输入 /allow 或 /deny 继续。", + }}) + return m.startACPDemoTurn(prompt) + }, + }, + { + Name: "acp-events", + Description: "查看 ACP 事件时间线(/acp-events [N])", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + limit := parsePositiveIntOrDefault(argText, 40) + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/acp-events", Lines: m.acpEventsTimelineLines(limit)}) + m.outputOffset = 0 + return nil + }, + }, + { + Name: "acp-events-summary", + Description: "查看 ACP 事件统计摘要", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/acp-events-summary", Lines: m.acpEventsSummaryLines()}) + m.outputOffset = 0 + return nil + }, + }, + { + Name: "acp-events-export", + Description: "导出 ACP 事件为 JSONL(默认 .local/data.jsonl)", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + path := strings.TrimSpace(argText) + if path == "" { + path = ".local/data.jsonl" + } + n, err := m.exportACPEventsJSONL(path) + if err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/acp-events-export", Lines: []string{err.Error()}}) + return nil + } + if n == 0 { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/acp-events-export", Lines: []string{"暂无 ACP 事件可导出。"}}) + return nil + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/acp-events-export", Lines: []string{fmt.Sprintf("已导出 %d 条事件到 %s", n, path)}}) + return nil + }, + }, + { + Name: "output", + Aliases: []string{"o", "out"}, + Description: "进入输出滚动模式", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + m.outputFocus = true + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/output", Lines: []string{ + "已进入输出滚动模式。", + "使用 ↑/↓ 单行滚动,PgUp/PgDn 翻页,Home/End 顶/底。", + "输入 /input 返回普通输入模式。", + }}) + return nil + }, + }, + { + Name: "input", + Aliases: []string{"i"}, + Description: "返回输入模式", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + m.outputFocus = false + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/input", Lines: []string{"已返回输入模式。"}}) + return nil + }, + }, + {Name: "top", Description: "跳到历史顶部", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputTop(); return nil }}, + {Name: "bottom", Aliases: []string{"end"}, Description: "跳到历史底部", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputBottom(); return nil }}, + {Name: "up", Description: "历史按行向上滚动", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputLines(1); return nil }}, + {Name: "down", Description: "历史按行向下滚动", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputLines(-1); return nil }}, + {Name: "pgup", Description: "历史按页向上滚动", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputPage(1); return nil }}, + {Name: "pgdown", Description: "历史按页向下滚动", Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { m.scrollOutputPage(-1); return nil }}, + { + Name: "history", + Aliases: []string{"his"}, + Description: "查看输入历史(默认最近 20 条,可 /history 50)", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + limit := 20 + if argText != "" { + n, err := strconv.Atoi(argText) + if err != nil || n <= 0 { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/history", Lines: []string{"用法:/history [正整数],例如 /history 50"}}) + return nil + } + limit = n + } + + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/history", Lines: m.historyPreviewLines(limit)}) + m.outputOffset = 0 + return nil + }, + }, + { + Name: "permissions", + Aliases: []string{"perm"}, + Description: "查看待处理权限请求", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if m.permissionBroker == nil { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/permissions", Lines: []string{"权限队列未初始化。"}}) + return nil + } + pending := m.permissionBroker.Pending() + if len(pending) == 0 { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/permissions", Lines: []string{"当前无待处理权限请求。"}}) + return nil + } + lines := make([]string, 0, len(pending)*3) + for i, item := range pending { + lines = append(lines, fmt.Sprintf("%d) request=%s session=%s tool=%s", i+1, item.RequestID, strings.TrimSpace(string(item.SessionID)), strings.TrimSpace(string(item.ToolCallID)))) + if strings.TrimSpace(item.Title) != "" { + lines = append(lines, " title: "+strings.TrimSpace(item.Title)) + } + for idx, option := range item.Options { + lines = append(lines, fmt.Sprintf(" option %d: id=%s kind=%s name=%s", idx+1, strings.TrimSpace(string(option.OptionId)), strings.TrimSpace(string(option.Kind)), strings.TrimSpace(option.Name))) + } + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/permissions", Lines: lines}) + return nil + }, + }, + { + Name: "questions", + Description: "查看待回答问题队列", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if m == nil || m.questionBroker == nil { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/questions", Lines: []string{"问题队列未初始化。"}}) + return nil + } + pending := m.questionBroker.Pending() + if len(pending) == 0 { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/questions", Lines: []string{"当前无待回答问题。"}}) + return nil + } + lines := make([]string, 0, len(pending)+1) + lines = append(lines, fmt.Sprintf("pending: %d", len(pending))) + for i, q := range pending { + lines = append(lines, fmt.Sprintf("%d) id=%s time=%s", i+1, q.ID, q.CreatedAt.Format("15:04:05"))) + lines = append(lines, " prompt: "+strings.TrimSpace(q.Prompt)) + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/questions", Lines: lines}) + return nil + }, + }, + { + Name: "reply", + Description: "回答问题(/reply [ask_id] )", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + if m == nil || m.questionBroker == nil { + return nil + } + parts := strings.Fields(strings.TrimSpace(argText)) + if len(parts) == 0 { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/reply", Lines: []string{"用法:/reply [ask_id] "}}) + return nil + } + targetID := "" + answer := strings.TrimSpace(argText) + if strings.HasPrefix(parts[0], "ask_") { + targetID = strings.TrimSpace(parts[0]) + answer = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(argText), targetID)) + } else if idx, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + pending := m.questionBroker.Pending() + if idx <= 0 || idx > len(pending) { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/reply", Lines: []string{fmt.Sprintf("问题序号超出范围: %d", idx)}}) + return nil + } + targetID = strings.TrimSpace(pending[idx-1].ID) + answer = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(argText), strings.TrimSpace(parts[0]))) + } + if strings.TrimSpace(answer) == "" { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/reply", Lines: []string{"answer 不能为空。"}}) + return nil + } + if err := m.questionBroker.Reply(targetID, answer); err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/reply", Lines: []string{err.Error()}}) + return nil + } + if strings.TrimSpace(targetID) == "" { + targetID = "(latest)" + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/reply", Lines: []string{fmt.Sprintf("已回复问题: %s", targetID)}}) + return nil + }, + }, + { + Name: "skip", + Description: "跳过问题(/skip [ask_id])", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + if m == nil || m.questionBroker == nil { + return nil + } + targetID := strings.TrimSpace(argText) + if err := m.questionBroker.Cancel(targetID); err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: "/skip", Lines: []string{err.Error()}}) + return nil + } + if targetID == "" { + targetID = "(latest)" + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/skip", Lines: []string{fmt.Sprintf("已跳过问题: %s", targetID)}}) + return nil + }, + }, + { + Name: "allow", + Description: "同意权限请求(/allow [request-id] [option-id|index])", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + return resolvePermissionSlash(m, true, argText) + }, + }, + { + Name: "deny", + Description: "拒绝权限请求(/deny [request-id] [option-id|index])", + Handler: func(m *agentlineModel, _, _, argText string) tea.Cmd { + return resolvePermissionSlash(m, false, argText) + }, + }, + { + Name: "cancel", + Aliases: []string{"stop"}, + Description: "中断当前运行中的任务", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if m.cancelActiveRun("收到 /cancel,正在尝试中断当前任务...") { + return nil + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/cancel", Lines: []string{"当前没有可中断的运行任务。"}}) + return nil + }, + }, + { + Name: "fold", + Description: "折叠 assistant/tool 详情块", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if m.foldDetails { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/fold", Lines: []string{"当前已是折叠状态。"}}) + } else { + m.foldDetails = true + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/fold", Lines: []string{"已折叠 assistant/tool 详情。输入 /unfold 可恢复。"}}) + } + return nil + }, + }, + { + Name: "unfold", + Description: "展开 assistant/tool 详情块", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if !m.foldDetails { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/unfold", Lines: []string{"当前已是展开状态。"}}) + } else { + m.foldDetails = false + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/unfold", Lines: []string{"已展开 assistant/tool 详情。"}}) + } + return nil + }, + }, + { + Name: "clear", + Aliases: []string{"cls"}, + Description: "清空历史块(保留欢迎信息)", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + m.blocks = []sessionBlock{{ + Kind: blockKindSystem, + Title: "system", + Lines: []string{"输出历史已清空。", "输入 /help 查看可用命令。"}, + }} + m.outputOffset = 0 + return nil + }, + }, + { + Name: "unbind", + Aliases: []string{"chat-off"}, + Description: "退出聊天粘性模式", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + if !m.isChatMode() { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/unbind", Lines: []string{"当前未启用聊天粘性模式。"}}) + } else { + m.unbindStickyInvocation() + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/unbind", Lines: []string{"已退出聊天粘性模式。"}}) + } + return nil + }, + }, + { + Name: "help", + Aliases: []string{"?"}, + Description: "显示 slash 命令帮助", + Handler: func(m *agentlineModel, _, _, _ string) tea.Cmd { + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "/help", Lines: slashHelpLines(m.root, m.agentOnlyMode)}) + return nil + }, + }, + {Name: "quit", Aliases: []string{"exit", "q"}, Description: "退出 agentline", Handler: func(_ *agentlineModel, _, _, _ string) tea.Cmd { return tea.Quit }}, +} + +var ( + slashCommands = buildSlashCommands(slashBuiltins) + slashHandlerByName = buildSlashHandlerByName(slashBuiltins) +) + +func buildSlashCommands(builtins []slashBuiltin) []slashCommand { + out := make([]slashCommand, 0, len(builtins)) + for _, item := range builtins { + out = append(out, slashCommand{Name: item.Name, Aliases: append([]string(nil), item.Aliases...), Description: item.Description}) + } + return out +} + +func buildSlashHandlerByName(builtins []slashBuiltin) map[string]slashHandler { + index := make(map[string]slashHandler, len(builtins)*2) + for _, item := range builtins { + if item.Handler == nil { + continue + } + name := strings.ToLower(strings.TrimSpace(item.Name)) + if name != "" { + index[name] = item.Handler + } + for _, alias := range item.Aliases { + key := strings.ToLower(strings.TrimSpace(alias)) + if key == "" { + continue + } + index[key] = item.Handler + } + } + return index +} + +func (m *agentlineModel) handleSlashInput(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]) + argText := strings.TrimSpace(strings.TrimPrefix(cmdText, parts[0])) + + if handler, ok := slashHandlerByName[cmd]; ok { + cmdOut := handler(m, raw, cmdText, argText) + m.normalizeOutputOffset() + return true, cmdOut + } + + if isCommandLikeInputWithAlias(m.root, cmdText, m.agentOnlyMode, false) { + return true, m.startCommandRun(cmdText) + } + + lines := []string{ + fmt.Sprintf("未知 slash 命令: %s", cmd), + "可尝试 /run ,或直接使用 / 形式。", + "输入 /help 查看可用命令。", + } + if guessed := suggestClosestSlashNames(cmd, 3); len(guessed) > 0 { + lines = append(lines, "你可能想输入:"+formatSlashGuessList(guessed)) + } + + m.appendBlock(sessionBlock{Kind: blockKindError, Title: raw, Lines: lines}) + m.normalizeOutputOffset() + return true, nil +} + +func suggestClosestSlashNames(input string, limit int) []string { + input = strings.ToLower(strings.TrimSpace(input)) + if input == "" || limit <= 0 { + return nil + } + + type scored struct { + name string + score int + } + + scores := make([]scored, 0, len(slashCommands)) + seen := map[string]struct{}{} + for _, sc := range slashCommands { + name := strings.ToLower(strings.TrimSpace(sc.Name)) + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + + d := damerauLevenshteinDistance(input, name) + if d <= 2 { + scores = append(scores, scored{name: name, score: d}) + } + } + + if len(scores) == 0 { + return nil + } + + sort.Slice(scores, func(i, j int) bool { + if scores[i].score != scores[j].score { + return scores[i].score < scores[j].score + } + return scores[i].name < scores[j].name + }) + + if len(scores) > limit { + scores = scores[:limit] + } + + out := make([]string, 0, len(scores)) + for _, item := range scores { + out = append(out, item.name) + } + return out +} + +func formatSlashGuessList(names []string) string { + if len(names) == 0 { + return "" + } + parts := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" { + continue + } + parts = append(parts, "/"+name) + } + return strings.Join(parts, "、") +} + +func damerauLevenshteinDistance(a, b string) int { + ra := []rune(a) + rb := []rune(b) + la, lb := len(ra), len(rb) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + + dp := make([][]int, la+1) + for i := 0; i <= la; i++ { + dp[i] = make([]int, lb+1) + dp[i][0] = i + } + for j := 0; j <= lb; j++ { + dp[0][j] = j + } + + for i := 1; i <= la; i++ { + for j := 1; j <= lb; j++ { + cost := 0 + if ra[i-1] != rb[j-1] { + cost = 1 + } + + deletion := dp[i-1][j] + 1 + insertion := dp[i][j-1] + 1 + substitution := dp[i-1][j-1] + cost + + best := deletion + if insertion < best { + best = insertion + } + if substitution < best { + best = substitution + } + + if i > 1 && j > 1 && ra[i-1] == rb[j-2] && ra[i-2] == rb[j-1] { + transposition := dp[i-2][j-2] + 1 + if transposition < best { + best = transposition + } + } + + dp[i][j] = best + } + } + + return dp[la][lb] +} + +func slashHelpLines(root *redant.Command, agentOnly bool) []string { + lines := []string{ + "slash commands:", + " /chat : 绑定聊天粘性模式(保留已输入参数,后续普通输入作为 prompt 多轮复用)", + " /: 直接执行命令(例如 /commit --message hi)", + " /run : 执行命令并输出 tool/command/result", + " /acp-demo [prompt]: 启动 ACP 权限回合演示", + " /acp-events [N]: 查看 ACP 事件时间线(默认最近 40 条)", + " /acp-events-summary: 查看 ACP 事件统计摘要", + " /acp-events-export [path]: 导出 ACP 事件到 JSONL(默认 .local/data.jsonl)", + " /questions: 查看待回答问题", + " /reply [ask_id] : 回答问题(省略 ask_id 默认回复最新)", + " /skip [ask_id]: 跳过问题(省略 ask_id 默认跳过最新)", + " /permissions: 查看待处理权限请求", + " /allow [request-id] [option-id|index]: 同意权限请求", + " /deny [request-id] [option-id|index]: 拒绝权限请求", + " /history [N]: 查看最近输入历史(默认 20 条)", + " /cancel: 中断当前运行中的任务", + " /fold: 折叠 assistant/tool 详情块", + " /unfold: 展开 assistant/tool 详情块", + " /unbind: 退出聊天粘性模式", + " /output (/o): 进入输出滚动模式", + " /input (/i): 返回输入模式", + " /top /bottom /up /down /pgup /pgdown: 浏览历史", + " /clear: 清空历史块", + " /quit: 退出 agentline", + } + + commands := collectCommandSlashItems(root, agentOnly, "") + if len(commands) > 0 { + lines = append(lines, "") + lines = append(lines, "可直接执行的命令:") + limit := len(commands) + if limit > 8 { + limit = 8 + } + for i := 0; i < limit; i++ { + lines = append(lines, " "+strings.TrimSpace(commands[i].Insert)) + } + if len(commands) > limit { + lines = append(lines, fmt.Sprintf(" ...(共 %d 个,可输入 / 查看候选)", len(commands))) + } + } + + return lines +} + +func resolvePermissionSlash(m *agentlineModel, allow bool, argText string) tea.Cmd { + if m == nil || m.permissionBroker == nil { + return nil + } + pending := m.permissionBroker.Pending() + if len(pending) == 0 { + title := "/deny" + if allow { + title = "/allow" + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: title, Lines: []string{"当前无待处理权限请求。"}}) + return nil + } + + parts := strings.Fields(strings.TrimSpace(argText)) + target := pending[len(pending)-1] + rest := []string{} + if len(parts) > 0 { + candidate := strings.TrimSpace(parts[0]) + if strings.HasPrefix(candidate, "perm_") { + for _, item := range pending { + if item.RequestID == candidate { + target = item + rest = parts[1:] + goto resolve + } + } + m.appendBlock(sessionBlock{Kind: blockKindError, Title: permissionTitle(allow), Lines: []string{fmt.Sprintf("未知 request id: %s", candidate)}}) + return nil + } + rest = parts + } + +resolve: + if len(rest) > 0 { + idx, optionID, isIndex := agentacp.ParseIndexOrOption(rest[0]) + var err error + if isIndex { + err = m.permissionBroker.ResolveByIndex(target.RequestID, idx) + } else { + err = m.permissionBroker.ResolveSelected(target.RequestID, optionID) + } + if err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: permissionTitle(allow), Lines: []string{err.Error()}}) + return nil + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: permissionTitle(allow), Lines: []string{fmt.Sprintf("已处理 request=%s", target.RequestID)}}) + return nil + } + + var err error + if allow { + err = m.permissionBroker.ResolveFirstByKind(target.RequestID, acp.PermissionOptionKindAllowOnce, acp.PermissionOptionKindAllowAlways) + } else { + err = m.permissionBroker.ResolveFirstByKind(target.RequestID, acp.PermissionOptionKindRejectOnce, acp.PermissionOptionKindRejectAlways) + } + if err != nil { + if allow { + err = m.permissionBroker.ResolveCancelled(target.RequestID) + } else { + // 无 reject 选项时回退 cancelled,保持可前进。 + err = m.permissionBroker.ResolveCancelled(target.RequestID) + } + } + if err != nil { + m.appendBlock(sessionBlock{Kind: blockKindError, Title: permissionTitle(allow), Lines: []string{err.Error()}}) + return nil + } + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: permissionTitle(allow), Lines: []string{fmt.Sprintf("已处理 request=%s", target.RequestID)}}) + return nil +} + +func permissionTitle(allow bool) string { + if allow { + return "/allow" + } + return "/deny" +} + +// ACPPermissionClient 返回仅处理 permission 的 ACP client 适配。 +// session/update 建议通过调用方在 UI 主循环中转发到 appendACPSessionNotification。 +func (m *agentlineModel) ACPPermissionClient() *agentacp.CallbackClient { + if m == nil { + return &agentacp.CallbackClient{} + } + return &agentacp.CallbackClient{ + PermissionBroker: m.permissionBroker, + OnSessionUpdate: func(_ context.Context, params acp.SessionNotification) error { + m.appendACPSessionNotification(params) + return nil + }, + } +} diff --git a/cmds/agentlineapp/agentline_terminal_ui.go b/cmds/agentlineapp/agentline_terminal_ui.go new file mode 100644 index 0000000..b70121a --- /dev/null +++ b/cmds/agentlineapp/agentline_terminal_ui.go @@ -0,0 +1,433 @@ +package agentlineapp + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" +) + +func (m *agentlineModel) View() tea.View { + contentWidth := m.contentWidth() + + renderedOutput := m.renderOutputLines(contentWidth) + outputRows := m.outputRows() + outputOffset := clampOutputOffset(m.outputOffset, len(renderedOutput), outputRows) + outputStart, outputEnd := visibleOutputRange(len(renderedOutput), outputRows, outputOffset) + + statusText := "IDLE" + statusStyle := styleStatusIdle + if m.running { + statusText = "RUNNING" + statusStyle = styleStatusBusy + } + pendingQuestions := 0 + latestQuestion := "" + if m.questionBroker != nil { + pending := m.questionBroker.Pending() + pendingQuestions = len(pending) + if pendingQuestions > 0 { + latestQuestion = strings.TrimSpace(pending[pendingQuestions-1].Prompt) + if m.running { + statusText = "WAITING_ANSWER" + statusStyle = styleStatusBusy + } + } + } + status := statusStyle.Render(statusText) + mode := strings.ToUpper(string(m.mode)) + if strings.TrimSpace(mode) == "" { + mode = strings.ToUpper(string(interactionModeCommand)) + } + focus := "INPUT" + if m.outputFocus { + focus = "OUTPUT" + } + + lines := make([]string, 0, m.height+8) + header := fmt.Sprintf("agentline · status=%s · mode=%s · focus=%s · blocks=%d · lines=%d", status, mode, focus, len(m.blocks), len(renderedOutput)) + if pendingQuestions > 0 { + header += fmt.Sprintf(" · pending_questions=%d", pendingQuestions) + } + lines = append(lines, styleHeader.Render(truncateDisplayWidth(header, contentWidth))) + lines = append(lines, styleHint.Render(truncateDisplayWidth(m.sessionContextLine(), contentWidth))) + if binding := strings.TrimSpace(m.chatBindingLine()); binding != "" { + lines = append(lines, styleHint.Render(truncateDisplayWidth(binding, contentWidth))) + } + + outputTitle := fmt.Sprintf("输出区域(%d-%d/%d)", displayStart(outputStart, outputEnd), outputEnd, len(renderedOutput)) + lines = append(lines, styleHeader.Render(truncateDisplayWidth(outputTitle, contentWidth))) + outputRegionStart := len(lines) - 1 + if len(renderedOutput) == 0 { + lines = append(lines, styleHint.Render("暂无输出")) + } else { + for i := outputStart; i < outputEnd; i++ { + lines = append(lines, renderedOutput[i]) + } + } + + if len(m.suggestions) > 0 { + rows := m.suggestionRows(len(m.suggestions)) + s, e := visibleSuggestionRange(len(m.suggestions), m.selected, rows) + suggestionHeader := fmt.Sprintf("slash 候选(%d,显示 %d-%d)", len(m.suggestions), s+1, e) + lines = append(lines, styleHeader.Render(truncateDisplayWidth(suggestionHeader, contentWidth))) + + suggestionWidth := contentWidth + if suggestionWidth > 0 { + suggestionWidth -= 2 + } + + for i := s; i < e; i++ { + item := m.suggestions[i] + prefix := " " + if i == m.selected { + prefix = "> " + } + line := padRightDisplay(item.Insert, 18) + raw := line + if item.Description != "" { + raw += " " + styleDesc.Render(item.Description) + } + raw = truncateDisplayWidth(raw, suggestionWidth) + if i == m.selected { + raw = styleSelected.Render(raw) + } + lines = append(lines, prefix+raw) + } + lines = append(lines, " "+styleHint.Render("提示:↑/↓ 选择,Tab 应用,Esc 关闭候选")) + } + + if m.running { + if pendingQuestions > 0 { + lines = append(lines, styleRunning.Render(truncateDisplayWidth(fmt.Sprintf("执行中(等待问题回复,pending=%d)", pendingQuestions), contentWidth))) + if latestQuestion != "" { + lines = append(lines, styleHint.Render(truncateDisplayWidth("最新问题: "+latestQuestion, contentWidth))) + } + lines = append(lines, styleHint.Render(truncateDisplayWidth("可直接输入并回车回复,或使用 /questions、/reply、/skip。", contentWidth))) + } else { + lines = append(lines, styleRunning.Render(truncateDisplayWidth("执行中...", contentWidth))) + } + } + outputRegionEnd := len(lines) - 1 + + inputTitle := "输入区域(支持点击历史回填)" + inputRegionStart := len(lines) + lines = append(lines, styleHeader.Render(truncateDisplayWidth(inputTitle, contentWidth))) + lines = append(lines, m.input.View()) + + historyRendered, historyIndices := m.renderInputHistoryLinesWithIndices(contentWidth) + historyRows := m.inputRows() + historyStart, historyEnd := visibleInputRange(len(historyRendered), historyRows, m.inputOffset) + + historyTitle := fmt.Sprintf("最近历史(%d-%d/%d,可点击回填)", displayStart(historyStart, historyEnd), historyEnd, len(historyRendered)) + if len(historyRendered) == 0 { + historyTitle = "最近历史(暂无)" + } + lines = append(lines, styleHeader.Render(truncateDisplayWidth(historyTitle, contentWidth))) + + historyRegionStart := len(lines) + if len(historyRendered) == 0 { + lines = append(lines, styleHint.Render("暂无输入历史")) + } else { + for i := historyStart; i < historyEnd; i++ { + line := historyRendered[i] + if i >= 0 && i < len(historyIndices) && historyIndices[i] == m.selectedHistory { + line = styleHistorySelected.Render(line) + } + lines = append(lines, line) + } + } + historyRegionEnd := len(lines) - 1 + + lines = append(lines, styleHint.Render(truncateDisplayWidth("命令:/run /history /cancel /fold /unfold;支持直接鼠标拖拽选择复制。", contentWidth))) + if m.isChatMode() { + lines = append(lines, styleHint.Render(truncateDisplayWidth("当前为聊天模式:普通输入会作为已绑定命令的 prompt;可继续使用 slash 命令(如 /run、/history),/unbind 可退出。", contentWidth))) + } + inputRegionEnd := len(lines) - 1 + + v := tea.NewView(strings.Join(lines, "\n")) + v.AltScreen = true + v.MouseMode = tea.MouseModeCellMotion + v.OnMouse = func(msg tea.MouseMsg) tea.Cmd { + switch event := msg.(type) { + case tea.MouseWheelMsg: + mouse := event.Mouse() + if mouse.Mod&tea.ModShift != 0 { + // Shift+鼠标事件旁路给终端原生处理,便于文本选择与复制。 + return nil + } + delta := 0 + switch mouse.Button { + case tea.MouseWheelUp: + delta = 1 + case tea.MouseWheelDown: + delta = -1 + default: + return nil + } + + if mouse.Y < outputRegionStart || mouse.Y > inputRegionEnd { + return nil + } + + region := mouseRegionOutput + if mouse.Y >= inputRegionStart && mouse.Y <= inputRegionEnd { + region = mouseRegionInput + } else if mouse.Y > outputRegionEnd { + return nil + } + + return func() tea.Msg { + return mouseScrollMsg{Region: region, Delta: delta} + } + + case tea.MouseClickMsg: + mouse := event.Mouse() + if mouse.Mod&tea.ModShift != 0 { + // Shift+点击旁路给终端,允许原生选择。 + return nil + } + y := mouse.Y + + if y < outputRegionStart || y > inputRegionEnd { + return nil + } + + if y >= outputRegionStart && y <= outputRegionEnd { + return func() tea.Msg { + return mouseFocusMsg{Region: mouseRegionOutput} + } + } + + if y >= inputRegionStart && y <= inputRegionEnd { + if y >= historyRegionStart && y <= historyRegionEnd && len(historyRendered) > 0 { + row := y - historyRegionStart + renderedIndex := historyStart + row + if renderedIndex >= 0 && renderedIndex < len(historyIndices) { + hIdx := historyIndices[renderedIndex] + return func() tea.Msg { + return mouseSelectHistoryMsg{HistoryIndex: hIdx} + } + } + } + + return func() tea.Msg { + return mouseFocusMsg{Region: mouseRegionInput} + } + } + + return nil + } + + return nil + } + + return v +} + +func (m *agentlineModel) scrollOutputLines(delta int) { + total := len(m.renderOutputLines(m.contentWidth())) + rows := m.outputRows() + m.outputOffset = clampOutputOffset(m.outputOffset+delta, total, rows) +} + +func (m *agentlineModel) scrollInputLines(delta int) { + total := len(m.renderInputHistoryLines(m.contentWidth())) + rows := m.inputRows() + m.inputOffset = clampInputOffset(m.inputOffset+delta, total, rows) +} + +func (m *agentlineModel) scrollOutputPage(deltaPage int) { + if deltaPage == 0 { + return + } + rows := m.outputRows() + m.scrollOutputLines(deltaPage * rows) +} + +func (m *agentlineModel) scrollOutputTop() { + total := len(m.renderOutputLines(m.contentWidth())) + rows := m.outputRows() + maxOffset := total - rows + if maxOffset < 0 { + maxOffset = 0 + } + m.outputOffset = maxOffset +} + +func (m *agentlineModel) scrollOutputBottom() { + m.outputOffset = 0 +} + +func (m *agentlineModel) normalizeOutputOffset() { + total := len(m.renderOutputLines(m.contentWidth())) + m.outputOffset = clampOutputOffset(m.outputOffset, total, m.outputRows()) +} + +func (m *agentlineModel) normalizeInputOffset() { + total := len(m.renderInputHistoryLines(m.contentWidth())) + m.inputOffset = clampInputOffset(m.inputOffset, total, m.inputRows()) +} + +func (m *agentlineModel) 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 = string(block.Kind) + } + + head := fmt.Sprintf("■ #%d [%s] %s", i+1, strings.ToUpper(string(block.Kind)), title) + out = append(out, renderBlockHeader(block.Kind, truncateDisplayWidth(head, width))) + + linesToRender := block.Lines + if m.foldDetails && (block.Kind == blockKindAssistant || block.Kind == blockKindTool) && len(block.Lines) > 1 { + linesToRender = []string{ + block.Lines[0], + fmt.Sprintf("... (%d more lines folded)", len(block.Lines)-1), + } + } + + if len(linesToRender) == 0 { + out = append(out, " (no output)") + } else { + for _, line := range linesToRender { + 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) + } + out = append(out, sep) + } + } + + return out +} + +func (m *agentlineModel) renderInputHistoryLines(width int) []string { + lines, _ := m.renderInputHistoryLinesWithIndices(width) + return lines +} + +func (m *agentlineModel) renderInputHistoryLinesWithIndices(width int) ([]string, []int) { + if len(m.history) == 0 { + return nil, nil + } + + historyWidth := width + if historyWidth > 0 { + historyWidth -= 2 + } + out := make([]string, 0, len(m.history)) + indices := make([]int, 0, len(m.history)) + for i, line := range m.history { + entry := fmt.Sprintf("%03d %s", i+1, strings.TrimSpace(line)) + wrapped := wrapDisplayWidth(entry, historyWidth) + if len(wrapped) == 0 { + continue + } + for _, w := range wrapped { + out = append(out, w) + indices = append(indices, i) + } + } + return out, indices +} + +func (m *agentlineModel) contentWidth() int { + if m.width <= 0 { + return 0 + } + w := m.width - 1 + if w < 1 { + return 1 + } + return w +} + +func (m *agentlineModel) 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 *agentlineModel) inputRows() int { + if m.height <= 0 { + return defaultInputRows + } + + rows := defaultInputRows + maxRows := m.height / 3 + if maxRows < 1 { + maxRows = 1 + } + if rows > maxRows { + rows = maxRows + } + if rows < 1 { + rows = 1 + } + return rows +} + +func (m *agentlineModel) 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 *agentlineModel) baseOccupiedRows(withSuggestionFrame bool) int { + rows := 5 + if m.running { + rows++ + } + if withSuggestionFrame { + rows += 2 + } + return rows +} diff --git a/cmds/agentlineapp/agentline_test.go b/cmds/agentlineapp/agentline_test.go new file mode 100644 index 0000000..4d657c3 --- /dev/null +++ b/cmds/agentlineapp/agentline_test.go @@ -0,0 +1,1019 @@ +package agentlineapp + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + agentlinemodule "github.com/pubgo/fastgit/pkg/agentline" + "github.com/pubgo/redant" +) + +func TestCollectSlashCompletionItems(t *testing.T) { + root := buildTestRoot() + items := collectSlashCompletionItems(root, "/", false) + if len(items) == 0 { + t.Fatalf("expected slash suggestions for '/'") + } + if _, ok := findCompletion(items, "/run"); !ok { + t.Fatalf("expected /run in slash suggestions") + } + if _, ok := findCompletion(items, "/history"); !ok { + t.Fatalf("expected /history in slash suggestions") + } + if _, ok := findCompletion(items, "/commit"); !ok { + t.Fatalf("expected /commit in slash suggestions") + } + if _, ok := findCompletion(items, "/a"); ok { + t.Fatalf("expected /a alias hidden from slash suggestions") + } + if _, ok := findCompletion(items, "/q"); ok { + t.Fatalf("expected /q alias hidden from slash suggestions") + } +} + +func TestCollectSlashCompletionItems_AgentOnly(t *testing.T) { + root := buildTestRoot() + + items := collectSlashCompletionItems(root, "/", true) + if _, ok := findCompletion(items, "/commit"); !ok { + t.Fatalf("expected /commit in agent-only slash suggestions") + } + if _, ok := findCompletion(items, "/wait"); ok { + t.Fatalf("expected /wait excluded in agent-only slash suggestions") + } +} + +func TestCollectSlashCompletionItems_StrictAgentOnlyExcludesUnmarked(t *testing.T) { + root := buildTestRoot() + root.Children[0].Metadata = nil // commit no longer marked as agent + + items := collectSlashCompletionItems(root, "/", true) + if _, ok := findCompletion(items, "/commit"); ok { + t.Fatalf("expected /commit excluded when command is not explicitly marked as agent") + } +} + +func TestCollectSlashCompletionItems_ExcludeCommandAliases(t *testing.T) { + root := buildTestRoot() + root.Children[0].Aliases = []string{"ci"} // commit alias + + items := collectSlashCompletionItems(root, "/", false) + if _, ok := findCompletion(items, "/commit"); !ok { + t.Fatalf("expected /commit in slash suggestions") + } + if _, ok := findCompletion(items, "/ci"); ok { + t.Fatalf("expected /ci alias not shown in slash suggestions") + } +} + +func TestCollectSlashCompletionItems_CommandFlags(t *testing.T) { + root := buildTestRoot() + + items := collectSlashCompletionItems(root, "/commit ", false) + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in slash flag suggestions") + } + + items = collectSlashCompletionItems(root, "/commit --m", false) + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in slash flag prefix suggestions") + } +} + +func TestCollectSlashCompletionItems_ChatSuggestsCommands(t *testing.T) { + root := buildTestRoot() + + items := collectSlashCompletionItems(root, "/chat ", false) + if _, ok := findCompletion(items, "/chat commit"); !ok { + t.Fatalf("expected '/chat commit' suggestion when typing '/chat '") + } + + items = collectSlashCompletionItems(root, "/chat com", false) + if _, ok := findCompletion(items, "/chat commit"); !ok { + t.Fatalf("expected '/chat commit' suggestion when typing '/chat com'") + } +} + +func TestCollectSlashCompletionItems_ChatCommandFlags(t *testing.T) { + root := buildTestRoot() + + items := collectSlashCompletionItems(root, "/chat commit ", false) + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in '/chat commit ' flag suggestions") + } + + items = collectSlashCompletionItems(root, "/chat commit --m", false) + if _, ok := findCompletion(items, "--message"); !ok { + t.Fatalf("expected --message in '/chat commit --m' flag suggestions") + } +} + +func TestCollectSlashCompletionItems_ChatCommandShowsFlagsAndArgsWithoutTrailingSpace(t *testing.T) { + var ( + sessionID string + prompt string + ) + root := &redant.Command{ + Use: "app", + Children: []*redant.Command{ + { + Use: "resume", + Short: "恢复会话", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "session-id", Value: redant.StringOf(&sessionID), Description: "会话 ID"}, + {Flag: "prompt", Value: redant.StringOf(&prompt), Description: "提示词"}, + }, + Args: redant.ArgSet{ + {Name: "topic", Description: "对话主题", Required: true}, + }, + }, + }, + } + + items := collectSlashCompletionItems(root, "/chat resume", true) + if _, ok := findCompletion(items, "--session-id"); !ok { + t.Fatalf("expected --session-id in '/chat resume' suggestions") + } + if _, ok := findCompletion(items, "topic"); !ok { + t.Fatalf("expected positional arg 'topic' in '/chat resume' suggestions") + } +} + +func TestApplySelectedCompletion_ChatDoesNotDuplicatePrefix(t *testing.T) { + got := applySelectedCompletion("/chat ", "/chat commit ") + if got != "/chat commit " { + t.Fatalf("expected '/chat commit ', got %q", got) + } +} + +func TestApplySelectedCompletion_ChatPrefixTokenDoesNotDuplicatePrefix(t *testing.T) { + got := applySelectedCompletion("/chat r", "/chat resume ") + if got != "/chat resume " { + t.Fatalf("expected '/chat resume ', got %q", got) + } +} + +func TestCollectSlashCompletionItems_TypoSuggestsChat(t *testing.T) { + root := buildTestRoot() + items := collectSlashCompletionItems(root, "/caht", false) + if _, ok := findCompletion(items, "/chat"); !ok { + t.Fatalf("expected typo '/caht' to suggest '/chat'") + } +} + +func TestHandleSlashInput_ModeSwitch(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/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.handleSlashInput("/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 TestView_MouseWheelDispatchByRegion(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 100 + m.height = 24 + m.history = []string{"/ask one", "/ask two", "/run commit --message hi", "/plan test"} + m.historyPos = len(m.history) + m.appendBlock(sessionBlock{Kind: blockKindSystem, Title: "output", Lines: []string{"line-1", "line-2", "line-3", "line-4", "line-5"}}) + + v := m.View() + if v.OnMouse == nil { + t.Fatalf("expected mouse handler configured") + } + + lines := strings.Split(v.Content, "\n") + outputY := findLineContaining(lines, "输出区域") + inputY := findLineContaining(lines, "输入区域") + if outputY < 0 || inputY < 0 { + t.Fatalf("expected output/input region markers in view") + } + + cmd := v.OnMouse(tea.MouseWheelMsg{X: 0, Y: outputY, Button: tea.MouseWheelUp}) + if cmd == nil { + t.Fatalf("expected output region wheel event produce cmd") + } + msg := cmd() + scroll, ok := msg.(mouseScrollMsg) + if !ok { + t.Fatalf("expected mouseScrollMsg, got %T", msg) + } + if scroll.Region != mouseRegionOutput || scroll.Delta != 1 { + t.Fatalf("expected output region delta=1, got region=%s delta=%d", scroll.Region, scroll.Delta) + } + + cmd = v.OnMouse(tea.MouseWheelMsg{X: 0, Y: inputY, Button: tea.MouseWheelDown}) + if cmd == nil { + t.Fatalf("expected input region wheel event produce cmd") + } + msg = cmd() + scroll, ok = msg.(mouseScrollMsg) + if !ok { + t.Fatalf("expected mouseScrollMsg, got %T", msg) + } + if scroll.Region != mouseRegionInput || scroll.Delta != -1 { + t.Fatalf("expected input region delta=-1, got region=%s delta=%d", scroll.Region, scroll.Delta) + } + + // Shift+滚轮:应旁路给终端原生行为(通常用于选择/复制场景)。 + cmd = v.OnMouse(tea.MouseWheelMsg{X: 0, Y: outputY, Button: tea.MouseWheelUp, Mod: tea.ModShift}) + if cmd != nil { + t.Fatalf("expected shift+wheel to be bypassed") + } +} + +func TestUpdate_MouseScrollMsgScrollsInputAndOutput(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 100 + m.height = 14 + m.history = []string{"h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "h9", "h10"} + m.historyPos = len(m.history) + m.blocks = []sessionBlock{{Kind: blockKindSystem, Title: "system", Lines: []string{"one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen"}}} + + model, _ := m.Update(mouseScrollMsg{Region: mouseRegionInput, Delta: 1}) + m = model.(*agentlineModel) + if m.inputOffset <= 0 { + t.Fatalf("expected inputOffset > 0 after input wheel up, got %d", m.inputOffset) + } + if m.outputFocus { + t.Fatalf("expected outputFocus=false when scrolling input region") + } + + model, _ = m.Update(mouseScrollMsg{Region: mouseRegionOutput, Delta: 1}) + m = model.(*agentlineModel) + if m.outputOffset <= 0 { + t.Fatalf("expected outputOffset > 0 after output wheel up, got %d", m.outputOffset) + } + if !m.outputFocus { + t.Fatalf("expected outputFocus=true when scrolling output region") + } +} + +func TestView_MouseClickInputRegionFocusInput(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 100 + m.height = 24 + m.history = []string{"/ask one", "/ask two", "/run commit --message hi", "/plan test"} + m.historyPos = len(m.history) + + v := m.View() + if v.OnMouse == nil { + t.Fatalf("expected mouse handler configured") + } + + lines := strings.Split(v.Content, "\n") + clickY := findLineContaining(lines, "输入区域") + if clickY < 0 { + t.Fatalf("expected input region rendered in view") + } + + cmd := v.OnMouse(tea.MouseClickMsg{X: 0, Y: clickY, Button: tea.MouseLeft}) + if cmd == nil { + t.Fatalf("expected click in input region to emit focus message") + } + msg := cmd() + focusMsg, ok := msg.(mouseFocusMsg) + if !ok { + t.Fatalf("expected mouseFocusMsg, got %T", msg) + } + if focusMsg.Region != mouseRegionInput { + t.Fatalf("expected focus region input, got %q", focusMsg.Region) + } + + // Shift+点击:应旁路给终端原生选择行为。 + cmd = v.OnMouse(tea.MouseClickMsg{X: 0, Y: clickY, Button: tea.MouseLeft, Mod: tea.ModShift}) + if cmd != nil { + t.Fatalf("expected shift+click to be bypassed") + } +} + +func TestView_MouseClickHistoryRowSelectsHistory(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 100 + m.height = 24 + m.history = []string{"/ask one", "/ask two", "/run commit --message hi", "/plan test"} + m.historyPos = len(m.history) + + v := m.View() + if v.OnMouse == nil { + t.Fatalf("expected mouse handler configured") + } + + lines := strings.Split(v.Content, "\n") + clickY := findLineContaining(lines, "002 /ask two") + if clickY < 0 { + t.Fatalf("expected rendered history row for '/ask two'") + } + + cmd := v.OnMouse(tea.MouseClickMsg{X: 0, Y: clickY, Button: tea.MouseLeft}) + if cmd == nil { + t.Fatalf("expected click on history row to emit selection message") + } + msg := cmd() + selMsg, ok := msg.(mouseSelectHistoryMsg) + if !ok { + t.Fatalf("expected mouseSelectHistoryMsg, got %T", msg) + } + if selMsg.HistoryIndex != 1 { + t.Fatalf("expected history index 1, got %d", selMsg.HistoryIndex) + } +} + +func TestUpdate_MouseSelectHistoryMsgFillsInput(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.history = []string{"h1", "h2", "h3"} + m.historyPos = len(m.history) + m.outputFocus = true + + model, _ := m.Update(mouseSelectHistoryMsg{HistoryIndex: 1}) + m = model.(*agentlineModel) + if got := m.input.Value(); got != "h2" { + t.Fatalf("expected input filled with h2, got %q", got) + } + if m.historyPos != 1 { + t.Fatalf("expected historyPos=1, got %d", m.historyPos) + } + if m.selectedHistory != 1 { + t.Fatalf("expected selectedHistory=1, got %d", m.selectedHistory) + } + if m.outputFocus { + t.Fatalf("expected outputFocus=false after selecting input history") + } +} + +func TestHistoryUpDownTracksSelectedHistory(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.history = []string{"h1", "h2", "h3"} + m.historyPos = len(m.history) + + m.historyUp() + if m.selectedHistory != 2 { + t.Fatalf("expected selectedHistory=2 after first up, got %d", m.selectedHistory) + } + + m.historyUp() + if m.selectedHistory != 1 { + t.Fatalf("expected selectedHistory=1 after second up, got %d", m.selectedHistory) + } + + m.historyDown() + if m.selectedHistory != 2 { + t.Fatalf("expected selectedHistory=2 after down, got %d", m.selectedHistory) + } + + m.historyDown() + if m.selectedHistory != -1 { + t.Fatalf("expected selectedHistory=-1 when leaving history mode, got %d", m.selectedHistory) + } + if got := m.input.Value(); got != "" { + t.Fatalf("expected empty input after leaving history mode, got %q", got) + } +} + +func TestRunSlashRunCmd(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + msg := runSlashRunCmd(context.Background(), m, root, "commit --message hello")() + res, ok := msg.(runResultMsg) + if !ok { + t.Fatalf("expected runResultMsg, got %T", msg) + } + if len(res.blocks) < 3 { + t.Fatalf("expected at least 3 blocks, got %d", len(res.blocks)) + } + if res.blocks[0].Kind != blockKindTool { + t.Fatalf("expected first block kind=tool, got %s", res.blocks[0].Kind) + } + if res.blocks[1].Kind != blockKindTool { + t.Fatalf("expected second block kind=tool(parse), got %s", res.blocks[1].Kind) + } + if res.blocks[2].Kind != blockKindCommand { + t.Fatalf("expected third block kind=command, got %s", res.blocks[2].Kind) + } + + result, ok := findBlockByKind(res.blocks, blockKindResult) + if !ok { + t.Fatalf("expected result block in run result") + } + + joined := strings.Join(result.Lines, "\n") + if !strings.Contains(joined, "status: ok") { + t.Fatalf("expected status line in result block, got: %s", joined) + } + if !strings.Contains(joined, "duration:") { + t.Fatalf("expected duration line in result block, got: %s", joined) + } + if !strings.Contains(joined, "commit ok") { + t.Fatalf("expected command output in result block, got: %s", joined) + } +} + +func TestRunSlashRunCmd_Canceled(t *testing.T) { + root := buildTestRoot() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + msg := runSlashRunCmd(ctx, m, root, "wait")() + res, ok := msg.(runResultMsg) + if !ok { + t.Fatalf("expected runResultMsg, got %T", msg) + } + + result, ok := findBlockByKind(res.blocks, blockKindResult) + if !ok { + t.Fatalf("expected result block") + } + joined := strings.Join(result.Lines, "\n") + if !strings.Contains(joined, "status: canceled") { + t.Fatalf("expected canceled status, got: %s", joined) + } +} + +func TestHandleSlashInput_CancelRunning(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + called := false + m.running = true + m.currentCancel = func() { called = true } + + handled, cmd := m.handleSlashInput("/cancel") + if !handled || cmd != nil { + t.Fatalf("expected /cancel handled without cmd, handled=%v cmd=%v", handled, cmd) + } + if !called { + t.Fatalf("expected cancel function called") + } +} + +func TestHandleSlashInput_FoldUnfold(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/fold") + if !handled || cmd != nil { + t.Fatalf("expected /fold handled without cmd") + } + if !m.foldDetails { + t.Fatalf("expected foldDetails=true after /fold") + } + + handled, cmd = m.handleSlashInput("/unfold") + if !handled || cmd != nil { + t.Fatalf("expected /unfold handled without cmd") + } + if m.foldDetails { + t.Fatalf("expected foldDetails=false after /unfold") + } +} + +func TestHandleSlashInput_CommandAsSlash(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/commit --message hi") + if !handled { + t.Fatalf("expected slash command handled") + } + if cmd == nil { + t.Fatalf("expected slash command to return run cmd") + } + if !m.running { + t.Fatalf("expected running=true after slash command run") + } +} + +func TestHandleSlashInput_CommandAliasNotUsedAsSlash(t *testing.T) { + root := buildTestRoot() + root.Children[0].Aliases = []string{"ci"} // commit alias + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/ci --message hi") + if !handled { + t.Fatalf("expected slash input handled as slash flow") + } + if cmd != nil { + t.Fatalf("expected alias not treated as runnable slash command") + } + if m.running { + t.Fatalf("expected running=false when alias is not accepted in slash") + } +} + +func TestHandleSlashInput_HistoryDefault(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.history = []string{"/ask one", "/run commit --message hi", "/plan release"} + m.outputOffset = 7 + + handled, cmd := m.handleSlashInput("/history") + if !handled || cmd != nil { + t.Fatalf("expected /history handled without cmd, handled=%v cmd=%v", handled, cmd) + } + + last := m.blocks[len(m.blocks)-1] + if last.Title != "/history" { + t.Fatalf("expected last block title /history, got %q", last.Title) + } + joined := strings.Join(last.Lines, "\n") + if !strings.Contains(joined, "total: 3") { + t.Fatalf("expected total line in /history output, got: %s", joined) + } + if !strings.Contains(joined, "003 /plan release") { + t.Fatalf("expected numbered history line, got: %s", joined) + } + if m.outputOffset != 0 { + t.Fatalf("expected outputOffset reset to 0 after /history, got %d", m.outputOffset) + } +} + +func TestHandleSlashInput_HistoryWithLimit(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.history = []string{"h1", "h2", "h3", "h4", "h5"} + + handled, cmd := m.handleSlashInput("/history 2") + if !handled || cmd != nil { + t.Fatalf("expected /history 2 handled without cmd, handled=%v cmd=%v", handled, cmd) + } + + last := m.blocks[len(m.blocks)-1] + if len(last.Lines) != 3 { + t.Fatalf("expected 3 lines(total+2 entries), got %d", len(last.Lines)) + } + joined := strings.Join(last.Lines, "\n") + if strings.Contains(joined, "003 h3") { + t.Fatalf("did not expect older history entry in limited output, got: %s", joined) + } + if !strings.Contains(joined, "004 h4") || !strings.Contains(joined, "005 h5") { + t.Fatalf("expected latest 2 entries, got: %s", joined) + } +} + +func TestHandleSlashInput_HistoryInvalidArg(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/history abc") + if !handled || cmd != nil { + t.Fatalf("expected invalid /history handled without cmd, handled=%v cmd=%v", handled, cmd) + } + + last := m.blocks[len(m.blocks)-1] + if last.Kind != blockKindError { + t.Fatalf("expected error block for invalid /history arg, got %s", last.Kind) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "用法:/history") { + t.Fatalf("expected usage hint for invalid /history") + } +} + +func TestHandleSlashInput_UnknownCommandSuggestsClosest(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/caht") + if !handled || cmd != nil { + t.Fatalf("expected unknown slash handled without cmd, handled=%v cmd=%v", handled, cmd) + } + + last := m.blocks[len(m.blocks)-1] + if last.Kind != blockKindError { + t.Fatalf("expected error block, got %s", last.Kind) + } + joined := strings.Join(last.Lines, "\n") + if !strings.Contains(joined, "你可能想输入") || !strings.Contains(joined, "/chat") { + t.Fatalf("expected closest command suggestion '/chat', got: %s", joined) + } +} + +func TestRenderOutputLines_FoldDetails(t *testing.T) { + m := &agentlineModel{ + foldDetails: true, + blocks: []sessionBlock{ + {Kind: blockKindAssistant, Title: "assistant", Lines: []string{"line1", "line2", "line3"}}, + }, + } + + lines := m.renderOutputLines(80) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "folded") { + t.Fatalf("expected folded hint in output, got: %s", joined) + } +} + +func TestTabOnEmptyInputShowsStarterSlashSuggestions(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + 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.(*agentlineModel) + if len(m.suggestions) == 0 { + t.Fatalf("expected starter suggestions on first TAB") + } + if _, ok := findCompletion(m.suggestions, "/run"); !ok { + t.Fatalf("expected /run suggestion") + } + if got := m.input.Value(); got != "" { + t.Fatalf("expected first TAB not applying suggestion, got input=%q", got) + } +} + +func TestEnterPlainTextShowsSimplifiedHint(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.input.SetValue("请帮我总结今天改动") + + model, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd != nil { + t.Fatalf("expected no async cmd for plain text in simplified mode") + } + if m.running { + t.Fatalf("expected running=false for plain text in simplified mode") + } + last := m.blocks[len(m.blocks)-1] + if last.Title != "input" { + t.Fatalf("expected input hint block, got %q", last.Title) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "精简命令模式") { + t.Fatalf("expected simplified mode hint in input block") + } +} + +func TestEnterPlainTextInChatStickyModeRunsCommand(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.input.SetValue("/chat commit") + + model, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd != nil { + t.Fatalf("expected no async cmd while entering chat sticky mode") + } + if m.stickyInvocation == nil { + t.Fatalf("expected sticky invocation to be configured") + } + + m.input.SetValue("接着上次的话题") + + model, cmd = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd == nil { + t.Fatalf("expected async cmd for plain text in chat sticky mode") + } + if !m.running { + t.Fatalf("expected running=true in chat sticky mode") + } + last := m.blocks[len(m.blocks)-1] + if last.Kind != blockKindUser || last.Title != "user" { + t.Fatalf("expected user echo block before command run, got kind=%s title=%q", last.Kind, last.Title) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "接着上次的话题") { + t.Fatalf("expected user echo contains latest input") + } +} + +func TestEnterCommandLikeInputInChatModeRunsStickyCommand(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + m.input.SetValue("/chat commit") + model, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd != nil { + t.Fatalf("expected no async cmd while entering chat sticky mode") + } + + m.input.SetValue("commit --message hi") + model, cmd = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if cmd == nil { + t.Fatalf("expected async cmd in chat sticky mode even for command-like text") + } + if !m.running { + t.Fatalf("expected running=true in chat sticky mode") + } +} + +func TestView_ShowsChatModeHintLine(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 100 + m.height = 24 + m.bindStickyInvocation(&stickyInvocation{BaseArgs: []string{"commit"}, PromptFlag: "--prompt"}) + + v := m.View() + if !strings.Contains(v.Content, "当前为聊天模式") { + t.Fatalf("expected view to contain chat mode hint line") + } + if !strings.Contains(v.Content, "chat=commit --prompt ") { + t.Fatalf("expected view to contain chat binding line") + } +} + +func TestView_ShowsPendingQuestionHintWhileRunning(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.width = 120 + m.height = 28 + m.running = true + + respCh := make(chan AskResponse, 1) + go func() { + resp, _ := m.questionBroker.Request(context.Background(), AskRequest{Prompt: "请确认是否继续执行"}) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.questionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + v := m.View() + if !strings.Contains(v.Content, "WAITING_ANSWER") { + t.Fatalf("expected running view status to be WAITING_ANSWER") + } + if !strings.Contains(v.Content, "等待问题回复") { + t.Fatalf("expected running view contains waiting-question hint") + } + if !strings.Contains(v.Content, "最新问题:") { + t.Fatalf("expected running view contains latest question line") + } + + if err := m.questionBroker.Cancel(""); err != nil { + t.Fatalf("cancel pending question failed: %v", err) + } + select { + case <-respCh: + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting question cancel") + } +} + +func TestHandleSlashInput_ChatRequiresCommand(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/chat") + if !handled || cmd != nil { + t.Fatalf("expected /chat handled without async cmd, handled=%v cmd=%v", handled, cmd) + } + last := m.blocks[len(m.blocks)-1] + if last.Kind != blockKindError { + t.Fatalf("expected error block for empty /chat, got %s", last.Kind) + } + if !strings.Contains(strings.Join(last.Lines, "\n"), "需要指定命令") { + t.Fatalf("expected usage hint for empty /chat") + } +} + +func TestHandleSlashInput_ChatAndUnbind(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/chat commit") + if !handled || cmd != nil { + t.Fatalf("expected /chat handled without async cmd, handled=%v cmd=%v", handled, cmd) + } + if m.stickyInvocation == nil { + t.Fatalf("expected sticky invocation after /chat") + } + + handled, cmd = m.handleSlashInput("/unbind") + if !handled || cmd != nil { + t.Fatalf("expected /unbind handled without async cmd, handled=%v cmd=%v", handled, cmd) + } + if m.stickyInvocation != nil { + t.Fatalf("expected sticky invocation cleared after /unbind") + } + if m.mode != interactionModeCommand { + t.Fatalf("expected command mode after /unbind, got %q", m.mode) + } +} + +func TestHandleSlashInput_ChatSetsMode(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + handled, cmd := m.handleSlashInput("/chat commit") + if !handled || cmd != nil { + t.Fatalf("expected /chat handled without async cmd, handled=%v cmd=%v", handled, cmd) + } + if m.mode != interactionModeChat { + t.Fatalf("expected chat mode after /chat, got %q", m.mode) + } + if !m.isChatMode() { + t.Fatalf("expected isChatMode=true after /chat") + } +} + +func TestSuggestionNavigationTakesPriorityOverOutputFocus(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + m.outputFocus = true + m.input.SetValue("/") + m.recomputeSuggestions() + + if len(m.suggestions) < 2 { + t.Fatalf("expected at least 2 slash suggestions, got=%d", len(m.suggestions)) + } + + model, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) + m = model.(*agentlineModel) + if m.selected != 1 { + t.Fatalf("expected selected=1 after down, got=%d", m.selected) + } + if m.outputOffset != 0 { + t.Fatalf("expected outputOffset unchanged when navigating suggestions, got=%d", m.outputOffset) + } + + model, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyUp})) + m = model.(*agentlineModel) + if m.selected != 0 { + t.Fatalf("expected selected=0 after up, got=%d", m.selected) + } +} + +func TestIsCommandLikeInput(t *testing.T) { + root := buildTestRoot() + if !isCommandLikeInput(root, "commit --message hi", false) { + t.Fatalf("expected commit line recognized as command input") + } + if isCommandLikeInput(root, "请帮我总结一下今天改动", false) { + t.Fatalf("expected natural language not recognized as command input") + } +} + +func TestIsCommandLikeInput_AgentOnlyMode(t *testing.T) { + root := buildTestRoot() + root.Children[0].Metadata = agentlinemodule.AgentCommandMetadata() // commit + + if !isCommandLikeInput(root, "commit --message hi", true) { + t.Fatalf("expected commit recognized in agent-only mode") + } + if isCommandLikeInput(root, "wait", true) { + t.Fatalf("expected non-agent command rejected in agent-only mode") + } +} + +func TestNewAgentlineModel_InitWithInitialArgv(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, []string{"commit", "--message", "hello world"}) + + cmd := m.Init() + if cmd == nil { + t.Fatalf("expected non-nil init cmd for initial argv") + } + if !m.running { + t.Fatalf("expected running=true after init bootstrap") + } +} + +func TestBuildStickyInvocation(t *testing.T) { + root := buildTestRoot() + + t.Run("empty command", func(t *testing.T) { + _, err := buildStickyInvocation(root, "", true) + if err == nil { + t.Fatalf("expected error for empty /chat command") + } + }) + + t.Run("agent command", func(t *testing.T) { + sticky, err := buildStickyInvocation(root, "commit --message old", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sticky == nil { + t.Fatalf("expected sticky invocation") + } else { + if sticky.PromptFlag != "--prompt" { + t.Fatalf("expected default prompt flag, got %q", sticky.PromptFlag) + } + joined := strings.Join(sticky.BaseArgs, " ") + if !strings.Contains(joined, "commit") { + t.Fatalf("expected command args keep command path, got %q", joined) + } + } + }) + + t.Run("reject non-agent command in agent-only mode", func(t *testing.T) { + _, err := buildStickyInvocation(root, "wait", true) + if err == nil { + t.Fatalf("expected non-agent command rejected") + } + }) +} + +func TestSessionContextLine(t *testing.T) { + m := &agentlineModel{sessionCWD: "/tmp/work", sessionGitBranch: "feat/copilot", sessionGitDirty: true} + got := m.sessionContextLine() + if !strings.Contains(got, "cwd=/tmp/work") { + t.Fatalf("expected cwd in session context line, got %q", got) + } + if !strings.Contains(got, "git=feat/copilot*") { + t.Fatalf("expected git branch in session context line, got %q", got) + } +} + +func TestDisplayGitBranch_NotRepo(t *testing.T) { + if got := displayGitBranch("", false); got != "(not repo)" { + t.Fatalf("expected (not repo), got %q", got) + } +} + +func TestDisplayGitBranch_DirtySuffix(t *testing.T) { + if got := displayGitBranch("feat/copilot", true); got != "feat/copilot*" { + t.Fatalf("expected dirty branch suffix, got %q", got) + } +} + +func buildTestRoot() *redant.Command { + var msg string + commit := &redant.Command{ + Use: "commit", + Short: "提交代码", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "message", Shorthand: "m", Description: "提交信息", Value: redant.StringOf(&msg)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _, err := inv.Stdout.Write([]byte("commit ok\n")) + return err + }, + } + + wait := &redant.Command{ + Use: "wait", + Short: "等待上下文取消", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + <-ctx.Done() + return ctx.Err() + }, + } + + return &redant.Command{ + Use: "app", + Children: []*redant.Command{commit, wait}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { return nil }, + } +} + +func findBlockByKind(blocks []sessionBlock, kind blockKind) (sessionBlock, bool) { + for _, b := range blocks { + if b.Kind == kind { + return b, true + } + } + return sessionBlock{}, false +} + +func findCompletion(items []completionItem, insert string) (completionItem, bool) { + for _, item := range items { + if item.Insert == insert { + return item, true + } + } + return completionItem{}, false +} + +func findLineContaining(lines []string, needle string) int { + for i, line := range lines { + if strings.Contains(line, needle) { + return i + } + } + return -1 +} diff --git a/cmds/agentlineapp/interaction_protocol.go b/cmds/agentlineapp/interaction_protocol.go new file mode 100644 index 0000000..a6adc97 --- /dev/null +++ b/cmds/agentlineapp/interaction_protocol.go @@ -0,0 +1,90 @@ +package agentlineapp + +import ( + "context" + "fmt" + "strings" + + "github.com/pubgo/redant" +) + +// InteractionAnnotationKey 是注入到 Invocation.Annotations 的协议入口键。 +// +// 协议约定(v1): +// 1. 命令侧通过 InteractionFromInvocation 获取桥接对象; +// 2. 调用 Emit 推送中间态事件(system/user/assistant/tool/error); +// 3. 调用 Ask 发起阻塞提问,用户可在 agentline 中通过 /questions /reply /skip 响应; +// 4. Ask 返回后命令继续执行,形成“命令 <-> UI”的双向交互。 +const InteractionAnnotationKey = "agentline.interaction.v1" + +type InteractionEvent struct { + Kind string + Title string + Lines []string +} + +type AskRequest struct { + Prompt string +} + +type AskResponse struct { + Answer string + Cancelled bool +} + +// InteractionBridge 定义命令与 agentline 的双向通信接口。 +type InteractionBridge interface { + Emit(ctx context.Context, event InteractionEvent) error + Ask(ctx context.Context, req AskRequest) (AskResponse, error) +} + +type runtimeInteractionBridge struct { + emitFn func(ctx context.Context, event InteractionEvent) error + askFn func(ctx context.Context, req AskRequest) (AskResponse, error) +} + +func (b *runtimeInteractionBridge) Emit(ctx context.Context, event InteractionEvent) error { + if b == nil || b.emitFn == nil { + return nil + } + return b.emitFn(ctx, event) +} + +func (b *runtimeInteractionBridge) Ask(ctx context.Context, req AskRequest) (AskResponse, error) { + if b == nil || b.askFn == nil { + return AskResponse{}, fmt.Errorf("interaction ask is not available") + } + return b.askFn(ctx, req) +} + +// InteractionFromInvocation 从 Invocation 注解中提取交互桥。 +func InteractionFromInvocation(inv *redant.Invocation) (InteractionBridge, bool) { + if inv == nil || inv.Annotations == nil { + return nil, false + } + v, ok := inv.Annotations[InteractionAnnotationKey] + if !ok || v == nil { + return nil, false + } + bridge, ok := v.(InteractionBridge) + return bridge, ok +} + +func interactionKindToBlockKind(kind string) blockKind { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "user": + return blockKindUser + case "assistant": + return blockKindAssistant + case "tool": + return blockKindTool + case "command": + return blockKindCommand + case "result": + return blockKindResult + case "error": + return blockKindError + default: + return blockKindSystem + } +} diff --git a/cmds/agentlineapp/question_broker.go b/cmds/agentlineapp/question_broker.go new file mode 100644 index 0000000..7c83d04 --- /dev/null +++ b/cmds/agentlineapp/question_broker.go @@ -0,0 +1,149 @@ +package agentlineapp + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" +) + +type PendingQuestion struct { + ID string + Prompt string + CreatedAt time.Time +} + +type pendingQuestionRequest struct { + id string + prompt string + createdAt time.Time + respCh chan AskResponse +} + +type QuestionBroker struct { + mu sync.Mutex + nextID int64 + pending []*pendingQuestionRequest +} + +func NewQuestionBroker() *QuestionBroker { return &QuestionBroker{} } + +func (b *QuestionBroker) Request(ctx context.Context, req AskRequest) (AskResponse, error) { + if b == nil { + return AskResponse{Cancelled: true}, nil + } + prompt := strings.TrimSpace(req.Prompt) + if prompt == "" { + return AskResponse{}, errors.New("ask prompt is empty") + } + + q := &pendingQuestionRequest{ + id: b.nextRequestID(), + prompt: prompt, + createdAt: time.Now(), + respCh: make(chan AskResponse, 1), + } + + b.mu.Lock() + b.pending = append(b.pending, q) + b.mu.Unlock() + + select { + case resp := <-q.respCh: + b.remove(q.id) + return resp, nil + case <-ctx.Done(): + _ = b.Cancel(q.id) + return AskResponse{Cancelled: true}, nil + } +} + +func (b *QuestionBroker) Pending() []PendingQuestion { + if b == nil { + return nil + } + b.mu.Lock() + defer b.mu.Unlock() + out := make([]PendingQuestion, 0, len(b.pending)) + for _, p := range b.pending { + if p == nil { + continue + } + out = append(out, PendingQuestion{ID: p.id, Prompt: p.prompt, CreatedAt: p.createdAt}) + } + return out +} + +func (b *QuestionBroker) Reply(id string, answer string) error { + if b == nil { + return errors.New("question broker is nil") + } + answer = strings.TrimSpace(answer) + if answer == "" { + return errors.New("reply answer is empty") + } + req, err := b.target(id) + if err != nil { + return err + } + select { + case req.respCh <- AskResponse{Answer: answer}: + return nil + default: + return errors.New("question already resolved") + } +} + +func (b *QuestionBroker) Cancel(id string) error { + if b == nil { + return errors.New("question broker is nil") + } + req, err := b.target(id) + if err != nil { + return err + } + select { + case req.respCh <- AskResponse{Cancelled: true}: + return nil + default: + return errors.New("question already resolved") + } +} + +func (b *QuestionBroker) target(id string) (*pendingQuestionRequest, error) { + id = strings.TrimSpace(id) + b.mu.Lock() + defer b.mu.Unlock() + if len(b.pending) == 0 { + return nil, errors.New("no pending questions") + } + if id == "" { + return b.pending[len(b.pending)-1], nil + } + for _, p := range b.pending { + if p != nil && p.id == id { + return p, nil + } + } + return nil, fmt.Errorf("question not found: %s", id) +} + +func (b *QuestionBroker) nextRequestID() string { + b.mu.Lock() + defer b.mu.Unlock() + b.nextID++ + return fmt.Sprintf("ask_%d", b.nextID) +} + +func (b *QuestionBroker) remove(id string) { + b.mu.Lock() + defer b.mu.Unlock() + for i, p := range b.pending { + if p != nil && p.id == id { + b.pending = append(b.pending[:i], b.pending[i+1:]...) + return + } + } +} diff --git a/cmds/agentlineapp/question_broker_test.go b/cmds/agentlineapp/question_broker_test.go new file mode 100644 index 0000000..b853bfb --- /dev/null +++ b/cmds/agentlineapp/question_broker_test.go @@ -0,0 +1,161 @@ +package agentlineapp + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" +) + +func TestQuestionBroker_RequestAndReply(t *testing.T) { + b := NewQuestionBroker() + respCh := make(chan AskResponse, 1) + + go func() { + resp, _ := b.Request(context.Background(), AskRequest{Prompt: "继续吗?"}) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(b.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + if len(b.Pending()) == 0 { + t.Fatal("expected pending question") + } + if err := b.Reply("", "继续"); err != nil { + t.Fatalf("reply failed: %v", err) + } + + select { + case resp := <-respCh: + if resp.Cancelled || strings.TrimSpace(resp.Answer) != "继续" { + t.Fatalf("unexpected ask response: %+v", resp) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting question response") + } +} + +func TestHandleSlashInput_QuestionsAndReply(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan AskResponse, 1) + go func() { + resp, _ := m.questionBroker.Request(context.Background(), AskRequest{Prompt: "请输入确认"}) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.questionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + handled, cmd := m.handleSlashInput("/questions") + if !handled || cmd != nil { + t.Fatalf("expected /questions handled") + } + last := m.blocks[len(m.blocks)-1] + if last.Title != "/questions" { + t.Fatalf("expected /questions block title, got %q", last.Title) + } + + handled, cmd = m.handleSlashInput("/reply 已确认") + if !handled || cmd != nil { + t.Fatalf("expected /reply handled") + } + + select { + case resp := <-respCh: + if strings.TrimSpace(resp.Answer) != "已确认" { + t.Fatalf("unexpected answer: %q", resp.Answer) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting reply") + } + + // 额外校验:运行中也允许 /questions 与 /reply + m.running = true + m.input.SetValue("/questions") + model, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + if m.blocks[len(m.blocks)-1].Title != "/questions" { + t.Fatalf("expected /questions allowed while running") + } +} + +func TestHandleSlashInput_ReplyByIndex(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan AskResponse, 1) + go func() { + resp, _ := m.questionBroker.Request(context.Background(), AskRequest{Prompt: "请输入确认"}) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.questionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + handled, cmd := m.handleSlashInput("/reply 1 好的") + if !handled || cmd != nil { + t.Fatalf("expected /reply handled") + } + + select { + case resp := <-respCh: + if strings.TrimSpace(resp.Answer) != "好的" { + t.Fatalf("unexpected answer: %q", resp.Answer) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting reply") + } +} + +func TestRunning_DirectInputAnswersPendingQuestion(t *testing.T) { + root := buildTestRoot() + m := newAgentlineModel(context.Background(), root, "agent> ", nil, "", false, nil) + + respCh := make(chan AskResponse, 1) + go func() { + resp, _ := m.questionBroker.Request(context.Background(), AskRequest{Prompt: "请输入确认"}) + respCh <- resp + }() + + for i := 0; i < 50; i++ { + if len(m.questionBroker.Pending()) > 0 { + break + } + time.Sleep(10 * time.Millisecond) + } + + m.running = true + m.input.SetValue("直接回复") + model, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) + m = model.(*agentlineModel) + + if len(m.blocks) == 0 || m.blocks[len(m.blocks)-1].Title != "reply.direct" { + t.Fatalf("expected reply.direct block after direct input while running") + } + + select { + case resp := <-respCh: + if strings.TrimSpace(resp.Answer) != "直接回复" { + t.Fatalf("unexpected answer: %q", resp.Answer) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting direct reply") + } +} diff --git a/cmds/chglogcmd/assets.go b/cmds/chglogcmd/assets.go new file mode 100644 index 0000000..9d2a585 --- /dev/null +++ b/cmds/chglogcmd/assets.go @@ -0,0 +1,204 @@ +package chglogcmd + +import ( + "fmt" + "strings" +) + +const draftPromptTemplate = `你是当前仓库的 Changelog 维护助手。 + +目标:根据当前仓库改动更新 .version/changelog/Unreleased.md。 + +必须遵守: +1. 只根据可见改动编写条目,不杜撰。 +2. 分类只能使用:新增 / 修复 / 变更 / 文档。 +3. 中文技术文风,单条以动词开头,简洁、可追溯。 +4. 合并去重,避免同义重复。 +5. 只允许修改 .version/changelog/Unreleased.md。 +6. 若分类下暂无内容,写“暂无”。 +7. 不要改动 README、VERSION 或任何历史版本文件。 + +归类规则: +- feat / 新增能力 -> 新增 +- fix / bug 修正 -> 修复 +- 重构、依赖迁移、行为调整、优化 -> 变更 +- README、docs、注释更新 -> 文档 + +工作目录:%s +基线分支:%s +当前版本:%s + +请先核对以下上下文,再直接修改目标文件: + +--- .version/changelog/Unreleased.md --- +%s + +--- .version/changelog/README.md --- +%s + +--- git diff %s --stat --- +%s + +--- git diff %s --name-only --- +%s + +完成后请仅输出简短自检: +- 是否只改动了 Unreleased.md +- 是否统一为标准四类 +- 是否完成去重` + +type draftPromptData struct { + RepoRoot string + BaseRef string + Version string + UnreleasedContent string + ReadmeContent string + DiffStat string + DiffNames string +} + +func renderDraftPrompt(data draftPromptData) string { + return strings.TrimSpace(fmt.Sprintf( + draftPromptTemplate, + data.RepoRoot, + data.BaseRef, + data.Version, + data.UnreleasedContent, + data.ReadmeContent, + data.BaseRef, + data.DiffStat, + data.BaseRef, + data.DiffNames, + )) +} + +func renderRepoChangelogPromptTemplate() string { + return strings.TrimSpace(`--- +name: changelog +description: 维护 .version/changelog(更新 Unreleased 或执行版本落版) +argument-hint: "模式:draft(更新 Unreleased)或 release(按 .version/VERSION 落版)" +agent: agent +--- + +你是当前仓库的 Changelog 维护助手。 + +## 目标 + +- `+"`draft`"+` 模式:根据当前改动更新 `+"`.version/changelog/Unreleased.md`"+`。 +- `+"`release`"+` 模式:将 `+"`Unreleased.md`"+` 落版为版本文件,并重建空模板。 + +## 必读上下文 + +在开始前先读取: + +- `+"`.version/changelog/Unreleased.md`"+` +- `+"`.version/VERSION`"+` + +## 通用规则 + +1. 只基于可见改动生成条目,不杜撰。 +2. **标准分类**:`+"`新增`"+` / `+"`修复`"+` / `+"`变更`"+` / `+"`文档`"+`。 + - 非标准分类(如 `+"`优化`"+`、`+"`重构`"+`)必须归入上述四类(通常归 `+"`变更`"+`)。 +3. 语言使用中文技术文风,单条以动词开头,简洁可追溯。 +4. 去重:同类项合并,避免语义重复。 +5. 不改写历史版本文件语义与顺序。 + +## draft 模式 + +1. 获取工作区 diff:运行 `+"`git diff --stat`"+` 和 `+"`git diff --name-only`"+` 确认改动范围。 +2. 仅更新 `+"`.version/changelog/Unreleased.md`"+`。 +3. 若缺少分类小节则补齐;无内容的小节写“暂无”。 +4. 归类规则: + - feat / 新增能力 → `+"`新增`"+` + - fix / bug 修正 → `+"`修复`"+` + - 重构、依赖迁移、行为调整、优化 → `+"`变更`"+` + - README、docs、注释更新 → `+"`文档`"+` + +## release 模式 + +1. 读取 `+"`.version/VERSION`"+` 获取目标版本号(如 `+"`v0.3.0`"+`)。 +2. **版本冲突检查**:若 `+"`.version/changelog/.md`"+` 已存在,提示用户确认是否需要递增版本号,不自行覆盖。 +3. 创建版本文件 `+"`.version/changelog/.md`"+`: + - 标题格式:`+"`# [] - `"+`。 + - 将 `+"`Unreleased.md`"+` 的内容迁移过去(分类统一为标准四类)。 +4. 重建 `+"`Unreleased.md`"+` 空模板(四个分类均写“暂无”)。 +5. 更新 `+"`.version/changelog/README.md`"+` 索引:在列表顶部(`+"`Unreleased`"+` 之后)插入新版本链接。 +6. 更新 `+"`.version/VERSION`"+` 为下一个预期版本号(**仅在用户确认后**,否则保持当前值)。 + +## 输出要求 + +- 直接给出文件修改结果。 +- 末尾附一段简短自检: + - 是否仅改动 `+"`.version/`"+` 范围内的文件; + - 分类是否统一为标准四类,是否完成去重; + - 历史版本文件是否未被修改。 +`) + "\n" +} + +func renderRepoChangelogRulesTemplate() string { + return strings.TrimSpace(`--- +name: Changelog 专项规范 +description: 仅用于维护 .version/changelog,保证 Unreleased 与版本文件结构稳定、分类一致、条目可追溯 +applyTo: ".version/changelog/*.md" +--- + +# Changelog 维护规范 + +本规则仅适用于 `+"`.version/changelog/*.md`"+`。 + +## 结构约束 + +- `+"`Unreleased.md`"+` 推荐分类:`+"`新增`"+` / `+"`修复`"+` / `+"`变更`"+` / `+"`文档`"+`。 +- 若某分类暂无内容,写“暂无”。 + +## 内容约束 + +- 仅基于可见改动编写条目,不杜撰能力或影响。 +- 单条应简洁、可读、可追溯,以动词开头。 +- 重复事项需合并去重,避免同义重复。 +- 非标准分类(如 `+"`优化`"+`、`+"`重构`"+`)必须归入标准四类(通常归 `+"`变更`"+`)。 +- 不改写历史版本文件语义,不重排已发布版本。 + +## 落版约束(release) + +- 版本号来源于 `+"`.version/VERSION`"+`。 +- 落版文件:`+"`.version/changelog/.md`"+`。 +- 文件头格式:`+"`# [] - `"+`。 +- 落版前检查版本文件是否已存在,已存在时提示用户确认。 +- 落版后重建 `+"`.version/changelog/Unreleased.md`"+` 模板(四个分类)。 +- 落版后同步更新 `+"`.version/changelog/README.md`"+` 索引。 + +## 协同建议 + +- 建议通过 agent 提示词执行:`+"`/changelog draft|release`"+`。 +`) + "\n" +} + +func renderRepoReleaseRulesTemplate() string { + return strings.TrimSpace(`--- +name: 发布前变更核对约束 +description: "Use when preparing a release or completing behavior-impacting changes, including changelog updates and release regression checks." +--- + +# 发布前核对规则 + +用于“准备发布”或“完成具备行为影响的改动”时的统一核对。 + +## 发布前检查清单 + +- 变更说明已写入 `+"`.version/changelog/Unreleased.md`"+`,分类正确(新增/修复/变更/文档)。 +- 用户可见行为变化,已同步示例或说明文档。 + +## 质量门槛 + +- 执行完整回归测试并确认通过。 +- 仅基于真实改动与真实测试结果编写发布说明,不杜撰。 + +## 落版流程 + +- 首选通过 `+"`fastgit changelog release`"+` 或等效 agent prompt 执行。 +- 版本号来源于 `+"`.version/VERSION`"+`;若版本文件已存在,需确认是否递增。 +- 落版后重建 `+"`Unreleased`"+` 模板并更新 changelog 索引。 +- changelog 结构与落版细节以 `+"`.github/instructions/changelog.instructions.md`"+` 为准。 +`) + "\n" +} diff --git a/cmds/chglogcmd/cmd.go b/cmds/chglogcmd/cmd.go index 7bcebf4..56cdb76 100644 --- a/cmds/chglogcmd/cmd.go +++ b/cmds/chglogcmd/cmd.go @@ -3,895 +3,202 @@ package chglogcmd import ( "context" "fmt" - "net/url" - "os" - "os/exec" - "regexp" "strings" - "time" - "github.com/pubgo/funk/v2/log" "github.com/pubgo/redant" ) -// ChangelogEntry represents a single changelog entry -type ChangelogEntry struct { - Hash string - Date time.Time - Author string - AuthorEmail string - Subject string - Body string - Type string - Scope string - Breaking bool - Refs []string - IsPRMerge bool - PRNumber string - PRTitle string -} - -// ChangelogSection represents a section of the changelog -type ChangelogSection struct { - Title string - Items []ChangelogEntry -} +// NewCommand creates the changelog command group. +func NewCommand() *redant.Command { + root := &redant.Command{ + Use: "changelog", + Short: "使用 Copilot 工作流维护 .version/changelog", + Long: "初始化 changelog 模板、用 Copilot 维护 Unreleased,或执行版本落版。", + } -// Changelog represents the entire changelog -type Changelog struct { - Version string - Date time.Time - StartDate time.Time - EndDate time.Time - Sections []ChangelogSection - Commits []ChangelogEntry - CommitURL string - IssueURL string - PRURL string -} + root.Children = []*redant.Command{ + newInitCommand(), + newDraftCommand(), + newReleaseCommand(), + } -// CommitRecord represents raw commit fields from git log -type CommitRecord struct { - Hash string - Date string - Author string - AuthorEmail string - Subject string - Body string + return root } -// NewCommand creates the changelog command -func NewCommand() *redant.Command { - var fromRef, toRef, outputFile string - var includeBreaking, includeRefs bool - includeAuthor := true - var style string - var keepExtra bool +func newInitCommand() *redant.Command { + var ( + repoPath string + version string + force bool + ) - app := &redant.Command{ - Use: "changelog", - Short: "Generate changelog between two refs (branches/tags)", - Long: `Generate a changelog between two Git refs (branches or tags) and output to a file or stdout`, - Options: []redant.Option{ - { - Flag: "from", - Description: "Source ref (branch/tag) to compare from (required)", - Value: redant.StringOf(&fromRef), - }, - { - Flag: "to", - Description: "Target ref (branch/tag) to compare to (required)", - Value: redant.StringOf(&toRef), - }, - { - Flag: "output", - Description: "Output file path (default: changelog.md)", - Value: redant.StringOf(&outputFile), - }, - { - Flag: "breaking", - Description: "Include breaking change indicators", - Value: redant.BoolOf(&includeBreaking), - }, - { - Flag: "refs", - Description: "Include commit references in changelog", - Value: redant.BoolOf(&includeRefs), - }, - { - Flag: "author", - Description: "Include authors section in changelog", - Value: redant.BoolOf(&includeAuthor), - }, - { - Flag: "style", - Description: "Changelog grouping style: conventional|keepachangelog (default: conventional)", - Value: redant.StringOf(&style), - }, - { - Flag: "keep-extra", - Description: "Keep extra sections when using keepachangelog (e.g., Chores/Build/CI/Tests)", - Value: redant.BoolOf(&keepExtra), - }, + return &redant.Command{ + Use: "init", + Short: "初始化 .version/changelog 模板", + Options: redant.OptionSet{ + {Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)}, + {Flag: "version", Description: "初始化版本号(默认 v0.1.0)", Value: redant.StringOf(&version), Default: defaultInitialVersion}, + {Flag: "force", Description: "覆盖已有模板文件", Value: redant.BoolOf(&force), Default: "false"}, }, - Handler: func(ctx context.Context, i *redant.Invocation) error { - // Set defaults - if outputFile == "" { - outputFile = "changelog.md" - } - if style == "" { - style = "conventional" - } - - // Auto resolve refs if not provided - if toRef == "" { - toRef = "HEAD" - log.Info().Str("ref", toRef).Msg("Auto-set target ref") - } - if fromRef == "" { - fromRef = getLastTag(ctx) - if fromRef == "" { - fromRef = getRootCommit(ctx) - } - if fromRef != "" { - log.Info().Str("ref", fromRef).Msg("Auto-set source ref") - } - } - - // Validate inputs - if fromRef == "" || toRef == "" { - log.Error().Msg("Both --from and --to refs must be specified or auto-detected") - return nil - } - - // Check if refs exist - if !refExists(ctx, fromRef) { - log.Error().Str("ref", fromRef).Msg("Source ref does not exist") - return nil - } - if !refExists(ctx, toRef) { - log.Error().Str("ref", toRef).Msg("Target ref does not exist") - return nil + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + repoRoot, err := resolveRepoRoot(strings.TrimSpace(repoPath)) + if err != nil { + return err } - // Generate changelog - changelog, err := generateChangelog(ctx, fromRef, toRef, style, keepExtra) + result, err := ensureChangelogScaffold(repoRoot, scaffoldOptions{ + Version: strings.TrimSpace(version), + Force: force, + CreateVersionIfMissing: true, + }) if err != nil { - log.Err(err).Msg("Failed to generate changelog") return err } - // Format and output changelog - content := formatChangelog(changelog, fromRef, toRef, includeBreaking, includeRefs, includeAuthor) - - if outputFile != "stdout" && outputFile != "" { - err = os.WriteFile(outputFile, []byte(content), 0644) - if err != nil { - log.Err(err).Str("file", outputFile).Msg("Failed to write changelog file") - return err - } - fmt.Printf("Changelog written to %s\n", outputFile) - } else { - fmt.Println(content) + for _, file := range result.Created { + _, _ = fmt.Fprintf(inv.Stdout, "created: %s\n", file) + } + for _, file := range result.Updated { + _, _ = fmt.Fprintf(inv.Stdout, "updated: %s\n", file) } + if len(result.Created) == 0 && len(result.Updated) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "changelog scaffold already up to date") + } + _, _ = fmt.Fprintf(inv.Stdout, "repo: %s\n", repoRoot) return nil }, } - - return app -} - -// refExists checks if a Git ref exists -func refExists(ctx context.Context, ref string) bool { - cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", ref) - err := cmd.Run() - return err == nil -} - -// generateChangelog generates changelog between two refs -func generateChangelog(ctx context.Context, fromRef, toRef, style string, keepExtra bool) (*Changelog, error) { - // Get commit differences between refs - commits, err := getCommitsBetweenRefs(ctx, fromRef, toRef) - if err != nil { - return nil, err - } - - // Parse commit messages to extract conventional commit information - var changelogEntries []ChangelogEntry - for _, commit := range commits { - entry := parseCommitMessage(commit) - changelogEntries = append(changelogEntries, entry) - } - - // Organize commits into sections - sections := organizeCommitsByStyle(changelogEntries, style, keepExtra) - startDate, endDate := computeRangeDates(changelogEntries) - - commitURL, issueURL, prURL := detectRepoURLs(ctx) - - return &Changelog{ - Version: fmt.Sprintf("%s...%s", fromRef, toRef), - Date: time.Now(), - StartDate: startDate, - EndDate: endDate, - Sections: sections, - Commits: changelogEntries, - CommitURL: commitURL, - IssueURL: issueURL, - PRURL: prURL, - }, nil } -// getCommitsBetweenRefs gets commits between two refs -func getCommitsBetweenRefs(ctx context.Context, fromRef, toRef string) ([]CommitRecord, error) { - // Use git log to get commits between refs in reverse chronological order - // Use non-printable separators to avoid conflicts with subject/body content - format := "%H%x1f%ad%x1f%an%x1f%ae%x1f%s%x1f%b%x1e" - cmd := exec.CommandContext(ctx, "git", "log", "--reverse", "--pretty=format:"+format, "--date=iso", fmt.Sprintf("%s..%s", fromRef, toRef)) - output, err := cmd.Output() - if err != nil { - return nil, err - } - - var result []CommitRecord - records := strings.Split(string(output), "\x1e") - for _, record := range records { - record = strings.TrimSpace(record) - if record == "" { - continue - } - fields := strings.Split(record, "\x1f") - if len(fields) < 5 { - continue - } - rec := CommitRecord{ - Hash: strings.TrimSpace(fields[0]), - Date: strings.TrimSpace(fields[1]), - Author: strings.TrimSpace(fields[2]), - AuthorEmail: strings.TrimSpace(fields[3]), - Subject: strings.TrimSpace(fields[4]), - } - if len(fields) > 5 { - rec.Body = strings.TrimSpace(strings.Join(fields[5:], "\x1f")) - } - result = append(result, rec) - } - - return result, nil -} - -// parseCommitMessage parses a commit message according to conventional commits specification -func parseCommitMessage(commit CommitRecord) ChangelogEntry { - hash := commit.Hash - dateStr := commit.Date - author := commit.Author - authorEmail := commit.AuthorEmail - subject := commit.Subject - body := commit.Body - - // Parse date - date, err := time.Parse("2006-01-02 15:04:05 -0700", dateStr) - if err != nil { - date = time.Now() - } - - // Detect PR merge or squash patterns - prNumber, prTitle, isPR := detectPR(subject, body) - - // Parse conventional commit format (type(scope): subject) - var commitType, scope string - var isBreaking bool - var refs []string - - re := regexp.MustCompile(`^([a-zA-Z][a-zA-Z0-9_/-]*)(?:\(([^)]+)\))?(!)?:\s*(.*)$`) - matches := re.FindStringSubmatch(subject) - - if len(matches) > 0 { - commitType = strings.ToLower(matches[1]) - scope = matches[2] - subject = matches[4] - - // Check if this is a breaking change (has ! in the header) - if matches[3] == "!" { - isBreaking = true - } - } else { - // Heuristic fallback for non-conventional commits - commitType = classifyFallback(subject, body) - } - - // Look for breaking changes in body - if strings.Contains(strings.ToLower(body), "breaking change") || - strings.Contains(strings.ToLower(body), "breaking-change") { - isBreaking = true - } - - // Extract issue references - refs = extractRefs(subject + " " + body) - - return ChangelogEntry{ - Hash: hash, - Date: date, - Author: author, - AuthorEmail: authorEmail, - Subject: subject, - Body: body, - Type: commitType, - Scope: scope, - Breaking: isBreaking, - Refs: refs, - IsPRMerge: isPR, - PRNumber: prNumber, - PRTitle: prTitle, - } -} - -// extractRefs extracts issue references from commit message -func extractRefs(message string) []string { - re := regexp.MustCompile(`#(\d+)`) - matches := re.FindAllStringSubmatch(message, -1) - - var refs []string - for _, match := range matches { - if len(match) > 1 { - refs = append(refs, match[1]) - } - } - - return refs -} - -// organizeCommitsByType organizes commits by type for changelog sections -func organizeCommitsByType(commits []ChangelogEntry) []ChangelogSection { - sectionsMap := make(map[string][]ChangelogEntry) - var prItems []ChangelogEntry - - // Define section order - sectionOrder := []string{"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert", "other"} - - for _, commit := range commits { - if commit.IsPRMerge { - prItems = append(prItems, commit) - continue - } - sectionType := commit.Type - if sectionType == "" { - sectionType = "other" - } - sectionsMap[sectionType] = append(sectionsMap[sectionType], commit) - } - - var sections []ChangelogSection - if len(prItems) > 0 { - sections = append(sections, ChangelogSection{ - Title: "Merged Pull Requests", - Items: prItems, - }) - } +func newDraftCommand() *redant.Command { + var ( + repoPath string + baseRef string + printPrompt bool + cliPath string + logLevel string + githubToken string + useLoggedInUser bool + model string + reasoningEffort string + streaming bool + autoUserAnswer string + ) + + return &redant.Command{ + Use: "draft", + Short: "使用 Copilot 根据当前改动更新 Unreleased.md", + Options: redant.OptionSet{ + {Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)}, + {Flag: "base", Description: "diff 基线(默认自动探测)", Value: redant.StringOf(&baseRef)}, + {Flag: "print-prompt", Description: "只打印最终 prompt,不调用 Copilot", Value: redant.BoolOf(&printPrompt), Default: "false"}, + {Flag: "copilot-cli-path", Description: "Copilot CLI 可执行路径(可选)", Value: redant.StringOf(&cliPath)}, + {Flag: "copilot-log-level", Description: "Copilot CLI 日志级别", Value: redant.StringOf(&logLevel), Default: "error"}, + {Flag: "copilot-token", Description: "GitHub Token(可选)", Value: redant.StringOf(&githubToken), Envs: []string{"GITHUB_TOKEN"}}, + {Flag: "copilot-use-logged-in-user", Description: "是否使用已登录用户身份", Value: redant.BoolOf(&useLoggedInUser), Default: "true"}, + {Flag: "model", Description: "会话模型", Value: redant.StringOf(&model), Default: "gpt-5"}, + {Flag: "reasoning-effort", Description: "推理强度(low/medium/high/xhigh)", Value: redant.StringOf(&reasoningEffort), Default: "medium"}, + {Flag: "stream", Description: "启用流式输出", Value: redant.BoolOf(&streaming), Default: "false"}, + {Flag: "auto-user-answer", Description: "ask_user 触发时自动回答内容", Value: redant.StringOf(&autoUserAnswer), Default: "继续执行"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + repoRoot, err := resolveExistingGitRepo(strings.TrimSpace(repoPath)) + if err != nil { + return err + } - for _, sectionType := range sectionOrder { - if commits, exists := sectionsMap[sectionType]; exists && len(commits) > 0 { - title := getSectionTitle(sectionType) - sections = append(sections, ChangelogSection{ - Title: title, - Items: commits, + result, err := ensureChangelogScaffold(repoRoot, scaffoldOptions{ + Version: defaultInitialVersion, + CreateVersionIfMissing: true, }) - } - } - - // Add any remaining types not in the predefined order - for _, commit := range commits { - if commit.IsPRMerge { - continue - } - alreadyAdded := false - for _, section := range sections { - if strings.EqualFold(section.Title, getSectionTitle(commit.Type)) { - alreadyAdded = true - break + if err != nil { + return err } - } - if !alreadyAdded { - title := getSectionTitle(commit.Type) - var items []ChangelogEntry - for _, c := range commits { - if c.Type == commit.Type && !c.IsPRMerge { - items = append(items, c) - } + for _, file := range result.Created { + _, _ = fmt.Fprintf(inv.Stdout, "created: %s\n", file) } - sections = append(sections, ChangelogSection{ - Title: title, - Items: items, - }) - } - } - return sections -} + prompt, detectedBase, err := buildDraftPrompt(ctx, repoRoot, strings.TrimSpace(baseRef)) + if err != nil { + return err + } + _, _ = fmt.Fprintf(inv.Stdout, "repo: %s\nbase: %s\n", repoRoot, detectedBase) + + if printPrompt { + _, _ = fmt.Fprintln(inv.Stdout, prompt) + return nil + } -// getSectionTitle gets the human-readable title for a commit type -func getSectionTitle(commitType string) string { - switch strings.ToLower(commitType) { - case "feat": - return "Features" - case "fix": - return "Bug Fixes" - case "docs": - return "Documentation" - case "style": - return "Styles" - case "refactor": - return "Code Refactoring" - case "perf": - return "Performance Improvements" - case "test": - return "Tests" - case "build": - return "Build System" - case "ci": - return "Continuous Integration" - case "chore": - return "Chores" - case "revert": - return "Reverts" - default: - return strings.ToTitle(commitType) + return runDraftWithCopilot(ctx, inv, prompt, draftCopilotOptions{ + CLIPath: strings.TrimSpace(cliPath), + LogLevel: strings.TrimSpace(logLevel), + WorkingDir: repoRoot, + GitHubToken: strings.TrimSpace(githubToken), + UseLoggedInUser: useLoggedInUser, + Model: strings.TrimSpace(model), + ReasoningEffort: strings.TrimSpace(reasoningEffort), + Streaming: streaming, + AutoUserAnswer: strings.TrimSpace(autoUserAnswer), + }) + }, } } -// formatChangelog formats the changelog as markdown -func formatChangelog(changelog *Changelog, fromRef, toRef string, includeBreaking, includeRefs, includeAuthor bool) string { - var result strings.Builder - - result.WriteString("# Changelog\n\n") - result.WriteString(fmt.Sprintf("Changelog from `%s` (%s) to `%s` (%s) (Generated on %s)\n\n", - fromRef, - formatRangeTime(changelog.StartDate), - toRef, - formatRangeTime(changelog.EndDate), - changelog.Date.Format("2006-01-02"), - )) - - for _, section := range changelog.Sections { - result.WriteString(fmt.Sprintf("## %s\n\n", section.Title)) - - for _, item := range section.Items { - linePrefix := "- " - if includeBreaking && item.Breaking { - linePrefix += "⚠️ " +func newReleaseCommand() *redant.Command { + var ( + repoPath string + version string + nextVersion string + bump string + dryRun bool + ) + + return &redant.Command{ + Use: "release", + Short: "将 Unreleased.md 落版为版本文件并重建模板", + Options: redant.OptionSet{ + {Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)}, + {Flag: "version", Description: "发布版本号(默认读取 .version/VERSION)", Value: redant.StringOf(&version)}, + {Flag: "next-version", Description: "发布后写回 .version/VERSION 的下一个版本号", Value: redant.StringOf(&nextVersion)}, + {Flag: "bump", Description: "自动计算下一个版本号(patch|minor|major)", Value: redant.StringOf(&bump)}, + {Flag: "dry-run", Description: "仅预览将要改动的文件,不写入磁盘", Value: redant.BoolOf(&dryRun), Default: "false"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + repoRoot, err := resolveRepoRoot(strings.TrimSpace(repoPath)) + if err != nil { + return err } - lineBody := buildEntryTitle(item, changelog.PRURL) - result.WriteString(linePrefix + lineBody) - - if includeRefs && len(item.Refs) > 0 { - var refLinks []string - for _, ref := range item.Refs { - refLinks = append(refLinks, formatIssueLink(ref, changelog.IssueURL)) - } - result.WriteString(fmt.Sprintf(" (%s)", strings.Join(refLinks, ", "))) + result, err := releaseChangelog(repoRoot, releaseOptions{ + Version: strings.TrimSpace(version), + NextVersion: strings.TrimSpace(nextVersion), + Bump: strings.TrimSpace(bump), + DryRun: dryRun, + }) + if err != nil { + return err } - result.WriteString(fmt.Sprintf(" (%s)\n", formatCommitLink(item.Hash, changelog.CommitURL))) - } - - result.WriteString("\n") - } - - if includeAuthor { - authors := collectAuthors(changelog.Commits) - if len(authors) > 0 { - result.WriteString("## Authors\n\n") - for _, author := range authors { - if author.Email != "" { - result.WriteString(fmt.Sprintf("- %s <%s>\n", author.Name, author.Email)) - } else { - result.WriteString(fmt.Sprintf("- %s\n", author.Name)) + for _, file := range result.CreatedFiles { + prefix := "created" + if dryRun { + prefix = "would create" } + _, _ = fmt.Fprintf(inv.Stdout, "%s: %s\n", prefix, file) } - result.WriteString("\n") - } - } - - return result.String() -} - -func buildEntryTitle(item ChangelogEntry, prURL string) string { - if item.IsPRMerge { - title := strings.TrimSpace(item.PRTitle) - if title == "" { - title = strings.TrimSpace(item.Subject) - } - if item.PRNumber != "" { - prDisplay := fmt.Sprintf("#%s", item.PRNumber) - if prURL != "" { - prDisplay = fmt.Sprintf("[#%s](%s)", item.PRNumber, fmt.Sprintf(prURL, item.PRNumber)) + for _, file := range result.UpdatedFiles { + prefix := "updated" + if dryRun { + prefix = "would update" + } + _, _ = fmt.Fprintf(inv.Stdout, "%s: %s\n", prefix, file) } - if title != "" { - return fmt.Sprintf("%s %s", prDisplay, title) + if result.NextVersion != "" { + _, _ = fmt.Fprintf(inv.Stdout, "next version: %s\n", result.NextVersion) } - return prDisplay - } - if title != "" { - return title - } - return "(unnamed pull request)" - } - - if item.Scope != "" { - return fmt.Sprintf("**%s**: %s", item.Scope, item.Subject) - } - return item.Subject -} - -func formatCommitLink(hash, commitURL string) string { - short := shortHash(hash) - if commitURL == "" || hash == "" { - return short - } - return fmt.Sprintf("[%s](%s)", short, fmt.Sprintf(commitURL, hash)) -} - -func formatIssueLink(ref, issueURL string) string { - if issueURL == "" { - return fmt.Sprintf("#%s", ref) - } - return fmt.Sprintf("[#%s](%s)", ref, fmt.Sprintf(issueURL, ref)) -} - -func shortHash(hash string) string { - if len(hash) >= 7 { - return hash[:7] - } - if hash == "" { - return "unknown" - } - return hash -} - -func detectPR(subject, body string) (string, string, bool) { - subject = strings.TrimSpace(subject) - body = strings.TrimSpace(body) - - mergeRe := regexp.MustCompile(`(?i)^merge (pull request|pr) #(\d+)`) - if matches := mergeRe.FindStringSubmatch(subject); len(matches) > 0 { - title := firstNonEmptyLine(body) - return matches[2], title, true - } - - squashRe := regexp.MustCompile(`\(#(\d+)\)\s*$`) - if matches := squashRe.FindStringSubmatch(subject); len(matches) > 0 { - title := strings.TrimSpace(squashRe.ReplaceAllString(subject, "")) - return matches[1], title, true - } - - prRefRe := regexp.MustCompile(`(?i)\b(?:pull request|pr)\s*#(\d+)\b`) - if matches := prRefRe.FindStringSubmatch(subject + " " + body); len(matches) > 0 { - title := strings.TrimSpace(subject) - return matches[1], title, true - } - - return "", "", false -} - -func firstNonEmptyLine(text string) string { - for _, line := range strings.Split(text, "\n") { - line = strings.TrimSpace(line) - if line != "" { - return line - } - } - return "" -} - -func classifyFallback(subject, body string) string { - text := strings.ToLower(subject + " " + body) - switch { - case containsAny(text, "fix", "bug", "hotfix", "patch"): - return "fix" - case containsAny(text, "feat", "feature", "add", "新增", "增加"): - return "feat" - case containsAny(text, "security", "cve", "vuln"): - return "security" - case containsAny(text, "deprecate", "deprecated"): - return "deprecated" - case containsAny(text, "doc", "docs", "readme", "文档"): - return "docs" - case containsAny(text, "refactor", "clean", "重构"): - return "refactor" - case containsAny(text, "perf", "opt", "optimize", "performance", "性能"): - return "perf" - case containsAny(text, "test", "tests", "unit", "e2e"): - return "test" - case containsAny(text, "build", "deps", "dependency"): - return "build" - case containsAny(text, "ci", "pipeline", "workflow"): - return "ci" - case containsAny(text, "chore", "misc"): - return "chore" - case containsAny(text, "style", "format", "lint"): - return "style" - case containsAny(text, "revert", "回滚"): - return "revert" - default: - return "other" - } -} - -func organizeCommitsByStyle(commits []ChangelogEntry, style string, keepExtra bool) []ChangelogSection { - style = strings.ToLower(strings.TrimSpace(style)) - switch style { - case "keepachangelog", "keep-a-changelog", "keep": - return organizeCommitsKeepAChangelog(commits, keepExtra) - case "conventional", "default", "": - return organizeCommitsByType(commits) - default: - log.Warn().Str("style", style).Msg("Unknown changelog style, falling back to conventional") - return organizeCommitsByType(commits) - } -} - -func organizeCommitsKeepAChangelog(commits []ChangelogEntry, keepExtra bool) []ChangelogSection { - sectionsMap := make(map[string][]ChangelogEntry) - var prItems []ChangelogEntry - - sectionOrder := buildKeepAChangelogOrder(keepExtra) - - for _, commit := range commits { - if commit.IsPRMerge { - prItems = append(prItems, commit) - continue - } - section := mapTypeToKeepSection(commit, keepExtra) - sectionsMap[section] = append(sectionsMap[section], commit) - } - - var sections []ChangelogSection - if len(prItems) > 0 { - sections = append(sections, ChangelogSection{ - Title: "Merged Pull Requests", - Items: prItems, - }) - } - - for _, section := range sectionOrder { - if section == "Merged Pull Requests" { - continue - } - if items, exists := sectionsMap[section]; exists && len(items) > 0 { - sections = append(sections, ChangelogSection{ - Title: section, - Items: items, - }) - } - } - - return sections -} - -func mapTypeToKeepSection(commit ChangelogEntry, keepExtra bool) string { - switch strings.ToLower(commit.Type) { - case "feat": - return "Added" - case "fix": - return "Fixed" - case "docs": - return "Documentation" - case "deprecated", "deprecate": - return "Deprecated" - case "revert", "remove", "removed": - return "Removed" - case "security": - return "Security" - case "refactor": - if keepExtra { - return "Code Refactoring" - } - return "Changed" - case "perf": - if keepExtra { - return "Performance Improvements" - } - return "Changed" - case "style": - if keepExtra { - return "Styles" - } - return "Changed" - case "build": - if keepExtra { - return "Build System" - } - return "Changed" - case "ci": - if keepExtra { - return "Continuous Integration" - } - return "Changed" - case "test": - if keepExtra { - return "Tests" - } - return "Changed" - case "chore": - if keepExtra { - return "Chores" - } - return "Changed" - default: - return "Other" - } -} - -func buildKeepAChangelogOrder(keepExtra bool) []string { - base := []string{ - "Merged Pull Requests", - "Added", - "Changed", - "Fixed", - "Deprecated", - "Removed", - "Security", - "Documentation", - } - if keepExtra { - return append(base, []string{ - "Code Refactoring", - "Performance Improvements", - "Styles", - "Build System", - "Continuous Integration", - "Tests", - "Chores", - "Other", - }...) - } - return append(base, "Other") -} - -type AuthorInfo struct { - Name string - Email string -} - -func collectAuthors(entries []ChangelogEntry) []AuthorInfo { - seen := make(map[string]struct{}) - var authors []AuthorInfo - for _, entry := range entries { - name := strings.TrimSpace(entry.Author) - email := strings.TrimSpace(entry.AuthorEmail) - if name == "" && email == "" { - continue - } - key := strings.ToLower(name + "|" + email) - if _, exists := seen[key]; exists { - continue - } - seen[key] = struct{}{} - if name == "" { - name = email - } - authors = append(authors, AuthorInfo{Name: name, Email: email}) - } - return authors -} - -func computeRangeDates(entries []ChangelogEntry) (time.Time, time.Time) { - if len(entries) == 0 { - now := time.Now() - return now, now - } - start := entries[0].Date - end := entries[0].Date - for _, entry := range entries { - if entry.Date.Before(start) { - start = entry.Date - } - if entry.Date.After(end) { - end = entry.Date - } - } - return start, end -} - -func formatRangeTime(t time.Time) string { - if t.IsZero() { - return "unknown" - } - return t.Format("2006-01-02 15:04:05") -} - -func getLastTag(ctx context.Context) string { - cmd := exec.CommandContext(ctx, "git", "describe", "--tags", "--abbrev=0") - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} - -func getRootCommit(ctx context.Context) string { - cmd := exec.CommandContext(ctx, "git", "rev-list", "--max-parents=0", "HEAD") - output, err := cmd.Output() - if err != nil { - return "" - } - lines := strings.Split(strings.TrimSpace(string(output)), "\n") - if len(lines) == 0 { - return "" - } - return strings.TrimSpace(lines[0]) -} - -func containsAny(text string, keywords ...string) bool { - for _, keyword := range keywords { - if strings.Contains(text, keyword) { - return true - } - } - return false -} - -func detectRepoURLs(ctx context.Context) (string, string, string) { - remote := getGitRemote(ctx) - if remote == "" { - return "", "", "" - } - - host, owner, repo := parseRemoteURL(remote) - if host == "" || owner == "" || repo == "" { - return "", "", "" - } - - if strings.Contains(strings.ToLower(host), "gitlab") { - return fmt.Sprintf("https://%s/%s/%s/-/commit/%%s", host, owner, repo), - fmt.Sprintf("https://%s/%s/%s/-/issues/%%s", host, owner, repo), - fmt.Sprintf("https://%s/%s/%s/-/merge_requests/%%s", host, owner, repo) - } - - return fmt.Sprintf("https://%s/%s/%s/commit/%%s", host, owner, repo), - fmt.Sprintf("https://%s/%s/%s/issues/%%s", host, owner, repo), - fmt.Sprintf("https://%s/%s/%s/pull/%%s", host, owner, repo) -} - -func getGitRemote(ctx context.Context) string { - cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url") - output, err := cmd.Output() - if err != nil { - return "" - } - return strings.TrimSpace(string(output)) -} - -func parseRemoteURL(remote string) (string, string, string) { - remote = strings.TrimSpace(remote) - if remote == "" { - return "", "", "" - } - - sshRe := regexp.MustCompile(`^git@([^:]+):(.+)$`) - if matches := sshRe.FindStringSubmatch(remote); len(matches) > 0 { - host := matches[1] - path := strings.TrimPrefix(matches[2], "/") - owner, repo := splitOwnerRepo(path) - return host, owner, repo - } - - parsed, err := url.Parse(remote) - if err != nil { - return "", "", "" - } - path := strings.Trim(parsed.Path, "/") - owner, repo := splitOwnerRepo(path) - return parsed.Hostname(), owner, repo -} - -func splitOwnerRepo(path string) (string, string) { - parts := strings.Split(path, "/") - if len(parts) < 2 { - return "", "" + return nil + }, } - owner := parts[0] - repo := strings.TrimSuffix(parts[1], ".git") - return owner, repo } diff --git a/cmds/chglogcmd/cmd_test.go b/cmds/chglogcmd/cmd_test.go new file mode 100644 index 0000000..22258c9 --- /dev/null +++ b/cmds/chglogcmd/cmd_test.go @@ -0,0 +1,155 @@ +package chglogcmd + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureChangelogScaffoldCreatesTemplates(t *testing.T) { + repo := t.TempDir() + + result, err := ensureChangelogScaffold(repo, scaffoldOptions{ + Version: "v1.2.3", + CreateVersionIfMissing: true, + }) + if err != nil { + t.Fatalf("ensureChangelogScaffold() error = %v", err) + } + if len(result.Created) == 0 { + t.Fatalf("expected scaffold to create files") + } + + paths := buildPaths(repo) + assertFileContains(t, paths.VersionFile, "v1.2.3") + assertFileContains(t, paths.UnreleasedFile, "# [Unreleased]") + assertFileContains(t, paths.ReadmeFile, "Changelog 索引") + assertFileContains(t, paths.ReadmeFile, "Unreleased.md") + assertFileContains(t, paths.ChangelogPromptFile, "name: changelog") + assertFileContains(t, paths.ChangelogRulesFile, "applyTo: \".version/changelog/*.md\"") + assertFileContains(t, paths.ReleaseRulesFile, "发布前核对规则") +} + +func TestReleaseChangelogCreatesVersionFileAndResetsTemplate(t *testing.T) { + repo := t.TempDir() + paths := buildPaths(repo) + if _, err := ensureChangelogScaffold(repo, scaffoldOptions{Version: "v0.4.0", CreateVersionIfMissing: true}); err != nil { + t.Fatalf("ensureChangelogScaffold() error = %v", err) + } + + unreleased := `# [Unreleased] + +## 新增 + +- 新增 changelog draft 子命令 + +## 修复 + +暂无 + +## 变更 + +- 调整 release 工作流 + +## 文档 + +- 补充使用说明 +` + if err := os.WriteFile(paths.UnreleasedFile, []byte(unreleased), 0o644); err != nil { + t.Fatalf("write unreleased: %v", err) + } + + result, err := releaseChangelog(repo, releaseOptions{Version: "v0.4.0", Bump: "minor"}) + if err != nil { + t.Fatalf("releaseChangelog() error = %v", err) + } + if result.NextVersion != "v0.5.0" { + t.Fatalf("expected next version v0.5.0, got %s", result.NextVersion) + } + if len(result.CreatedFiles) != 1 || result.CreatedFiles[0] != filepath.Join(paths.ChangelogDir, "v0.4.0.md") { + t.Fatalf("expected created release file, got %+v", result.CreatedFiles) + } + + releaseFile := filepath.Join(paths.ChangelogDir, "v0.4.0.md") + assertFileContains(t, releaseFile, "# [v0.4.0] - ") + assertFileContains(t, releaseFile, "- 新增 changelog draft 子命令") + assertFileContains(t, paths.UnreleasedFile, "# [Unreleased]") + assertFileContains(t, paths.UnreleasedFile, "暂无") + assertFileContains(t, paths.ReadmeFile, "[`v0.4.0.md`](v0.4.0.md)") + assertFileContains(t, paths.VersionFile, "v0.5.0") +} + +func TestReleaseChangelogRejectsEmptyUnreleased(t *testing.T) { + repo := t.TempDir() + if _, err := ensureChangelogScaffold(repo, scaffoldOptions{Version: "v0.4.0", CreateVersionIfMissing: true}); err != nil { + t.Fatalf("ensureChangelogScaffold() error = %v", err) + } + + _, err := releaseChangelog(repo, releaseOptions{Version: "v0.4.0"}) + if err == nil { + t.Fatalf("expected empty unreleased to be rejected") + } + if !strings.Contains(err.Error(), "没有可发布的变更条目") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBuildDraftPromptIncludesBaseAndDiff(t *testing.T) { + repo := t.TempDir() + runGit(t, repo, "init") + runGit(t, repo, "config", "user.email", "test@example.com") + runGit(t, repo, "config", "user.name", "tester") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("hello\n"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } + runGit(t, repo, "add", ".") + runGit(t, repo, "commit", "-m", "feat: init") + runGit(t, repo, "branch", "-M", "main") + + if _, err := ensureChangelogScaffold(repo, scaffoldOptions{Version: "v0.1.0", CreateVersionIfMissing: true}); err != nil { + t.Fatalf("ensureChangelogScaffold() error = %v", err) + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatalf("rewrite README: %v", err) + } + + prompt, base, err := buildDraftPrompt(context.Background(), repo, "main") + if err != nil { + t.Fatalf("buildDraftPrompt() error = %v", err) + } + if base != "main" { + t.Fatalf("expected base main, got %s", base) + } + if !strings.Contains(prompt, "git diff main --stat") { + t.Fatalf("prompt missing diff stat heading: %s", prompt) + } + if !strings.Contains(prompt, "README.md") { + t.Fatalf("prompt missing changed file list: %s", prompt) + } + if !strings.Contains(prompt, "只允许修改 .version/changelog/Unreleased.md") { + t.Fatalf("prompt missing target-file restriction: %s", prompt) + } +} + +func assertFileContains(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !strings.Contains(string(content), want) { + t.Fatalf("expected %s to contain %q, got:\n%s", path, want, string(content)) + } +} + +func runGit(t *testing.T, repo string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", repo}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, string(out)) + } +} diff --git a/cmds/chglogcmd/draft.go b/cmds/chglogcmd/draft.go new file mode 100644 index 0000000..981c9e6 --- /dev/null +++ b/cmds/chglogcmd/draft.go @@ -0,0 +1,210 @@ +package chglogcmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + copilot "github.com/github/copilot-sdk/go" + "github.com/pubgo/redant" +) + +type draftCopilotOptions struct { + CLIPath string + LogLevel string + WorkingDir string + GitHubToken string + UseLoggedInUser bool + Model string + ReasoningEffort string + Streaming bool + AutoUserAnswer string +} + +func buildDraftPrompt(ctx context.Context, repoRoot, requestedBase string) (string, string, error) { + paths := buildPaths(repoRoot) + baseRef, err := detectBaseRef(ctx, repoRoot, requestedBase) + if err != nil { + return "", "", err + } + + versionContent, err := os.ReadFile(paths.VersionFile) + if err != nil { + return "", "", fmt.Errorf("read VERSION: %w", err) + } + unreleasedContent, err := os.ReadFile(paths.UnreleasedFile) + if err != nil { + return "", "", fmt.Errorf("read Unreleased.md: %w", err) + } + readmeContent, err := os.ReadFile(paths.ReadmeFile) + if err != nil { + return "", "", fmt.Errorf("read changelog README.md: %w", err) + } + + diffStat, err := gitOutput(ctx, repoRoot, "diff", baseRef, "--stat") + if err != nil { + return "", "", err + } + diffNames, err := gitOutput(ctx, repoRoot, "diff", baseRef, "--name-only") + if err != nil { + return "", "", err + } + + prompt := renderDraftPrompt(draftPromptData{ + RepoRoot: repoRoot, + BaseRef: baseRef, + Version: strings.TrimSpace(string(versionContent)), + UnreleasedContent: strings.TrimSpace(string(unreleasedContent)), + ReadmeContent: strings.TrimSpace(string(readmeContent)), + DiffStat: emptyAsNone(diffStat), + DiffNames: emptyAsNone(diffNames), + }) + + return prompt + "\n", baseRef, nil +} + +func detectBaseRef(ctx context.Context, repoRoot, requested string) (string, error) { + requested = strings.TrimSpace(requested) + if requested != "" { + if _, err := gitOutput(ctx, repoRoot, "rev-parse", "--verify", requested); err != nil { + return "", fmt.Errorf("base ref %q not found: %w", requested, err) + } + return requested, nil + } + + candidates := make([]string, 0, 5) + if output, err := gitOutput(ctx, repoRoot, "rev-parse", "--abbrev-ref", "origin/HEAD"); err == nil { + branch := strings.TrimSpace(output) + if branch != "" && branch != "origin/HEAD" { + candidates = append(candidates, branch) + } + } + candidates = append(candidates, "origin/main", "main", "origin/master", "master") + + seen := map[string]struct{}{} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + if _, ok := seen[candidate]; ok { + continue + } + seen[candidate] = struct{}{} + if _, err := gitOutput(ctx, repoRoot, "rev-parse", "--verify", candidate); err == nil { + return candidate, nil + } + } + + return "", fmt.Errorf("unable to detect base ref for repo %s", repoRoot) +} + +func gitOutput(ctx context.Context, repoRoot string, args ...string) (string, error) { + fullArgs := append([]string{"-C", repoRoot}, args...) + cmd := exec.CommandContext(ctx, "git", fullArgs...) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return strings.TrimSpace(string(output)), nil +} + +func emptyAsNone(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "(no changes)" + } + return text +} + +func runDraftWithCopilot(ctx context.Context, inv *redant.Invocation, prompt string, opts draftCopilotOptions) error { + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: strings.TrimSpace(opts.CLIPath), + LogLevel: defaultString(strings.TrimSpace(opts.LogLevel), "error"), + Cwd: strings.TrimSpace(opts.WorkingDir), + GitHubToken: strings.TrimSpace(opts.GitHubToken), + UseLoggedInUser: copilot.Bool(opts.UseLoggedInUser), + AutoStart: copilot.Bool(false), + }) + + if err := client.Start(ctx); err != nil { + return fmt.Errorf("start copilot client: %w", err) + } + defer func() { _ = client.Stop() }() + + session, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: defaultString(strings.TrimSpace(opts.Model), "gpt-5"), + ReasoningEffort: defaultString(strings.TrimSpace(opts.ReasoningEffort), "medium"), + WorkingDirectory: strings.TrimSpace(opts.WorkingDir), + Streaming: opts.Streaming, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnUserInputRequest: func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + _, _ = fmt.Fprintf(inv.Stdout, "[ask_user] session=%s question=%s\n", invocation.SessionID, request.Question) + answer := defaultString(strings.TrimSpace(opts.AutoUserAnswer), "继续执行") + return copilot.UserInputResponse{Answer: answer, WasFreeform: true}, nil + }, + }) + if err != nil { + return fmt.Errorf("create copilot session: %w", err) + } + defer func() { _ = session.Disconnect() }() + + _, _ = fmt.Fprintf(inv.Stdout, "session=%s\n", session.SessionID) + + done := make(chan struct{}, 1) + errCh := make(chan error, 1) + unsub := session.On(func(event copilot.SessionEvent) { + switch event.Type { + case "assistant.message_delta", "assistant.reasoning_delta": + if opts.Streaming && event.Data.DeltaContent != nil { + _, _ = fmt.Fprint(inv.Stdout, *event.Data.DeltaContent) + } + case "assistant.message": + if event.Data.Content != nil { + if opts.Streaming { + _, _ = fmt.Fprintln(inv.Stdout) + } + _, _ = fmt.Fprintf(inv.Stdout, "assistant: %s\n", *event.Data.Content) + } + case "session.error": + if event.Data.Message != nil { + select { + case errCh <- fmt.Errorf("session error: %s", *event.Data.Message): + default: + } + } + case "session.idle": + select { + case done <- struct{}{}: + default: + } + } + }) + defer unsub() + + if _, err := session.Send(ctx, copilot.MessageOptions{Prompt: prompt}); err != nil { + return fmt.Errorf("send draft prompt: %w", err) + } + + waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + select { + case <-done: + _, _ = fmt.Fprintln(inv.Stdout, "draft completed: .version/changelog/Unreleased.md") + return nil + case err := <-errCh: + return err + case <-waitCtx.Done(): + return fmt.Errorf("wait session idle: %w", waitCtx.Err()) + } +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} diff --git a/cmds/chglogcmd/files.go b/cmds/chglogcmd/files.go new file mode 100644 index 0000000..0198db1 --- /dev/null +++ b/cmds/chglogcmd/files.go @@ -0,0 +1,494 @@ +package chglogcmd + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + semver "github.com/hashicorp/go-version" +) + +const defaultInitialVersion = "v0.1.0" + +var standardSections = []string{"新增", "修复", "变更", "文档"} + +type changelogPaths struct { + RepoRoot string + VersionDir string + VersionFile string + ChangelogDir string + UnreleasedFile string + ReadmeFile string + GitHubDir string + PromptsDir string + InstructionsDir string + ChangelogPromptFile string + ChangelogRulesFile string + ReleaseRulesFile string +} + +type scaffoldOptions struct { + Version string + Force bool + CreateVersionIfMissing bool +} + +type scaffoldResult struct { + Created []string + Updated []string +} + +type releaseOptions struct { + Version string + NextVersion string + Bump string + DryRun bool +} + +type releaseResult struct { + CreatedFiles []string + UpdatedFiles []string + NextVersion string +} + +func resolveRepoRoot(input string) (string, error) { + path := strings.TrimSpace(input) + if path == "" { + var err error + path, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + } + path, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve repo path: %w", err) + } + return path, nil +} + +func resolveExistingGitRepo(input string) (string, error) { + repoRoot, err := resolveRepoRoot(input) + if err != nil { + return "", err + } + + cmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--show-toplevel") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("resolve git repository(%s): %w", repoRoot, err) + } + return strings.TrimSpace(string(output)), nil +} + +func buildPaths(repoRoot string) changelogPaths { + versionDir := filepath.Join(repoRoot, ".version") + changelogDir := filepath.Join(versionDir, "changelog") + return changelogPaths{ + RepoRoot: repoRoot, + VersionDir: versionDir, + VersionFile: filepath.Join(versionDir, "VERSION"), + ChangelogDir: changelogDir, + UnreleasedFile: filepath.Join(changelogDir, "Unreleased.md"), + ReadmeFile: filepath.Join(changelogDir, "README.md"), + GitHubDir: filepath.Join(repoRoot, ".github"), + PromptsDir: filepath.Join(repoRoot, ".github", "prompts"), + InstructionsDir: filepath.Join(repoRoot, ".github", "instructions"), + ChangelogPromptFile: filepath.Join(repoRoot, ".github", "prompts", "changelog.prompt.md"), + ChangelogRulesFile: filepath.Join(repoRoot, ".github", "instructions", "changelog.instructions.md"), + ReleaseRulesFile: filepath.Join(repoRoot, ".github", "instructions", "release.instructions.md"), + } +} + +func ensureChangelogScaffold(repoRoot string, opts scaffoldOptions) (scaffoldResult, error) { + paths := buildPaths(repoRoot) + version := strings.TrimSpace(opts.Version) + if version == "" { + version = defaultInitialVersion + } + if err := validateVersion(version); err != nil { + return scaffoldResult{}, err + } + + if err := os.MkdirAll(paths.ChangelogDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create changelog directory: %w", err) + } + if err := os.MkdirAll(paths.PromptsDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create prompts directory: %w", err) + } + if err := os.MkdirAll(paths.InstructionsDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create instructions directory: %w", err) + } + + result := scaffoldResult{} + + if !fileExists(paths.VersionFile) && opts.CreateVersionIfMissing { + if err := os.MkdirAll(paths.VersionDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create version directory: %w", err) + } + if err := os.WriteFile(paths.VersionFile, []byte(version+"\n"), 0o644); err != nil { + return scaffoldResult{}, fmt.Errorf("write VERSION: %w", err) + } + result.Created = append(result.Created, paths.VersionFile) + } else if fileExists(paths.VersionFile) && opts.Force { + if err := os.WriteFile(paths.VersionFile, []byte(version+"\n"), 0o644); err != nil { + return scaffoldResult{}, fmt.Errorf("rewrite VERSION: %w", err) + } + result.Updated = append(result.Updated, paths.VersionFile) + } + + state, err := writeManagedFile(paths.UnreleasedFile, renderUnreleasedTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.UnreleasedFile, state) + + readmeContent, err := renderChangelogReadme(paths.ChangelogDir) + if err != nil { + return scaffoldResult{}, err + } + state, err = writeManagedFile(paths.ReadmeFile, readmeContent, opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.ReadmeFile, state) + + state, err = writeManagedFile(paths.ChangelogPromptFile, renderRepoChangelogPromptTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.ChangelogPromptFile, state) + + state, err = writeManagedFile(paths.ChangelogRulesFile, renderRepoChangelogRulesTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.ChangelogRulesFile, state) + + state, err = writeManagedFile(paths.ReleaseRulesFile, renderRepoReleaseRulesTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.ReleaseRulesFile, state) + + return result, nil +} + +func recordScaffoldState(result *scaffoldResult, path, state string) { + if result == nil { + return + } + switch state { + case "created": + result.Created = append(result.Created, path) + case "updated": + result.Updated = append(result.Updated, path) + } +} + +func writeManagedFile(path, content string, force bool) (string, error) { + if fileExists(path) && !force { + return "skipped", nil + } + state := "created" + if fileExists(path) { + state = "updated" + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return state, nil +} + +func releaseChangelog(repoRoot string, opts releaseOptions) (releaseResult, error) { + paths := buildPaths(repoRoot) + if _, err := ensureChangelogScaffold(repoRoot, scaffoldOptions{Version: defaultInitialVersion, CreateVersionIfMissing: true}); err != nil { + return releaseResult{}, err + } + + currentVersion := strings.TrimSpace(opts.Version) + if currentVersion == "" { + content, err := os.ReadFile(paths.VersionFile) + if err != nil { + return releaseResult{}, fmt.Errorf("read VERSION: %w", err) + } + currentVersion = strings.TrimSpace(string(content)) + } + if err := validateVersion(currentVersion); err != nil { + return releaseResult{}, err + } + + targetFile := filepath.Join(paths.ChangelogDir, currentVersion+".md") + if fileExists(targetFile) { + return releaseResult{}, fmt.Errorf("release file already exists: %s", targetFile) + } + + unreleasedContent, err := os.ReadFile(paths.UnreleasedFile) + if err != nil { + return releaseResult{}, fmt.Errorf("read Unreleased.md: %w", err) + } + + sections := parseStandardSections(string(unreleasedContent)) + if !hasMeaningfulEntries(sections) { + return releaseResult{}, errors.New("Unreleased.md 中没有可发布的变更条目") + } + releaseContent := renderReleaseContent(currentVersion, time.Now(), sections) + nextVersion, err := resolveNextVersion(currentVersion, strings.TrimSpace(opts.NextVersion), strings.TrimSpace(opts.Bump)) + if err != nil { + return releaseResult{}, err + } + + created := []string{targetFile} + updated := []string{paths.UnreleasedFile, paths.ReadmeFile} + if nextVersion != "" { + updated = append(updated, paths.VersionFile) + } + + if opts.DryRun { + return releaseResult{CreatedFiles: created, UpdatedFiles: updated, NextVersion: nextVersion}, nil + } + + if err := os.WriteFile(targetFile, []byte(releaseContent), 0o644); err != nil { + return releaseResult{}, fmt.Errorf("write release file: %w", err) + } + if err := os.WriteFile(paths.UnreleasedFile, []byte(renderUnreleasedTemplate()), 0o644); err != nil { + return releaseResult{}, fmt.Errorf("reset Unreleased.md: %w", err) + } + + readmeContent, err := renderChangelogReadme(paths.ChangelogDir) + if err != nil { + return releaseResult{}, err + } + if err := os.WriteFile(paths.ReadmeFile, []byte(readmeContent), 0o644); err != nil { + return releaseResult{}, fmt.Errorf("update README.md: %w", err) + } + + if nextVersion != "" { + if err := os.WriteFile(paths.VersionFile, []byte(nextVersion+"\n"), 0o644); err != nil { + return releaseResult{}, fmt.Errorf("update VERSION: %w", err) + } + } + + return releaseResult{CreatedFiles: created, UpdatedFiles: updated, NextVersion: nextVersion}, nil +} + +func renderUnreleasedTemplate() string { + return strings.TrimSpace(` +# [Unreleased] + +> 推荐维护方式:`+"`fastgit changelog draft|release`"+` + +## 新增 + +暂无 + +## 修复 + +暂无 + +## 变更 + +暂无 + +## 文档 + +暂无 +`) + "\n" +} + +func renderReleaseContent(version string, now time.Time, sections map[string]string) string { + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("# [%s] - %s\n\n", version, now.Format("2006-01-02"))) + for _, title := range standardSections { + body := normalizeSectionBody(sections[title]) + buf.WriteString("## " + title + "\n\n") + buf.WriteString(body) + buf.WriteString("\n\n") + } + return buf.String() +} + +func parseStandardSections(content string) map[string]string { + sections := make(map[string]string, len(standardSections)) + for _, title := range standardSections { + sections[title] = "暂无" + } + + current := "" + var buf bytes.Buffer + flush := func() { + if current == "" { + buf.Reset() + return + } + sections[current] = normalizeSectionBody(buf.String()) + buf.Reset() + } + + for _, rawLine := range strings.Split(content, "\n") { + line := strings.TrimSpace(rawLine) + if strings.HasPrefix(line, "## ") { + flush() + title := strings.TrimSpace(strings.TrimPrefix(line, "## ")) + if containsString(standardSections, title) { + current = title + } else { + current = "" + } + continue + } + if current == "" { + continue + } + buf.WriteString(rawLine) + buf.WriteString("\n") + } + flush() + return sections +} + +func normalizeSectionBody(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "暂无" + } + if body == "暂无" { + return body + } + return body +} + +func hasMeaningfulEntries(sections map[string]string) bool { + for _, title := range standardSections { + body := normalizeSectionBody(sections[title]) + if strings.TrimSpace(body) != "" && strings.TrimSpace(body) != "暂无" { + return true + } + } + return false +} + +func renderChangelogReadme(changelogDir string) (string, error) { + versions, err := listReleaseVersions(changelogDir) + if err != nil { + return "", err + } + + var buf bytes.Buffer + buf.WriteString("# Changelog 索引\n\n") + buf.WriteString("本目录保存项目变更记录,采用“一个版本一个文件”的方式维护。\n\n") + buf.WriteString("## 文件约定\n\n") + buf.WriteString("- `Unreleased.md`:当前开发中变更(待发布)。\n") + buf.WriteString("- `vX.Y.Z.md`:已发布版本变更(例如 `v0.0.5.md`)。\n\n") + buf.WriteString("## 当前版本文件\n\n") + buf.WriteString("- [`Unreleased.md`](Unreleased.md)\n") + for _, version := range versions { + buf.WriteString(fmt.Sprintf("- [`%s.md`](%s.md)\n", version, version)) + } + buf.WriteString("\n## 维护约定\n\n") + buf.WriteString("- 分类保持:`新增` / `修复` / `变更` / `文档`。\n") + buf.WriteString("- 发布时将 `Unreleased.md` 内容迁移到新版本文件,并重建空模板。\n") + buf.WriteString("- 历史版本文件只做勘误,不改写语义与顺序。\n") + return buf.String(), nil +} + +func listReleaseVersions(changelogDir string) ([]string, error) { + entries, err := os.ReadDir(changelogDir) + if err != nil { + return nil, fmt.Errorf("read changelog directory: %w", err) + } + + versions := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "v") || !strings.HasSuffix(name, ".md") || name == "Unreleased.md" { + continue + } + versions = append(versions, strings.TrimSuffix(name, ".md")) + } + + sort.SliceStable(versions, func(i, j int) bool { + vi, errI := semver.NewVersion(versions[i]) + vj, errJ := semver.NewVersion(versions[j]) + if errI != nil || errJ != nil { + return versions[i] > versions[j] + } + return vi.GreaterThan(vj) + }) + + return versions, nil +} + +func resolveNextVersion(current, explicit, bump string) (string, error) { + if strings.TrimSpace(explicit) != "" && strings.TrimSpace(bump) != "" { + return "", errors.New("--next-version 与 --bump 只能二选一") + } + if strings.TrimSpace(explicit) != "" { + if err := validateVersion(explicit); err != nil { + return "", err + } + return strings.TrimSpace(explicit), nil + } + if strings.TrimSpace(bump) == "" { + return "", nil + } + + v, err := semver.NewVersion(current) + if err != nil { + return "", fmt.Errorf("parse current version %q: %w", current, err) + } + segments := v.Segments() + if len(segments) < 3 { + return "", fmt.Errorf("invalid version: %s", current) + } + + switch strings.ToLower(strings.TrimSpace(bump)) { + case "patch": + segments[2]++ + case "minor": + segments[1]++ + segments[2] = 0 + case "major": + segments[0]++ + segments[1] = 0 + segments[2] = 0 + default: + return "", fmt.Errorf("unsupported bump level: %s", bump) + } + + return fmt.Sprintf("v%d.%d.%d", segments[0], segments[1], segments[2]), nil +} + +func validateVersion(version string) error { + version = strings.TrimSpace(version) + if version == "" { + return errors.New("version cannot be empty") + } + if _, err := semver.NewVersion(version); err != nil { + return fmt.Errorf("invalid version %q: %w", version, err) + } + return nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func containsString(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false +} diff --git a/cmds/copilotcmd/cmd.go b/cmds/copilotcmd/cmd.go new file mode 100644 index 0000000..220502e --- /dev/null +++ b/cmds/copilotcmd/cmd.go @@ -0,0 +1,1793 @@ +package copilotcmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "sync" + "time" + + copilot "github.com/github/copilot-sdk/go" + agentlineapp "github.com/pubgo/fastgit/cmds/agentlineapp" + agentlinemodule "github.com/pubgo/fastgit/pkg/agentline" + skillsmodule "github.com/pubgo/fastgit/pkg/skills" + "github.com/pubgo/redant" +) + +var rt = newRuntime() + +func New() *redant.Command { + var ( + cliPath string + logLevel string + workingDir string + githubToken string + useLoggedInUser bool + + model string + reasoningEffort string + streaming bool + autoUserAnswer string + + configDir string + sessionWorkingDir string + profileName string + profileFile string + systemMessageMode string + systemMessage string + systemSectionsJSON string + skillDirs []string + disabledSkills []string + availableTools []string + excludedTools []string + mcpServersJSON string + customAgentsJSON string + agentName string + customToolsJSON string + enableDemoEchoTool bool + enableSkillsTool bool + enableInfiniteSession bool + + prompt string + sessionID string + pingMsg string + + hydrateSessions bool + hydrateTimeout string + hydrateMaxEvents int64 + ) + + rootCmd := &redant.Command{ + Use: "copilot", + Short: "通过 fastgit 使用 Copilot CLI 能力", + Metadata: agentlinemodule.AgentEntryMetadata(), + Options: redant.OptionSet{ + {Flag: "copilot-cli-path", Description: "Copilot CLI 可执行路径(可选)", Value: redant.StringOf(&cliPath)}, + {Flag: "copilot-log-level", Description: "Copilot CLI 日志级别", Value: redant.StringOf(&logLevel), Default: "error"}, + {Flag: "copilot-cwd", Description: "Copilot CLI 工作目录", Value: redant.StringOf(&workingDir)}, + {Flag: "copilot-token", Description: "GitHub Token(可选)", Value: redant.StringOf(&githubToken), Envs: []string{"GITHUB_TOKEN"}}, + {Flag: "copilot-use-logged-in-user", Description: "是否使用已登录用户身份", Value: redant.BoolOf(&useLoggedInUser), Default: "true"}, + {Flag: "model", Description: "会话模型", Value: redant.StringOf(&model), Default: "gpt-5"}, + {Flag: "reasoning-effort", Description: "推理强度(low/medium/high/xhigh)", Value: redant.StringOf(&reasoningEffort)}, + {Flag: "stream", Description: "启用流式输出", Value: redant.BoolOf(&streaming), Default: "false"}, + {Flag: "auto-user-answer", Description: "ask_user 触发时自动回答内容", Value: redant.StringOf(&autoUserAnswer), Default: "继续执行"}, + {Flag: "config-dir", Description: "覆盖 Copilot Session 的配置目录", Value: redant.StringOf(&configDir)}, + {Flag: "working-directory", Description: "会话工作目录(工具执行根目录)", Value: redant.StringOf(&sessionWorkingDir)}, + {Flag: "profile", Description: "使用的配置 profile 名称(从 profile-file 读取)", Value: redant.StringOf(&profileName)}, + {Flag: "profile-file", Description: "profile 配置文件路径(JSON)", Value: redant.StringOf(&profileFile), Default: ".copilot/profiles.json"}, + {Flag: "system-message-mode", Description: "系统提示词模式(append|replace|customize)", Value: redant.StringOf(&systemMessageMode), Default: "append"}, + {Flag: "system-message", Description: "系统提示词内容", Value: redant.StringOf(&systemMessage)}, + {Flag: "system-sections-json", Description: "customize 模式 section 覆盖 JSON", Value: redant.StringOf(&systemSectionsJSON)}, + {Flag: "skill-dirs", Description: "技能目录列表,可重复传入", Value: redant.StringArrayOf(&skillDirs)}, + {Flag: "disabled-skills", Description: "禁用的技能名列表,可重复传入", Value: redant.StringArrayOf(&disabledSkills)}, + {Flag: "available-tools", Description: "允许工具白名单,可重复传入", Value: redant.StringArrayOf(&availableTools)}, + {Flag: "excluded-tools", Description: "禁用工具黑名单,可重复传入", Value: redant.StringArrayOf(&excludedTools)}, + {Flag: "mcp-servers-json", Description: "MCP servers JSON(map)", Value: redant.StringOf(&mcpServersJSON)}, + {Flag: "custom-agents-json", Description: "自定义 agents JSON(array)", Value: redant.StringOf(&customAgentsJSON)}, + {Flag: "agent", Description: "激活的自定义 agent 名称", Value: redant.StringOf(&agentName)}, + {Flag: "custom-tools-json", Description: "自定义工具 JSON(array)", Value: redant.StringOf(&customToolsJSON)}, + {Flag: "enable-demo-echo-tool", Description: "启用内置 demo_echo 工具", Value: redant.BoolOf(&enableDemoEchoTool), Default: "false"}, + {Flag: "enable-skills-tool", Description: "启用内置 skills_tool function call", Value: redant.BoolOf(&enableSkillsTool), Default: "true"}, + {Flag: "enable-infinite-sessions", Description: "启用 Infinite Sessions", Value: redant.BoolOf(&enableInfiniteSession), Default: "true"}, + }, + } + + chatCmd := &redant.Command{ + Use: "chat", + Short: "创建或复用会话并发送 Prompt", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "prompt", Shorthand: "p", Description: "发送给 Copilot 的提示词", Value: redant.StringOf(&prompt), Required: true}, + {Flag: "session-id", Description: "指定会话 ID(可选)", Value: redant.StringOf(&sessionID)}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + resolved, err := resolveCopilotOptions(resolveCopilotInput{ + Model: model, + ReasoningEffort: reasoningEffort, + Streaming: streaming, + ConfigDir: configDir, + SessionWorkingDir: sessionWorkingDir, + ProfileName: profileName, + ProfileFile: profileFile, + SystemMessageMode: systemMessageMode, + SystemMessage: systemMessage, + SystemSectionsJSON: systemSectionsJSON, + SkillDirs: skillDirs, + DisabledSkills: disabledSkills, + AvailableTools: availableTools, + ExcludedTools: excludedTools, + MCPServersJSON: mcpServersJSON, + CustomAgentsJSON: customAgentsJSON, + AgentName: agentName, + CustomToolsJSON: customToolsJSON, + EnableDemoEchoTool: enableDemoEchoTool, + EnableSkillsTool: enableSkillsTool, + EnableInfiniteSession: enableInfiniteSession, + }) + if err != nil { + return err + } + + return withClient(ctx, inv, clientOptions{cliPath, logLevel, workingDir, githubToken, useLoggedInUser}, func(ctx context.Context, client *copilot.Client) error { + sid := strings.TrimSpace(sessionID) + if sid == "" { + s, err := client.CreateSession(ctx, &copilot.SessionConfig{ + Model: strings.TrimSpace(resolved.Model), + ReasoningEffort: strings.TrimSpace(resolved.ReasoningEffort), + ConfigDir: strings.TrimSpace(resolved.ConfigDir), + WorkingDirectory: strings.TrimSpace(resolved.SessionWorkingDir), + Tools: resolved.Advanced.Tools, + SystemMessage: resolved.Advanced.SystemMessage, + AvailableTools: resolved.Advanced.AvailableTools, + ExcludedTools: resolved.Advanced.ExcludedTools, + MCPServers: resolved.Advanced.MCPServers, + CustomAgents: resolved.Advanced.CustomAgents, + Agent: resolved.Advanced.Agent, + SkillDirectories: resolved.Advanced.SkillDirectories, + DisabledSkills: resolved.Advanced.DisabledSkills, + InfiniteSessions: resolved.Advanced.InfiniteSessions, + Streaming: resolved.Streaming, + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + OnUserInputRequest: buildUserInputHandler(inv, autoUserAnswer), + }) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + rt.StoreSession(s) + _, _ = fmt.Fprintf(inv.Stdout, "session created: %s\n", s.SessionID) + return sendPrompt(ctx, inv, s, strings.TrimSpace(prompt), streaming) + } + + s, err := ensureSession(ctx, client, sid, resumeOptions{ + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Streaming: resolved.Streaming, + ConfigDir: resolved.ConfigDir, + WorkingDirectory: resolved.SessionWorkingDir, + AutoUserAnswer: autoUserAnswer, + SystemMessage: resolved.Advanced.SystemMessage, + Tools: resolved.Advanced.Tools, + AvailableTools: resolved.Advanced.AvailableTools, + ExcludedTools: resolved.Advanced.ExcludedTools, + MCPServers: resolved.Advanced.MCPServers, + CustomAgents: resolved.Advanced.CustomAgents, + Agent: resolved.Advanced.Agent, + SkillDirectories: resolved.Advanced.SkillDirectories, + DisabledSkills: resolved.Advanced.DisabledSkills, + InfiniteSessions: resolved.Advanced.InfiniteSessions, + PermissionApproval: true, + }, inv) + if err != nil { + return err + } + return sendPrompt(ctx, inv, s, strings.TrimSpace(prompt), resolved.Streaming) + }) + }, + } + + resumeCmd := &redant.Command{ + Use: "resume", + Short: "恢复会话并继续对话", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "session-id", Description: "待恢复会话 ID", Value: redant.StringOf(&sessionID), Required: true}, + {Flag: "prompt", Shorthand: "p", Description: "继续发送的提示词", Value: redant.StringOf(&prompt), Default: "继续"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + resolved, err := resolveCopilotOptions(resolveCopilotInput{ + Model: model, + ReasoningEffort: reasoningEffort, + Streaming: streaming, + ConfigDir: configDir, + SessionWorkingDir: sessionWorkingDir, + ProfileName: profileName, + ProfileFile: profileFile, + SystemMessageMode: systemMessageMode, + SystemMessage: systemMessage, + SystemSectionsJSON: systemSectionsJSON, + SkillDirs: skillDirs, + DisabledSkills: disabledSkills, + AvailableTools: availableTools, + ExcludedTools: excludedTools, + MCPServersJSON: mcpServersJSON, + CustomAgentsJSON: customAgentsJSON, + AgentName: agentName, + CustomToolsJSON: customToolsJSON, + EnableDemoEchoTool: enableDemoEchoTool, + EnableSkillsTool: enableSkillsTool, + EnableInfiniteSession: enableInfiniteSession, + }) + if err != nil { + return err + } + + return withClient(ctx, inv, clientOptions{cliPath, logLevel, workingDir, githubToken, useLoggedInUser}, func(ctx context.Context, client *copilot.Client) error { + sid := strings.TrimSpace(sessionID) + s, err := ensureSession(ctx, client, sid, resumeOptions{ + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Streaming: resolved.Streaming, + ConfigDir: resolved.ConfigDir, + WorkingDirectory: resolved.SessionWorkingDir, + AutoUserAnswer: autoUserAnswer, + SystemMessage: resolved.Advanced.SystemMessage, + Tools: resolved.Advanced.Tools, + AvailableTools: resolved.Advanced.AvailableTools, + ExcludedTools: resolved.Advanced.ExcludedTools, + MCPServers: resolved.Advanced.MCPServers, + CustomAgents: resolved.Advanced.CustomAgents, + Agent: resolved.Advanced.Agent, + SkillDirectories: resolved.Advanced.SkillDirectories, + DisabledSkills: resolved.Advanced.DisabledSkills, + InfiniteSessions: resolved.Advanced.InfiniteSessions, + PermissionApproval: true, + }, inv) + if err != nil { + return err + } + return sendPrompt(ctx, inv, s, strings.TrimSpace(prompt), resolved.Streaming) + }) + }, + } + + sessionsCmd := &redant.Command{ + Use: "sessions", + Short: "列出 Copilot 会话", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "hydrate", Description: "尝试恢复会话并补全最近 assistant 摘要", Value: redant.BoolOf(&hydrateSessions), Default: "false"}, + {Flag: "hydrate-timeout", Description: "单会话补全超时", Value: redant.StringOf(&hydrateTimeout), Default: "4s"}, + {Flag: "hydrate-max-events", Description: "补全时最多扫描的最近事件数", Value: redant.Int64Of(&hydrateMaxEvents), Default: "50"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return withClient(ctx, inv, clientOptions{cliPath, logLevel, workingDir, githubToken, useLoggedInUser}, func(ctx context.Context, client *copilot.Client) error { + items, err := client.ListSessions(ctx, nil) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + if len(items) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "暂无会话") + return nil + } + + hydrateCfg := hydrateConfig{ + enabled: hydrateSessions, + timeout: parseDurationOrDefault(hydrateTimeout, 4*time.Second), + maxEvents: int(hydrateMaxEvents), + } + + onlyIDCount := 0 + hydratedCount := 0 + for _, s := range items { + info := hydrateSessionInfo{maxEvents: hydrateCfg.maxEvents} + if hydrateCfg.enabled { + hydratedCount++ + info = hydrateSession(ctx, client, strings.TrimSpace(s.SessionID), hydrateCfg) + } + + line, onlyID := renderSessionLine(s, info) + if onlyID { + onlyIDCount++ + } + _, _ = fmt.Fprintln(inv.Stdout, line) + } + + if onlyIDCount > 0 { + _, _ = fmt.Fprintf(inv.Stdout, "\n提示: %d/%d 条会话只返回 session id(上游 CLI 限制,不是解析错误)。\n", onlyIDCount, len(items)) + } + if hydrateCfg.enabled { + _, _ = fmt.Fprintf(inv.Stdout, "提示: hydrate 已尝试补全 %d 条会话(timeout=%s, maxEvents=%d)。\n", hydratedCount, hydrateCfg.timeout.String(), hydrateCfg.maxEvents) + } + return nil + }) + }, + } + + statusCmd := &redant.Command{ + Use: "status", + Short: "查看连接和认证状态", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{{Flag: "ping-message", Description: "Ping 消息", Value: redant.StringOf(&pingMsg), Default: "copilot ping"}}, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return withClient(ctx, inv, clientOptions{cliPath, logLevel, workingDir, githubToken, useLoggedInUser}, func(ctx context.Context, client *copilot.Client) error { + resp, err := client.Ping(ctx, strings.TrimSpace(pingMsg)) + if err != nil { + return fmt.Errorf("ping: %w", err) + } + _, _ = fmt.Fprintf(inv.Stdout, "ping: message=%q timestamp=%d\n", resp.Message, resp.Timestamp) + + if s, err := client.GetStatus(ctx); err == nil { + _, _ = fmt.Fprintf(inv.Stdout, "status: version=%s protocol=%d\n", s.Version, s.ProtocolVersion) + } + if a, err := client.GetAuthStatus(ctx); err == nil { + _, _ = fmt.Fprintf(inv.Stdout, "auth: isAuthenticated=%v\n", a.IsAuthenticated) + } + return nil + }) + }, + } + + modelsCmd := &redant.Command{ + Use: "models", + Short: "列出可用模型", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + return withClient(ctx, inv, clientOptions{cliPath, logLevel, workingDir, githubToken, useLoggedInUser}, func(ctx context.Context, client *copilot.Client) error { + models, err := client.ListModels(ctx) + if err != nil { + return fmt.Errorf("list models: %w", err) + } + for _, m := range models { + _, _ = fmt.Fprintf(inv.Stdout, "- %s (%s)\n", m.ID, m.Name) + } + return nil + }) + }, + } + + doctorCmd := &redant.Command{ + Use: "doctor", + Short: "诊断 Copilot 会话配置与依赖", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + resolved, err := resolveCopilotOptions(resolveCopilotInput{ + Model: model, + ReasoningEffort: reasoningEffort, + Streaming: streaming, + ConfigDir: configDir, + SessionWorkingDir: sessionWorkingDir, + ProfileName: profileName, + ProfileFile: profileFile, + SystemMessageMode: systemMessageMode, + SystemMessage: systemMessage, + SystemSectionsJSON: systemSectionsJSON, + SkillDirs: skillDirs, + DisabledSkills: disabledSkills, + AvailableTools: availableTools, + ExcludedTools: excludedTools, + MCPServersJSON: mcpServersJSON, + CustomAgentsJSON: customAgentsJSON, + AgentName: agentName, + CustomToolsJSON: customToolsJSON, + EnableDemoEchoTool: enableDemoEchoTool, + EnableSkillsTool: enableSkillsTool, + EnableInfiniteSession: enableInfiniteSession, + }) + if err != nil { + return err + } + + report := runDoctorChecks(doctorInput{ + GitHubToken: githubToken, + UseLoggedInUser: useLoggedInUser, + ProfileName: resolved.ProfileName, + ProfileFile: resolved.ProfileFile, + SkillDirs: resolved.Advanced.SkillDirectories, + DisabledSkills: resolved.Advanced.DisabledSkills, + AgentName: strings.TrimSpace(resolved.Advanced.Agent), + CustomAgentsJSON: resolved.CustomAgentsJSON, + MCPServersJSON: resolved.MCPServersJSON, + }) + + for _, line := range report.lines() { + _, _ = fmt.Fprintln(inv.Stdout, line) + } + + if report.hasError() { + return fmt.Errorf("doctor failed: %d error(s), %d warning(s)", report.errorCount(), report.warnCount()) + } + return nil + }, + } + + inspectCmd := &redant.Command{ + Use: "inspect", + Short: "查看当前会话将生效的配置摘要", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + resolved, err := resolveCopilotOptions(resolveCopilotInput{ + Model: model, + ReasoningEffort: reasoningEffort, + Streaming: streaming, + ConfigDir: configDir, + SessionWorkingDir: sessionWorkingDir, + ProfileName: profileName, + ProfileFile: profileFile, + SystemMessageMode: systemMessageMode, + SystemMessage: systemMessage, + SystemSectionsJSON: systemSectionsJSON, + SkillDirs: skillDirs, + DisabledSkills: disabledSkills, + AvailableTools: availableTools, + ExcludedTools: excludedTools, + MCPServersJSON: mcpServersJSON, + CustomAgentsJSON: customAgentsJSON, + AgentName: agentName, + CustomToolsJSON: customToolsJSON, + EnableDemoEchoTool: enableDemoEchoTool, + EnableSkillsTool: enableSkillsTool, + EnableInfiniteSession: enableInfiniteSession, + }) + if err != nil { + return err + } + + summary := buildInspectSummary(inspectInput{ + Model: resolved.Model, + ReasoningEffort: resolved.ReasoningEffort, + Streaming: resolved.Streaming, + ConfigDir: resolved.ConfigDir, + SessionWorkingDir: resolved.SessionWorkingDir, + UseLoggedInUser: useLoggedInUser, + HasGitHubToken: strings.TrimSpace(githubToken) != "", + EnableDemoEchoTool: resolved.EnableDemoEchoTool, + EnableInfiniteSesion: resolved.EnableInfiniteSession, + ProfileName: resolved.ProfileName, + ProfileFile: resolved.ProfileFile, + Advanced: resolved.Advanced, + CustomAgentsJSON: resolved.CustomAgentsJSON, + MCPServersJSON: resolved.MCPServersJSON, + }) + + payload, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Errorf("marshal inspect summary: %w", err) + } + _, _ = fmt.Fprintln(inv.Stdout, string(payload)) + return nil + }, + } + + interactiveDemoCmd := &redant.Command{ + Use: "interactive-demo", + Short: "演示命令与 agentline 的双向问答", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + bridge, ok := agentlineapp.InteractionFromInvocation(inv) + if !ok || bridge == nil { + _, _ = fmt.Fprintln(inv.Stdout, "interactive bridge 不可用:请在 copilot 交互模式中执行该命令。") + return nil + } + _ = bridge.Emit(ctx, agentlineapp.InteractionEvent{Kind: "assistant", Title: "interactive", Lines: []string{"请输入 /reply 或直接输入文本回车。"}}) + resp, err := bridge.Ask(ctx, agentlineapp.AskRequest{Prompt: "是否继续执行下一步?"}) + if err != nil { + return err + } + if resp.Cancelled { + _ = bridge.Emit(ctx, agentlineapp.InteractionEvent{Kind: "result", Title: "interactive", Lines: []string{"已取消"}}) + return nil + } + _ = bridge.Emit(ctx, agentlineapp.InteractionEvent{Kind: "result", Title: "interactive", Lines: []string{"收到回复: " + strings.TrimSpace(resp.Answer)}}) + return nil + }, + } + + skillsCmd := newSkillsCmd(&profileName, &profileFile, &skillDirs) + + rootCmd.Children = []*redant.Command{chatCmd, resumeCmd, sessionsCmd, statusCmd, modelsCmd, doctorCmd, inspectCmd, skillsCmd, interactiveDemoCmd} + rootCmd.Handler = func(ctx context.Context, inv *redant.Invocation) error { + defer rt.Close(inv.Stderr) + return agentlineapp.Run(ctx, rootCmd, &agentlineapp.RuntimeOptions{Prompt: "copilot> ", Stdin: inv.Stdin, Stdout: inv.Stdout}) + } + return rootCmd +} + +type advancedConfigInput struct { + SystemMessageMode string + SystemMessage string + SystemSectionsJSON string + SkillDirs []string + DisabledSkills []string + AvailableTools []string + ExcludedTools []string + MCPServersJSON string + CustomAgentsJSON string + AgentName string + CustomToolsJSON string + EnableDemoEchoTool bool + EnableSkillsTool bool + EnableInfiniteSession bool +} + +type advancedConfig struct { + SystemMessage *copilot.SystemMessageConfig + SkillDirectories []string + DisabledSkills []string + AvailableTools []string + ExcludedTools []string + MCPServers map[string]copilot.MCPServerConfig + CustomAgents []copilot.CustomAgentConfig + Agent string + Tools []copilot.Tool + InfiniteSessions *copilot.InfiniteSessionConfig +} + +type copilotProfile struct { + Model string `json:"model"` + ReasoningEffort string `json:"reasoningEffort"` + Streaming *bool `json:"streaming"` + ConfigDir string `json:"configDir"` + SessionWorkingDir string `json:"workingDirectory"` + SystemMessageMode string `json:"systemMessageMode"` + SystemMessage string `json:"systemMessage"` + SystemSectionsJSON string `json:"systemSectionsJSON"` + SkillDirs []string `json:"skillDirs"` + DisabledSkills []string `json:"disabledSkills"` + AvailableTools []string `json:"availableTools"` + ExcludedTools []string `json:"excludedTools"` + MCPServersJSON string `json:"mcpServersJSON"` + CustomAgentsJSON string `json:"customAgentsJSON"` + AgentName string `json:"agent"` + CustomToolsJSON string `json:"customToolsJSON"` + EnableDemoEchoTool *bool `json:"enableDemoEchoTool"` + EnableSkillsTool *bool `json:"enableSkillsTool"` + EnableInfiniteSession *bool `json:"enableInfiniteSession"` +} + +type copilotProfileFile struct { + Profiles map[string]copilotProfile `json:"profiles"` +} + +type resolveCopilotInput struct { + Model string + ReasoningEffort string + Streaming bool + ConfigDir string + SessionWorkingDir string + ProfileName string + ProfileFile string + SystemMessageMode string + SystemMessage string + SystemSectionsJSON string + SkillDirs []string + DisabledSkills []string + AvailableTools []string + ExcludedTools []string + MCPServersJSON string + CustomAgentsJSON string + AgentName string + CustomToolsJSON string + EnableDemoEchoTool bool + EnableSkillsTool bool + EnableInfiniteSession bool +} + +type resolvedCopilotOptions struct { + Model string + ReasoningEffort string + Streaming bool + ConfigDir string + SessionWorkingDir string + ProfileName string + ProfileFile string + CustomAgentsJSON string + MCPServersJSON string + EnableDemoEchoTool bool + EnableSkillsTool bool + EnableInfiniteSession bool + Advanced *advancedConfig +} + +func resolveCopilotOptions(in resolveCopilotInput) (*resolvedCopilotOptions, error) { + pf, err := loadCopilotProfile(strings.TrimSpace(in.ProfileFile), strings.TrimSpace(in.ProfileName)) + if err != nil { + return nil, err + } + + if pf != nil { + if strings.TrimSpace(in.Model) == "" || strings.TrimSpace(in.Model) == "gpt-5" { + if strings.TrimSpace(pf.Model) != "" { + in.Model = strings.TrimSpace(pf.Model) + } + } + if strings.TrimSpace(in.ReasoningEffort) == "" && strings.TrimSpace(pf.ReasoningEffort) != "" { + in.ReasoningEffort = strings.TrimSpace(pf.ReasoningEffort) + } + if strings.TrimSpace(in.ConfigDir) == "" && strings.TrimSpace(pf.ConfigDir) != "" { + in.ConfigDir = strings.TrimSpace(pf.ConfigDir) + } + if strings.TrimSpace(in.SessionWorkingDir) == "" && strings.TrimSpace(pf.SessionWorkingDir) != "" { + in.SessionWorkingDir = strings.TrimSpace(pf.SessionWorkingDir) + } + if (strings.TrimSpace(in.SystemMessageMode) == "" || strings.EqualFold(strings.TrimSpace(in.SystemMessageMode), "append")) && strings.TrimSpace(pf.SystemMessageMode) != "" { + in.SystemMessageMode = strings.TrimSpace(pf.SystemMessageMode) + } + if strings.TrimSpace(in.SystemMessage) == "" && strings.TrimSpace(pf.SystemMessage) != "" { + in.SystemMessage = strings.TrimSpace(pf.SystemMessage) + } + if strings.TrimSpace(in.SystemSectionsJSON) == "" && strings.TrimSpace(pf.SystemSectionsJSON) != "" { + in.SystemSectionsJSON = strings.TrimSpace(pf.SystemSectionsJSON) + } + if len(in.SkillDirs) == 0 && len(pf.SkillDirs) > 0 { + in.SkillDirs = append([]string(nil), pf.SkillDirs...) + } + if len(in.DisabledSkills) == 0 && len(pf.DisabledSkills) > 0 { + in.DisabledSkills = append([]string(nil), pf.DisabledSkills...) + } + if len(in.AvailableTools) == 0 && len(pf.AvailableTools) > 0 { + in.AvailableTools = append([]string(nil), pf.AvailableTools...) + } + if len(in.ExcludedTools) == 0 && len(pf.ExcludedTools) > 0 { + in.ExcludedTools = append([]string(nil), pf.ExcludedTools...) + } + if strings.TrimSpace(in.MCPServersJSON) == "" && strings.TrimSpace(pf.MCPServersJSON) != "" { + in.MCPServersJSON = strings.TrimSpace(pf.MCPServersJSON) + } + if strings.TrimSpace(in.CustomAgentsJSON) == "" && strings.TrimSpace(pf.CustomAgentsJSON) != "" { + in.CustomAgentsJSON = strings.TrimSpace(pf.CustomAgentsJSON) + } + if strings.TrimSpace(in.AgentName) == "" && strings.TrimSpace(pf.AgentName) != "" { + in.AgentName = strings.TrimSpace(pf.AgentName) + } + if strings.TrimSpace(in.CustomToolsJSON) == "" && strings.TrimSpace(pf.CustomToolsJSON) != "" { + in.CustomToolsJSON = strings.TrimSpace(pf.CustomToolsJSON) + } + if !in.Streaming && pf.Streaming != nil { + in.Streaming = *pf.Streaming + } + if !in.EnableDemoEchoTool && pf.EnableDemoEchoTool != nil { + in.EnableDemoEchoTool = *pf.EnableDemoEchoTool + } + if !in.EnableSkillsTool && pf.EnableSkillsTool != nil { + in.EnableSkillsTool = *pf.EnableSkillsTool + } + if in.EnableInfiniteSession && pf.EnableInfiniteSession != nil { + in.EnableInfiniteSession = *pf.EnableInfiniteSession + } + } + + adv, err := buildAdvancedConfig(advancedConfigInput{ + SystemMessageMode: in.SystemMessageMode, + SystemMessage: in.SystemMessage, + SystemSectionsJSON: in.SystemSectionsJSON, + SkillDirs: in.SkillDirs, + DisabledSkills: in.DisabledSkills, + AvailableTools: in.AvailableTools, + ExcludedTools: in.ExcludedTools, + MCPServersJSON: in.MCPServersJSON, + CustomAgentsJSON: in.CustomAgentsJSON, + AgentName: in.AgentName, + CustomToolsJSON: in.CustomToolsJSON, + EnableDemoEchoTool: in.EnableDemoEchoTool, + EnableSkillsTool: in.EnableSkillsTool, + EnableInfiniteSession: in.EnableInfiniteSession, + }) + if err != nil { + return nil, err + } + + return &resolvedCopilotOptions{ + Model: strings.TrimSpace(in.Model), + ReasoningEffort: strings.TrimSpace(in.ReasoningEffort), + Streaming: in.Streaming, + ConfigDir: strings.TrimSpace(in.ConfigDir), + SessionWorkingDir: strings.TrimSpace(in.SessionWorkingDir), + ProfileName: strings.TrimSpace(in.ProfileName), + ProfileFile: strings.TrimSpace(in.ProfileFile), + CustomAgentsJSON: strings.TrimSpace(in.CustomAgentsJSON), + MCPServersJSON: strings.TrimSpace(in.MCPServersJSON), + EnableDemoEchoTool: in.EnableDemoEchoTool, + EnableSkillsTool: in.EnableSkillsTool, + EnableInfiniteSession: in.EnableInfiniteSession, + Advanced: adv, + }, nil +} + +func loadCopilotProfile(profileFile, profileName string) (*copilotProfile, error) { + profileName = strings.TrimSpace(profileName) + if profileName == "" { + return nil, nil + } + profileFile = strings.TrimSpace(profileFile) + if profileFile == "" { + return nil, fmt.Errorf("--profile-file 不能为空") + } + + content, err := os.ReadFile(profileFile) + if err != nil { + return nil, fmt.Errorf("read profile file(%s): %w", profileFile, err) + } + + var cfg copilotProfileFile + if err := json.Unmarshal(content, &cfg); err != nil { + return nil, fmt.Errorf("invalid profile file json(%s): %w", profileFile, err) + } + if len(cfg.Profiles) == 0 { + return nil, fmt.Errorf("profile file(%s) 中未找到 profiles", profileFile) + } + + p, ok := cfg.Profiles[profileName] + if !ok { + names := make([]string, 0, len(cfg.Profiles)) + for name := range cfg.Profiles { + names = append(names, name) + } + sort.Strings(names) + return nil, fmt.Errorf("profile %q 不存在,available=%s", profileName, strings.Join(names, ",")) + } + return &p, nil +} + +func buildAdvancedConfig(in advancedConfigInput) (*advancedConfig, error) { + sm, err := buildSystemMessageConfig(in.SystemMessageMode, in.SystemMessage, in.SystemSectionsJSON) + if err != nil { + return nil, err + } + + mcp, err := parseMCPServers(in.MCPServersJSON) + if err != nil { + return nil, err + } + agents, err := parseCustomAgents(in.CustomAgentsJSON) + if err != nil { + return nil, err + } + tools, err := parseCustomTools(in.CustomToolsJSON) + if err != nil { + return nil, err + } + if in.EnableDemoEchoTool { + tools = append(tools, buildDemoEchoTool()) + } + if in.EnableSkillsTool { + tools = append(tools, buildSkillsTool(in.SkillDirs)) + } + + cfg := &advancedConfig{ + SystemMessage: sm, + SkillDirectories: compactStringSlice(in.SkillDirs), + DisabledSkills: compactStringSlice(in.DisabledSkills), + AvailableTools: compactStringSlice(in.AvailableTools), + ExcludedTools: compactStringSlice(in.ExcludedTools), + MCPServers: mcp, + CustomAgents: agents, + Agent: strings.TrimSpace(in.AgentName), + Tools: tools, + } + + if in.EnableInfiniteSession { + cfg.InfiniteSessions = &copilot.InfiniteSessionConfig{Enabled: copilot.Bool(true)} + } else { + cfg.InfiniteSessions = &copilot.InfiniteSessionConfig{Enabled: copilot.Bool(false)} + } + + return cfg, nil +} + +func buildSystemMessageConfig(mode, content, sectionsJSON string) (*copilot.SystemMessageConfig, error) { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + mode = "append" + } + + cfg := &copilot.SystemMessageConfig{Mode: mode, Content: strings.TrimSpace(content)} + if mode != "customize" { + if cfg.Content == "" { + return nil, nil + } + return cfg, nil + } + + if strings.TrimSpace(sectionsJSON) == "" && cfg.Content == "" { + return nil, nil + } + if strings.TrimSpace(sectionsJSON) != "" { + var sections map[string]copilot.SectionOverride + if err := json.Unmarshal([]byte(sectionsJSON), §ions); err != nil { + return nil, fmt.Errorf("invalid --system-sections-json: %w", err) + } + cfg.Sections = sections + } + return cfg, nil +} + +func parseMCPServers(raw string) (map[string]copilot.MCPServerConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var out map[string]copilot.MCPServerConfig + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, fmt.Errorf("invalid --mcp-servers-json: %w", err) + } + return out, nil +} + +func parseCustomAgents(raw string) ([]copilot.CustomAgentConfig, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + var out []copilot.CustomAgentConfig + if err := json.Unmarshal([]byte(raw), &out); err != nil { + return nil, fmt.Errorf("invalid --custom-agents-json: %w", err) + } + return out, nil +} + +type customToolSpec struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ResultText string `json:"resultText,omitempty"` + SkipPermission bool `json:"skipPermission,omitempty"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +func parseCustomTools(raw string) ([]copilot.Tool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + + var specs []customToolSpec + if err := json.Unmarshal([]byte(raw), &specs); err != nil { + return nil, fmt.Errorf("invalid --custom-tools-json: %w", err) + } + + tools := make([]copilot.Tool, 0, len(specs)) + for _, spec := range specs { + name := strings.TrimSpace(spec.Name) + if name == "" { + return nil, fmt.Errorf("custom tool name cannot be empty") + } + resultText := strings.TrimSpace(spec.ResultText) + if resultText == "" { + resultText = "ok" + } + parameters := spec.Parameters + if parameters == nil { + parameters = map[string]any{"type": "object", "properties": map[string]any{}} + } + desc := strings.TrimSpace(spec.Description) + if desc == "" { + desc = "custom tool: " + name + } + + currentName := name + currentResult := resultText + tools = append(tools, copilot.Tool{ + Name: currentName, + Description: desc, + Parameters: parameters, + SkipPermission: spec.SkipPermission, + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + return copilot.ToolResult{ + TextResultForLLM: currentResult, + ResultType: "success", + SessionLog: "tool(" + currentName + ") executed", + ToolTelemetry: map[string]any{ + "tool": currentName, + "session_id": invocation.SessionID, + }, + }, nil + }, + }) + } + return tools, nil +} + +func buildDemoEchoTool() copilot.Tool { + return copilot.Tool{ + Name: "demo_echo", + Description: "Echo input text for demo", + SkipPermission: true, + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{"type": "string", "description": "text to echo"}, + }, + "required": []string{"text"}, + }, + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + text := "(empty)" + if m, ok := invocation.Arguments.(map[string]any); ok { + if v, ok := m["text"]; ok { + t := strings.TrimSpace(fmt.Sprint(v)) + if t != "" { + text = t + } + } + } + return copilot.ToolResult{TextResultForLLM: text, ResultType: "success", SessionLog: "demo_echo executed"}, nil + }, + } +} + +func buildSkillsTool(skillDirs []string) copilot.Tool { + defaultDirs := compactStringSlice(skillDirs) + const schemaVersion = "skills_tool/v1" + return copilot.Tool{ + Name: "skills_tool", + Description: "Manage local skills with stable descriptor output: list/get/query", + SkipPermission: true, + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{"type": "string", "enum": []string{"list", "get", "query"}, "description": "tool action"}, + "name": map[string]any{"type": "string", "description": "skill name for get/query"}, + "h2": map[string]any{"type": "string", "description": "h2 title for query"}, + "h3": map[string]any{"type": "string", "description": "h3 title for query"}, + "dirs": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "override skill dirs", + }, + }, + "required": []string{"action"}, + }, + Handler: func(invocation copilot.ToolInvocation) (copilot.ToolResult, error) { + svc := skillsmodule.NewLocalManager() + action := "" + name := "" + h2 := "" + h3 := "" + dirs := append([]string(nil), defaultDirs...) + + if m, ok := invocation.Arguments.(map[string]any); ok { + action = strings.ToLower(strings.TrimSpace(fmt.Sprint(m["action"]))) + name = strings.TrimSpace(fmt.Sprint(m["name"])) + h2 = strings.TrimSpace(fmt.Sprint(m["h2"])) + h3 = strings.TrimSpace(fmt.Sprint(m["h3"])) + if rawDirs, ok := m["dirs"].([]any); ok && len(rawDirs) > 0 { + override := make([]string, 0, len(rawDirs)) + for _, d := range rawDirs { + override = append(override, strings.TrimSpace(fmt.Sprint(d))) + } + dirs = svc.CompactStringSlice(override) + } + } + + if len(dirs) == 0 { + dirs = svc.ExistingDirs([]string{"./skills", "./.copilot/skills"}) + } + + entries, warns := svc.Discover(dirs) + response := map[string]any{"schemaVersion": schemaVersion, "action": action, "dirs": dirs, "warnings": warns} + + switch action { + case "list": + descriptors := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + descriptors = append(descriptors, buildSkillDescriptor(entry, false)) + } + response["skills"] = descriptors + response["skillsLegacy"] = entries + case "get": + if name == "" { + return errorToolResult("skills_tool get requires name"), nil + } + target, err := svc.FindByName(entries, name) + if err != nil { + return errorToolResult(err.Error()), nil + } + content, err := svc.ReadSkill(target.Path) + if err != nil { + return errorToolResult(err.Error()), nil + } + response["skill"] = buildSkillDescriptor(target, true) + response["skillLegacy"] = target + response["content"] = content + case "query": + if name == "" || h2 == "" { + return errorToolResult("skills_tool query requires name and h2"), nil + } + target, err := svc.FindByName(entries, name) + if err != nil { + return errorToolResult(err.Error()), nil + } + content, err := svc.ReadSkill(target.Path) + if err != nil { + return errorToolResult(err.Error()), nil + } + parsed, err := svc.ParseContent(content, target.Name) + if err != nil { + return errorToolResult(err.Error()), nil + } + headings := []string{h2} + if strings.TrimSpace(h3) != "" { + headings = append(headings, h3) + } + sectionText, ok := svc.FindSectionContent(parsed.Sections, headings...) + response["skill"] = buildSkillDescriptor(target, true) + response["skillLegacy"] = target + response["headings"] = headings + response["found"] = ok + response["sectionContent"] = sectionText + default: + return errorToolResult("unsupported action, use list|get|query"), nil + } + + payload, err := json.Marshal(response) + if err != nil { + return errorToolResult(err.Error()), nil + } + return copilot.ToolResult{TextResultForLLM: string(payload), ResultType: "success", SessionLog: "skills_tool executed"}, nil + }, + } +} + +func buildSkillDescriptor(entry skillsmodule.Entry, includeSections bool) map[string]any { + kind := strings.TrimSpace(entry.Kind) + if kind == "" { + kind = "local" + } + slug := strings.TrimSpace(entry.Slug) + if slug == "" { + slug = strings.TrimSpace(entry.Name) + } + displayName := strings.TrimSpace(entry.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(entry.Title) + } + if displayName == "" { + displayName = strings.TrimSpace(entry.Name) + } + summary := strings.TrimSpace(entry.Summary) + if summary == "" { + summary = strings.TrimSpace(entry.Description) + } + + d := map[string]any{ + "id": strings.TrimSpace(entry.ID), + "kind": kind, + "namespace": strings.TrimSpace(entry.Namespace), + "name": strings.TrimSpace(entry.Name), + "slug": slug, + "displayName": displayName, + "summary": summary, + "description": strings.TrimSpace(entry.Description), + "version": strings.TrimSpace(entry.Version), + "tags": entry.Tags, + "useWhen": entry.UseWhen, + "tools": entry.Tools, + "path": strings.TrimSpace(entry.Path), + "dir": strings.TrimSpace(entry.Dir), + "source": strings.TrimSpace(entry.Source), + "headings": map[string]any{ + "h2": entry.H2, + "h3": entry.H3, + }, + "metadata": entry.Metadata, + } + if includeSections { + d["sections"] = entry.Sections + } + return d +} + +func errorToolResult(msg string) copilot.ToolResult { + msg = strings.TrimSpace(msg) + if msg == "" { + msg = "unknown error" + } + return copilot.ToolResult{TextResultForLLM: msg, ResultType: "error", SessionLog: "tool error: " + msg} +} + +func compactStringSlice(in []string) []string { + out := make([]string, 0, len(in)) + for _, item := range in { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + +type resumeOptions struct { + Model string + ReasoningEffort string + Streaming bool + ConfigDir string + WorkingDirectory string + AutoUserAnswer string + SystemMessage *copilot.SystemMessageConfig + Tools []copilot.Tool + AvailableTools []string + ExcludedTools []string + MCPServers map[string]copilot.MCPServerConfig + CustomAgents []copilot.CustomAgentConfig + Agent string + SkillDirectories []string + DisabledSkills []string + InfiniteSessions *copilot.InfiniteSessionConfig + PermissionApproval bool +} + +func ensureSession(ctx context.Context, client *copilot.Client, sid string, opts resumeOptions, inv *redant.Invocation) (*copilot.Session, error) { + if cached, ok := rt.GetSession(sid); ok { + _, _ = fmt.Fprintf(inv.Stdout, "reuse cached session: %s\n", sid) + return cached, nil + } + + resumeCfg := &copilot.ResumeSessionConfig{ + Model: strings.TrimSpace(opts.Model), + ReasoningEffort: strings.TrimSpace(opts.ReasoningEffort), + Streaming: opts.Streaming, + ConfigDir: strings.TrimSpace(opts.ConfigDir), + WorkingDirectory: strings.TrimSpace(opts.WorkingDirectory), + SystemMessage: opts.SystemMessage, + Tools: opts.Tools, + AvailableTools: opts.AvailableTools, + ExcludedTools: opts.ExcludedTools, + MCPServers: opts.MCPServers, + CustomAgents: opts.CustomAgents, + Agent: strings.TrimSpace(opts.Agent), + SkillDirectories: opts.SkillDirectories, + DisabledSkills: opts.DisabledSkills, + InfiniteSessions: opts.InfiniteSessions, + OnUserInputRequest: buildUserInputHandler( + inv, + opts.AutoUserAnswer, + ), + } + if opts.PermissionApproval { + resumeCfg.OnPermissionRequest = copilot.PermissionHandler.ApproveAll + } + + s, err := client.ResumeSession(ctx, strings.TrimSpace(sid), resumeCfg) + if err != nil { + return nil, fmt.Errorf("resume session: %w", err) + } + rt.StoreSession(s) + _, _ = fmt.Fprintf(inv.Stdout, "session resumed: %s\n", sid) + return s, nil +} + +func sendPrompt(ctx context.Context, inv *redant.Invocation, session *copilot.Session, prompt string, stream bool) error { + if strings.TrimSpace(prompt) == "" { + return fmt.Errorf("prompt 不能为空") + } + _, _ = fmt.Fprintf(inv.Stdout, "session=%s\n", session.SessionID) + + done := make(chan struct{}, 1) + errCh := make(chan error, 1) + unsub := session.On(func(event copilot.SessionEvent) { + switch event.Type { + case "assistant.message_delta", "assistant.reasoning_delta": + if stream && event.Data.DeltaContent != nil { + _, _ = fmt.Fprint(inv.Stdout, *event.Data.DeltaContent) + } + case "assistant.message": + if event.Data.Content != nil { + if stream { + _, _ = fmt.Fprintln(inv.Stdout) + } + _, _ = fmt.Fprintf(inv.Stdout, "assistant: %s\n", *event.Data.Content) + } + case "session.error": + if event.Data.Message != nil { + select { + case errCh <- fmt.Errorf("session error: %s", *event.Data.Message): + default: + } + } + case "session.idle": + select { + case done <- struct{}{}: + default: + } + } + }) + defer unsub() + + if _, err := session.Send(ctx, copilot.MessageOptions{Prompt: strings.TrimSpace(prompt)}); err != nil { + return fmt.Errorf("send prompt: %w", err) + } + + waitCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + select { + case <-done: + return nil + case err := <-errCh: + return err + case <-waitCtx.Done(): + return fmt.Errorf("wait session idle: %w", waitCtx.Err()) + } +} + +func buildUserInputHandler(inv *redant.Invocation, answer string) copilot.UserInputHandler { + ans := strings.TrimSpace(answer) + if ans == "" { + ans = "继续执行" + } + return func(request copilot.UserInputRequest, invocation copilot.UserInputInvocation) (copilot.UserInputResponse, error) { + _, _ = fmt.Fprintf(inv.Stdout, "[ask_user] session=%s question=%s\n", invocation.SessionID, request.Question) + return copilot.UserInputResponse{Answer: ans, WasFreeform: true}, nil + } +} + +type clientOptions struct { + cliPath string + logLevel string + cwd string + token string + useLoggedInUser bool +} + +func (o clientOptions) key() string { + return strings.Join([]string{ + strings.TrimSpace(o.cliPath), + strings.TrimSpace(o.logLevel), + strings.TrimSpace(o.cwd), + strings.TrimSpace(o.token), + fmt.Sprintf("%t", o.useLoggedInUser), + }, "|") +} + +type runtime struct { + mu sync.Mutex + client *copilot.Client + clientKey string + sessions map[string]*copilot.Session +} + +func newRuntime() *runtime { + return &runtime{sessions: make(map[string]*copilot.Session)} +} + +func (r *runtime) ensureClient(ctx context.Context, inv *redant.Invocation, opts clientOptions) (*copilot.Client, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.client != nil && r.clientKey == opts.key() { + return r.client, nil + } + if err := r.closeLocked(inv.Stderr); err != nil { + _, _ = fmt.Fprintf(inv.Stderr, "warn: close previous copilot runtime failed: %v\n", err) + } + + client := copilot.NewClient(&copilot.ClientOptions{ + CLIPath: strings.TrimSpace(opts.cliPath), + LogLevel: withDefault(strings.TrimSpace(opts.logLevel), "error"), + Cwd: strings.TrimSpace(opts.cwd), + GitHubToken: strings.TrimSpace(opts.token), + UseLoggedInUser: copilot.Bool(opts.useLoggedInUser), + AutoStart: copilot.Bool(false), + }) + if err := client.Start(ctx); err != nil { + return nil, fmt.Errorf("start client: %w", err) + } + + r.client = client + r.clientKey = opts.key() + r.sessions = make(map[string]*copilot.Session) + _, _ = fmt.Fprintln(inv.Stdout, "Copilot client started") + return client, nil +} + +func (r *runtime) GetSession(sessionID string) (*copilot.Session, bool) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.sessions[strings.TrimSpace(sessionID)] + return s, ok +} + +func (r *runtime) StoreSession(s *copilot.Session) { + if s == nil || strings.TrimSpace(s.SessionID) == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.sessions[strings.TrimSpace(s.SessionID)] = s +} + +func (r *runtime) Close(stderr io.Writer) { + r.mu.Lock() + defer r.mu.Unlock() + if err := r.closeLocked(stderr); err != nil && stderr != nil { + _, _ = fmt.Fprintf(stderr, "warn: close copilot runtime failed: %v\n", err) + } +} + +func (r *runtime) closeLocked(stderr io.Writer) error { + var closeErr error + for sid, s := range r.sessions { + if s == nil { + delete(r.sessions, sid) + continue + } + if err := s.Disconnect(); err != nil { + closeErr = errors.Join(closeErr, fmt.Errorf("disconnect session(%s): %w", sid, err)) + } + delete(r.sessions, sid) + } + if r.client != nil { + if err := r.client.Stop(); err != nil { + closeErr = errors.Join(closeErr, fmt.Errorf("stop client: %w", err)) + } + } + r.client = nil + r.clientKey = "" + r.sessions = make(map[string]*copilot.Session) + return closeErr +} + +func withClient(ctx context.Context, inv *redant.Invocation, opts clientOptions, fn func(context.Context, *copilot.Client) error) error { + client, err := rt.ensureClient(ctx, inv, opts) + if err != nil { + return err + } + return fn(ctx, client) +} + +func withDefault(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} + +type hydrateConfig struct { + enabled bool + timeout time.Duration + maxEvents int +} + +type hydrateSessionInfo struct { + messageCount int + lastAssistant string + errorText string + maxEvents int +} + +func renderSessionLine(s copilot.SessionMetadata, hydrate hydrateSessionInfo) (line string, onlyID bool) { + parts := []string{fmt.Sprintf("- id=%s", withDefault(strings.TrimSpace(s.SessionID), "(empty)"))} + + if t := strings.TrimSpace(s.StartTime); t != "" { + parts = append(parts, "start="+t) + } + if t := strings.TrimSpace(s.ModifiedTime); t != "" { + parts = append(parts, "modified="+t) + } + + if s.Summary != nil { + summary := strings.TrimSpace(*s.Summary) + if summary != "" { + parts = append(parts, "summary="+summary) + } + } + + if s.Context != nil { + if repo := strings.TrimSpace(s.Context.Repository); repo != "" { + parts = append(parts, "repo="+repo) + } + if branch := strings.TrimSpace(s.Context.Branch); branch != "" { + parts = append(parts, "branch="+branch) + } + if cwd := strings.TrimSpace(s.Context.Cwd); cwd != "" { + parts = append(parts, "cwd="+cwd) + } + } + + if hydrate.errorText != "" { + parts = append(parts, "hydrate.error="+hydrate.errorText) + } + if hydrate.messageCount > 0 { + parts = append(parts, fmt.Sprintf("hydrate.messages=%d", hydrate.messageCount)) + } + if hydrate.lastAssistant != "" { + parts = append(parts, "hydrate.assistant="+hydrate.lastAssistant) + } + if hydrate.maxEvents > 0 { + parts = append(parts, fmt.Sprintf("hydrate.scan=%d", hydrate.maxEvents)) + } + + if len(parts) == 1 { + parts = append(parts, "meta=empty") + return strings.Join(parts, " "), true + } + + return strings.Join(parts, " "), false +} + +func hydrateSession(ctx context.Context, client *copilot.Client, sessionID string, cfg hydrateConfig) hydrateSessionInfo { + info := hydrateSessionInfo{maxEvents: cfg.maxEvents} + if !cfg.enabled || sessionID == "" { + return info + } + + if cfg.maxEvents <= 0 { + cfg.maxEvents = 50 + info.maxEvents = cfg.maxEvents + } + + rctx, cancel := context.WithTimeout(ctx, cfg.timeout) + defer cancel() + + session, err := client.ResumeSession(rctx, sessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + DisableResume: true, + }) + if err != nil { + info.errorText = compactText(err.Error(), 120) + return info + } + defer func() { + if err := session.Disconnect(); err != nil { + if strings.TrimSpace(info.errorText) == "" { + info.errorText = compactText(fmt.Sprintf("disconnect session: %v", err), 120) + } else { + info.errorText = compactText(info.errorText+"; disconnect session: "+err.Error(), 120) + } + } + }() + + events, err := session.GetMessages(rctx) + if err != nil { + info.errorText = compactText(err.Error(), 120) + return info + } + + info.messageCount = len(events) + start := 0 + if len(events) > cfg.maxEvents { + start = len(events) - cfg.maxEvents + } + + for i := len(events) - 1; i >= start; i-- { + e := events[i] + if e.Type == "assistant.message" && e.Data.Content != nil { + text := strings.TrimSpace(*e.Data.Content) + if text != "" { + info.lastAssistant = compactText(text, 120) + break + } + } + } + + return info +} + +func compactText(s string, max int) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\t", " ") + s = strings.Join(strings.Fields(s), " ") + if max <= 0 || len(s) <= max { + return s + } + if max <= 1 { + return "…" + } + return s[:max-1] + "…" +} + +func parseDurationOrDefault(raw string, fallback time.Duration) time.Duration { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + d, err := time.ParseDuration(raw) + if err != nil || d <= 0 { + return fallback + } + return d +} + +type doctorInput struct { + GitHubToken string + UseLoggedInUser bool + ProfileName string + ProfileFile string + SkillDirs []string + DisabledSkills []string + AgentName string + CustomAgentsJSON string + MCPServersJSON string +} + +type doctorFinding struct { + Level string + Text string +} + +type doctorReport struct { + findings []doctorFinding +} + +func runDoctorChecks(in doctorInput) doctorReport { + report := doctorReport{} + + if strings.TrimSpace(in.ProfileName) != "" { + report.add("ok", fmt.Sprintf("使用 profile=%s (file=%s)", strings.TrimSpace(in.ProfileName), withDefault(strings.TrimSpace(in.ProfileFile), "(empty)"))) + } + + if strings.TrimSpace(in.GitHubToken) == "" && !in.UseLoggedInUser { + report.add("error", "未提供 --copilot-token/GITHUB_TOKEN,且 --copilot-use-logged-in-user=false,无法认证") + } else if strings.TrimSpace(in.GitHubToken) == "" && in.UseLoggedInUser { + report.add("warn", "未提供 token,将依赖已登录用户身份") + } else { + report.add("ok", "认证配置可用(检测到 token 或已登录用户模式)") + } + + if len(in.SkillDirs) == 0 { + report.add("warn", "未配置 --skill-dirs,skills 不会从外部目录加载") + } else { + for _, dir := range in.SkillDirs { + if err := checkSkillDir(dir); err != nil { + report.add("error", err.Error()) + continue + } + report.add("ok", "skills 目录可用: "+dir) + } + } + + if len(in.DisabledSkills) > 0 { + report.add("ok", fmt.Sprintf("禁用技能数: %d", len(in.DisabledSkills))) + } + + agentNames := extractCustomAgentNamesFromJSON(in.CustomAgentsJSON) + agentNameSet := make(map[string]struct{}, len(agentNames)) + for _, item := range agentNames { + agentNameSet[item] = struct{}{} + } + if in.AgentName != "" { + if len(agentNameSet) == 0 { + report.add("warn", "指定了 --agent,但未通过 --custom-agents-json 提供可选 agent 列表") + } else if _, ok := agentNameSet[in.AgentName]; !ok { + report.add("error", fmt.Sprintf("--agent=%q 不在 custom-agents 列表中", in.AgentName)) + } else { + report.add("ok", "激活 agent 匹配成功: "+in.AgentName) + } + } else if len(agentNameSet) > 0 { + report.add("warn", "已配置 custom-agents,但未指定 --agent 激活项") + } + + mcpCommands := extractMCPServerCommands(in.MCPServersJSON) + if len(mcpCommands) == 0 { + report.add("warn", "未配置 --mcp-servers-json") + } else { + for _, item := range mcpCommands { + if item.Command == "" { + report.add("warn", fmt.Sprintf("MCP server=%q 未配置 command", item.Name)) + continue + } + if _, err := exec.LookPath(item.Command); err != nil { + report.add("error", fmt.Sprintf("MCP server=%q command=%q 不可执行: %v", item.Name, item.Command, err)) + continue + } + report.add("ok", fmt.Sprintf("MCP server=%q command 可执行: %s", item.Name, item.Command)) + } + } + + return report +} + +func (r *doctorReport) add(level, text string) { + r.findings = append(r.findings, doctorFinding{Level: strings.TrimSpace(level), Text: strings.TrimSpace(text)}) +} + +func (r doctorReport) lines() []string { + out := make([]string, 0, len(r.findings)+1) + out = append(out, "Copilot doctor report") + for _, item := range r.findings { + prefix := "[INFO]" + switch strings.ToLower(strings.TrimSpace(item.Level)) { + case "ok": + prefix = "[ OK ]" + case "warn": + prefix = "[WARN]" + case "error": + prefix = "[ERR ]" + } + out = append(out, fmt.Sprintf("%s %s", prefix, item.Text)) + } + out = append(out, fmt.Sprintf("summary: errors=%d warnings=%d", r.errorCount(), r.warnCount())) + return out +} + +func (r doctorReport) errorCount() int { + n := 0 + for _, item := range r.findings { + if strings.EqualFold(strings.TrimSpace(item.Level), "error") { + n++ + } + } + return n +} + +func (r doctorReport) warnCount() int { + n := 0 + for _, item := range r.findings { + if strings.EqualFold(strings.TrimSpace(item.Level), "warn") { + n++ + } + } + return n +} + +func (r doctorReport) hasError() bool { + return r.errorCount() > 0 +} + +func checkSkillDir(path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("skills 目录为空") + } + st, err := os.Stat(path) + if err != nil { + return fmt.Errorf("skills 目录不可用(%s): %w", path, err) + } + if !st.IsDir() { + return fmt.Errorf("skills 路径不是目录(%s)", path) + } + return nil +} + +type namedCommand struct { + Name string + Command string +} + +func extractMCPServerCommands(raw string) []namedCommand { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var payload map[string]map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + out := make([]namedCommand, 0, len(payload)) + for name, cfg := range payload { + cmd := "" + if cfg != nil { + if v, ok := cfg["command"]; ok { + cmd = strings.TrimSpace(fmt.Sprint(v)) + if cmd == "" { + cmd = "" + } + } + } + out = append(out, namedCommand{Name: strings.TrimSpace(name), Command: cmd}) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +func extractCustomAgentNamesFromJSON(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var payload []map[string]any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return nil + } + out := make([]string, 0, len(payload)) + for _, item := range payload { + name := strings.TrimSpace(fmt.Sprint(item["name"])) + if name == "" || name == "" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} + +type inspectInput struct { + Model string + ReasoningEffort string + Streaming bool + ConfigDir string + SessionWorkingDir string + UseLoggedInUser bool + HasGitHubToken bool + EnableDemoEchoTool bool + EnableInfiniteSesion bool + ProfileName string + ProfileFile string + Advanced *advancedConfig + CustomAgentsJSON string + MCPServersJSON string +} + +func buildInspectSummary(in inspectInput) map[string]any { + systemMessage := map[string]any{"enabled": false} + if in.Advanced != nil && in.Advanced.SystemMessage != nil { + systemMessage["enabled"] = true + systemMessage["mode"] = strings.TrimSpace(in.Advanced.SystemMessage.Mode) + systemMessage["hasContent"] = strings.TrimSpace(in.Advanced.SystemMessage.Content) != "" + systemMessage["sectionOverrideCount"] = len(in.Advanced.SystemMessage.Sections) + } + + mcpServers := extractMCPServerCommands(in.MCPServersJSON) + mcpNames := make([]string, 0, len(mcpServers)) + for _, item := range mcpServers { + mcpNames = append(mcpNames, item.Name) + } + + toolNames := make([]string, 0) + if in.Advanced != nil { + toolNames = extractToolNames(in.Advanced.Tools) + } + + return map[string]any{ + "model": withDefault(strings.TrimSpace(in.Model), "gpt-5"), + "reasoningEffort": strings.TrimSpace(in.ReasoningEffort), + "profile": map[string]any{"name": strings.TrimSpace(in.ProfileName), "file": strings.TrimSpace(in.ProfileFile)}, + "streaming": in.Streaming, + "configDir": strings.TrimSpace(in.ConfigDir), + "workingDirectory": strings.TrimSpace(in.SessionWorkingDir), + "auth": map[string]any{"useLoggedInUser": in.UseLoggedInUser, "hasGitHubToken": in.HasGitHubToken}, + "systemMessage": systemMessage, + "skillDirectories": safeSlice(in.Advanced, func(a *advancedConfig) []string { return a.SkillDirectories }), + "disabledSkills": safeSlice(in.Advanced, func(a *advancedConfig) []string { return a.DisabledSkills }), + "availableTools": safeSlice(in.Advanced, func(a *advancedConfig) []string { return a.AvailableTools }), + "excludedTools": safeSlice(in.Advanced, func(a *advancedConfig) []string { return a.ExcludedTools }), + "customToolNames": toolNames, + "enableDemoEchoTool": in.EnableDemoEchoTool, + "customAgentNames": extractCustomAgentNamesFromJSON(in.CustomAgentsJSON), + "activeAgent": safeString(in.Advanced, func(a *advancedConfig) string { return a.Agent }), + "mcpServers": mcpNames, + "enableInfiniteSessions": in.EnableInfiniteSesion, + } +} + +func extractToolNames(tools []copilot.Tool) []string { + if len(tools) == 0 { + return nil + } + out := make([]string, 0, len(tools)) + for _, t := range tools { + name := strings.TrimSpace(t.Name) + if name == "" { + continue + } + out = append(out, name) + } + sort.Strings(out) + return out +} + +func safeSlice(adv *advancedConfig, get func(*advancedConfig) []string) []string { + if adv == nil { + return nil + } + return get(adv) +} + +func safeString(adv *advancedConfig, get func(*advancedConfig) string) string { + if adv == nil { + return "" + } + return strings.TrimSpace(get(adv)) +} diff --git a/cmds/copilotcmd/skills.go b/cmds/copilotcmd/skills.go new file mode 100644 index 0000000..25bfbf9 --- /dev/null +++ b/cmds/copilotcmd/skills.go @@ -0,0 +1,179 @@ +package copilotcmd + +import ( + "context" + "fmt" + "strings" + + agentlinemodule "github.com/pubgo/fastgit/pkg/agentline" + skillsmodule "github.com/pubgo/fastgit/pkg/skills" + "github.com/pubgo/redant" +) + +func newSkillsCmd(profileName, profileFile *string, cliSkillDirs *[]string) *redant.Command { + return newSkillsCmdWithService(profileName, profileFile, cliSkillDirs, skillsmodule.NewService()) +} + +func newSkillsCmdWithService(profileName, profileFile *string, cliSkillDirs *[]string, svc skillsmodule.Service) *redant.Command { + if svc == nil { + svc = skillsmodule.NewService() + } + + var ( + skillName string + skillDir string + force bool + ) + + root := &redant.Command{ + Use: "skills", + Short: "管理 skills(查看、创建、加载)", + Metadata: agentlinemodule.AgentCommandMetadata(), + } + + listCmd := &redant.Command{ + Use: "list", + Short: "列出当前可发现的 skills", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + dirs, err := resolveSkillDirsWithService(*profileName, *profileFile, *cliSkillDirs, svc) + if err != nil { + return err + } + entries, warns := svc.Discover(dirs) + if len(entries) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "未发现任何 skills") + for _, w := range warns { + _, _ = fmt.Fprintf(inv.Stdout, "warn: %s\n", w) + } + return nil + } + for _, s := range entries { + desc := strings.TrimSpace(s.Description) + if desc != "" { + _, _ = fmt.Fprintf(inv.Stdout, "- %s\t%s\t(source=%s)\t%s\n", s.Name, s.Path, strings.TrimSpace(s.Source), desc) + continue + } + _, _ = fmt.Fprintf(inv.Stdout, "- %s\t%s\t(source=%s)\n", s.Name, s.Path, strings.TrimSpace(s.Source)) + } + for _, w := range warns { + _, _ = fmt.Fprintf(inv.Stdout, "warn: %s\n", w) + } + return nil + }, + } + + showCmd := &redant.Command{ + Use: "show", + Short: "查看指定 skill 内容", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "name", Description: "skill 名称(目录名)", Value: redant.StringOf(&skillName), Required: true}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + dirs, err := resolveSkillDirsWithService(*profileName, *profileFile, *cliSkillDirs, svc) + if err != nil { + return err + } + entries, _ := svc.Discover(dirs) + target, err := svc.FindByName(entries, skillName) + if err != nil { + return err + } + content, err := svc.ReadSkill(target.Path) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, content) + return nil + }, + } + + createCmd := &redant.Command{ + Use: "create", + Short: "创建新的 skill 模板", + Metadata: agentlinemodule.AgentCommandMetadata(), + Options: redant.OptionSet{ + {Flag: "name", Description: "skill 名称(目录名)", Value: redant.StringOf(&skillName), Required: true}, + {Flag: "dir", Description: "目标 skills 根目录", Value: redant.StringOf(&skillDir), Default: "./skills"}, + {Flag: "force", Description: "覆盖已存在的 SKILL.md", Value: redant.BoolOf(&force), Default: "false"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + name := svc.SanitizeName(skillName) + if name == "" { + return fmt.Errorf("invalid --name") + } + entry, err := svc.CreateSkill(skillsmodule.CreateInput{ + Name: name, + BaseDir: strings.TrimSpace(skillDir), + Force: force, + }) + if err != nil { + return err + } + _, _ = fmt.Fprintf(inv.Stdout, "skill created: %s\n", entry.Path) + return nil + }, + } + + loadCmd := &redant.Command{ + Use: "load", + Short: "展示本次会话将加载的 skills 目录与结果", + Metadata: agentlinemodule.AgentCommandMetadata(), + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + dirs, err := resolveSkillDirsWithService(*profileName, *profileFile, *cliSkillDirs, svc) + if err != nil { + return err + } + _, _ = fmt.Fprintln(inv.Stdout, "resolved skill directories:") + for _, d := range dirs { + _, _ = fmt.Fprintf(inv.Stdout, "- %s\n", d) + } + entries, warns := svc.Discover(dirs) + _, _ = fmt.Fprintf(inv.Stdout, "discovered skills: %d\n", len(entries)) + for _, s := range entries { + _, _ = fmt.Fprintf(inv.Stdout, "- %s\n", s.Name) + } + for _, w := range warns { + _, _ = fmt.Fprintf(inv.Stdout, "warn: %s\n", w) + } + return nil + }, + } + + root.Children = []*redant.Command{listCmd, showCmd, createCmd, loadCmd} + return root +} + +func resolveSkillDirs(profileName, profileFile string, cliSkillDirs []string) ([]string, error) { + return resolveSkillDirsWithService(profileName, profileFile, cliSkillDirs, skillsmodule.NewService()) +} + +func resolveSkillDirsWithService(profileName, profileFile string, cliSkillDirs []string, svc skillsmodule.Service) ([]string, error) { + if svc == nil { + svc = skillsmodule.NewService() + } + + resolved, err := resolveCopilotOptions(resolveCopilotInput{ + ProfileName: strings.TrimSpace(profileName), + ProfileFile: strings.TrimSpace(profileFile), + SkillDirs: cliSkillDirs, + SystemMessageMode: "append", + EnableInfiniteSession: true, + }) + if err != nil { + return nil, err + } + + dirs := svc.CompactStringSlice(resolved.Advanced.SkillDirectories) + if len(dirs) > 0 { + return dirs, nil + } + + fallback := svc.ExistingDirs([]string{"./skills", "./.copilot/skills"}) + return svc.CompactStringSlice(fallback), nil +} diff --git a/cmds/docscmd/assets.go b/cmds/docscmd/assets.go new file mode 100644 index 0000000..275a8d6 --- /dev/null +++ b/cmds/docscmd/assets.go @@ -0,0 +1,295 @@ +package docscmd + +import ( + "fmt" + "strings" +) + +const docsBacktick = "`" + +func renderCommitMessagePromptTemplate() string { + tpl := `--- +name: commit-message +description: 基于当前代码改动生成提交信息并直接执行本地 git commit,默认继续 push 到当前分支 +argument-hint: "可选:补充本次提交的核心意图、偏好的 type/scope;默认直接完成本地提交并推送远程" +agent: agent +--- + +你是当前仓库的 Git 提交信息助手。 + +## 目标 + +基于当前代码改动生成高质量的 git commit message,并**直接完成本地提交与远程推送**。 + +你的首要职责不是解释 diff,而是: + +1. 判断本次应提交哪些改动; +2. 生成最合适的 commit message; +3. 执行本地 {{BT}}git commit{{BT}}; +4. 执行 {{BT}}git push{{BT}}; +5. 报告结果。 + +除非用户明确要求不要推送,否则**默认执行 {{BT}}git push{{BT}}**。 + +## 必读上下文 + +优先按以下顺序读取或获取: + +- {{BT}}git diff --cached --stat{{BT}} +- {{BT}}git diff --cached --name-only{{BT}} +- 如有必要,再查看 {{BT}}git diff --cached{{BT}} + +如果 staged diff 为空,不要立即结束;继续读取: + +- {{BT}}git status --short{{BT}} +- {{BT}}git diff --stat{{BT}} +- {{BT}}git diff --name-only{{BT}} +- 如有必要,再查看 {{BT}}git diff{{BT}} + +处理规则: + +- 如果 **有 staged diff**: + - 仅基于 staged diff 生成 commit message; + - 仅提交 staged 内容; + - 不要自动把 unstaged 改动加入提交。 +- 如果 **没有 staged diff,但有 unstaged diff**: + - 基于 working tree 改动生成 commit message; + - 自动执行 {{BT}}git add -A{{BT}} 将当前改动加入暂存区; + - 再执行本地提交。 +- 如果 staged / unstaged 都为空: + - 明确提示当前没有可用于生成提交信息的代码改动。 + - 此时不要杜撰任何 commit message,也不要执行提交。 + +## 执行规则 + +在确定 commit message 后: + +1. 如果 staged diff 非空:直接执行 {{BT}}git commit -m ""{{BT}}。 +2. 如果 staged diff 为空但 unstaged diff 非空:先执行 {{BT}}git add -A{{BT}},再执行 {{BT}}git commit -m ""{{BT}}。 +3. 提交成功后,默认继续执行 {{BT}}git push{{BT}}。 +4. 优先推送当前分支的上游;如果没有上游,则推送当前分支到同名远程分支。 +5. 如果 {{BT}}git commit{{BT}} 失败,应输出失败原因,而不是假装成功。 +6. 如果 {{BT}}git push{{BT}} 失败,应输出真实失败原因。 +7. 不要编造 commit hash;只能使用真实执行结果。 + +## 判定优先级 + +生成提交信息时,按以下优先级判断: + +1. **用户可见新能力** 优先于内部重构细节。 + - 例如:新增命令、子命令、交互入口、脚手架、repo prompt、工作流能力,优先考虑 {{BT}}feat{{BT}}。 +2. **真实行为修复** 优先于实现细节调整。 + - 例如:修复发布流程、修复输出错误、修复空发布,优先考虑 {{BT}}fix{{BT}}。 +3. **纯结构调整且无新增用户能力** 才优先考虑 {{BT}}refactor{{BT}}。 +4. **纯文档更新** 才优先考虑 {{BT}}docs{{BT}}。 + +如果一次改动同时包含“用户可见新能力”和“内部重构”,应优先围绕**最核心的用户可见变化**选择 {{BT}}type{{BT}}。 + +## 通用规则 + +1. 只基于可见改动生成提交信息,不杜撰。 +2. 使用 **Conventional Commits** 规范: + - {{BT}}feat{{BT}} + - {{BT}}fix{{BT}} + - {{BT}}docs{{BT}} + - {{BT}}refactor{{BT}} + - {{BT}}test{{BT}} + - {{BT}}chore{{BT}} + - {{BT}}perf{{BT}} + - {{BT}}build{{BT}} + - {{BT}}ci{{BT}} +3. 标题格式: + - {{BT}}(): {{BT}} + - 如果 scope 不明确,可省略 scope,使用 {{BT}}: {{BT}}。 +4. {{BT}}summary{{BT}} 使用英文,简洁明确,尽量不超过 50 个字符。 +5. 优先描述本次改动的**核心行为变化**,不要机械罗列所有文件。 +6. 如果主要是新增能力,优先用 {{BT}}feat{{BT}}。 +7. 如果主要是修复问题,优先用 {{BT}}fix{{BT}}。 +8. 如果主要是无行为变化的结构调整,优先用 {{BT}}refactor{{BT}}。 +9. 如果主要是文档、说明、注释更新,优先用 {{BT}}docs{{BT}}。 +10. 如果同时存在代码和文档改动,优先按代码主行为决定 type。 +11. 如果改动新增了命令、子命令、prompt、规则文件、脚手架或发布工作流,且这些内容对用户直接可见,优先考虑 {{BT}}feat{{BT}},不要轻易降级成 {{BT}}refactor{{BT}}。 +12. 提交信息最终只能选择 1 条最优结果用于实际提交。 + +## scope 选择建议 + +优先根据当前仓库模块推断 scope,例如: + +- {{BT}}copilot{{BT}} +- {{BT}}ggc{{BT}} +- {{BT}}changelog{{BT}} +- {{BT}}agentline{{BT}} +- {{BT}}ssh{{BT}} +- {{BT}}skills{{BT}} +- {{BT}}push{{BT}} +- {{BT}}commit{{BT}} +- {{BT}}docs{{BT}} + +如果这些都不合适,再根据实际改动模块自行推断。 + +## 输出要求 + +如果成功提交并推送,请输出: + +- {{BT}}mode:{{BT}} 说明本次基于 {{BT}}staged{{BT}} 还是 {{BT}}unstaged-auto-stage{{BT}} +- {{BT}}commit:{{BT}} 实际执行的 commit message +- {{BT}}hash:{{BT}} 实际生成的 commit hash(短 hash 即可) +- {{BT}}push:{{BT}} 推送目标或推送结果摘要 +- {{BT}}reason:{{BT}} 用中文简短说明为什么这条提交信息最合适(1~2 句) + +输出时必须遵守: + +1. 只输出最终结果,不要展示分析过程。 +2. 不要加标题,不要加 Markdown 段落说明,不要加“已完成 X 个步骤”。 +3. 顶层字段固定使用: + - {{BT}}mode:{{BT}} + - {{BT}}commit:{{BT}} + - {{BT}}hash:{{BT}} + - {{BT}}push:{{BT}} + - {{BT}}reason:{{BT}} +4. {{BT}}commit:{{BT}} 必须是**单行** commit message。 +5. {{BT}}hash:{{BT}} 必须来自真实 {{BT}}git commit{{BT}} 结果。 +6. {{BT}}push:{{BT}} 必须来自真实 {{BT}}git push{{BT}} 结果摘要。 +7. {{BT}}reason:{{BT}} 只写 1~2 句中文,简洁即可。 + +如果当前没有任何可用改动,请只输出一段简短提示,说明: + +- 当前没有 staged diff +- 当前也没有 unstaged diff(如果确实为空) +- 请先修改代码或执行 {{BT}}git add{{BT}} + +如果提交失败,请输出简短失败结果,包含: + +- {{BT}}mode:{{BT}} +- {{BT}}commit:{{BT}} +- {{BT}}error:{{BT}} + +其中 {{BT}}error:{{BT}} 必须是真实报错摘要。 + +如果提交成功但推送失败,请输出简短失败结果,包含: + +- {{BT}}mode:{{BT}} +- {{BT}}commit:{{BT}} +- {{BT}}hash:{{BT}} +- {{BT}}error:{{BT}} + +其中 {{BT}}error:{{BT}} 必须是真实 push 报错摘要。 + +输出格式示例: + +mode: staged +commit: feat(copilot): add interactive session commands +hash: a1b2c3d +push: pushed to origin/current-branch +reason: 本次改动核心是新增 Copilot 交互与会话能力,使用 feat 更准确,scope 选 copilot 更能概括主行为。 + +当没有 staged diff、但存在 unstaged diff 并自动暂存提交时,输出格式示例: + +mode: unstaged-auto-stage +commit: feat(changelog): add prompt-based release workflow +hash: d4e5f6g +push: pushed to origin/current-branch +reason: 当前改动虽然尚未暂存,但核心变化包含用户可见的 changelog 工作流与 prompt 脚手架,因此优先使用 feat,并已自动完成本地提交。 +` + + return strings.TrimSpace(strings.ReplaceAll(tpl, "{{BT}}", docsBacktick)) + "\n" +} + +func renderDocumentationPromptTemplate() string { + return fmt.Sprintf(`--- +name: documentation +description: 维护 README/docs/example 文档,确保中文技术文风、结构一致、变更可追溯 +argument-hint: "可选:补充要更新的文档范围、主题或目标读者" +agent: agent +--- + +你是当前仓库的文档维护助手。 + +## 目标 + +根据当前仓库真实改动,维护与同步以下文档内容: + +- %sREADME.md%s +- %sdocs/**%s +- %sexample/**/README.md%s +- 其他与当前改动直接相关的 Markdown 文档 + +## 必读上下文 + +开始前优先读取: + +- 当前变更涉及的文档文件 +- 与改动直接相关的实现代码 +- 如涉及行为变化,读取 %s.version/changelog/Unreleased.md%s + +如果改动涉及架构、流程或命令面变化,优先检查: + +- %sREADME.md%s +- %sdocs/DESIGN.md%s(如果存在) +- %sdocs/**%s 下对应专题文档 + +## 通用规则 + +1. 只基于当前仓库真实实现写作,不杜撰未实现能力。 +2. 默认使用中文技术文风,表达简洁、可执行、可复现。 +3. 优先使用二级/三级标题和短列表,避免大段空泛描述。 +4. 流程、架构、关系图优先使用 Mermaid。 +5. 避免在多个文档中复制粘贴同一段说明;优先引用单一事实来源。 +6. 若只是措辞润色,不要改动技术语义与行为结论。 +7. 描述命令时,优先使用仓库中真实存在的命令或任务名。 + +## 仓库约定 + +1. 如果当前仓库存在 %sdocs/INDEX.md%s,新增文档时应同步更新索引关系。 +2. 涉及架构或流程变化时,优先更新 %sdocs/DESIGN.md%s(如果存在),再补 README / 示例 / 其他说明文档。 +3. 用户可见行为变化,应同步更新 README 或对应示例文档。 +4. 行为变更通常应同步 %s.version/changelog/Unreleased.md%s,但本 prompt 默认只修改文档文件;如确需修改 changelog,应单独说明。 + +## 输出要求 + +- 直接给出文档修改结果。 +- 末尾附简短自检: + - 是否仅基于真实改动更新文档; + - 是否保持中文技术文风与结构化表达; + - 是否已同步最关键的入口文档(README / DESIGN / 示例)。 + `, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick) +} + +func renderDocumentationInstructionTemplate() string { + return fmt.Sprintf(`--- +name: 文档专项规范 +description: 适用于仓库文档写作与维护(README/docs/example/internal),确保中文技术文风、结构一致、变更可追溯 +applyTo: "**/*.md" +--- + +# 仓库文档专项规范 + +仅在“项目文档内容”场景生效(如 %sREADME.md%s、%sdocs/**%s、%sexample/**/README.md%s、%sinternal/**/README.md%s)。 + +## 基本要求 + +- 默认使用中文技术文风,表达简洁、可执行、可复现。 +- 结构化写作:优先使用二级/三级标题与短列表,避免大段空泛描述。 +- 流程、架构、关系图优先使用 Mermaid。 +- 避免复制粘贴同一段说明到多个文档;优先“引用索引文档”或“链接到单一事实来源”。 + +## 仓库约定(必须遵循) + +- 如果当前仓库存在 %sdocs/INDEX.md%s,新增文档时需补充索引关系(如适用)。 +- 涉及架构或流程变化时,先更新 %sdocs/DESIGN.md%s(如存在),再补示例/说明文档。 +- 行为变更需同步 %s.version/changelog/Unreleased.md%s;必要时同步其他评估或使用文档。 +- 术语需与当前仓库现有命名保持一致,不擅自发明新概念。 + +## 写作与更新策略 + +- 面向“当前仓库真实实现”写作,不杜撰未实现能力。 +- 描述命令时优先使用仓库中已存在的命令名与任务名。 +- 变更文档时说明“改了什么、为什么改、影响范围”。 +- 若仅做措辞润色,不应改动技术语义与行为结论。 + +## Changelog 联动 + +- 如涉及行为变化,建议同步更新 %s.version/changelog/Unreleased.md%s。 +- 建议通过相关 prompt 执行 changelog 维护。 + `, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick, docsBacktick) +} diff --git a/cmds/docscmd/cmd.go b/cmds/docscmd/cmd.go new file mode 100644 index 0000000..03ead32 --- /dev/null +++ b/cmds/docscmd/cmd.go @@ -0,0 +1,60 @@ +package docscmd + +import ( + "context" + "fmt" + "strings" + + "github.com/pubgo/redant" +) + +func New() *redant.Command { + root := &redant.Command{ + Use: "docs", + Short: "初始化和维护仓库文档模板", + Long: "初始化文档相关的 prompt / instruction 模板,便于通过 Copilot 维护 README 与 docs。", + } + + root.Children = []*redant.Command{newInitCommand()} + return root +} + +func newInitCommand() *redant.Command { + var ( + repoPath string + force bool + ) + + return &redant.Command{ + Use: "init", + Short: "初始化 documentation prompt / instruction 模板", + Options: redant.OptionSet{ + {Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)}, + {Flag: "force", Description: "覆盖已有模板文件", Value: redant.BoolOf(&force), Default: "false"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + _ = ctx + repoRoot, err := resolveRepoRoot(strings.TrimSpace(repoPath)) + if err != nil { + return err + } + + result, err := ensureDocumentationScaffold(repoRoot, scaffoldOptions{Force: force}) + if err != nil { + return err + } + + for _, file := range result.Created { + _, _ = fmt.Fprintf(inv.Stdout, "created: %s\n", file) + } + for _, file := range result.Updated { + _, _ = fmt.Fprintf(inv.Stdout, "updated: %s\n", file) + } + if len(result.Created) == 0 && len(result.Updated) == 0 { + _, _ = fmt.Fprintln(inv.Stdout, "documentation scaffold already up to date") + } + _, _ = fmt.Fprintf(inv.Stdout, "repo: %s\n", repoRoot) + return nil + }, + } +} diff --git a/cmds/docscmd/cmd_test.go b/cmds/docscmd/cmd_test.go new file mode 100644 index 0000000..812c9af --- /dev/null +++ b/cmds/docscmd/cmd_test.go @@ -0,0 +1,36 @@ +package docscmd + +import ( + "os" + "strings" + "testing" +) + +func TestEnsureDocumentationScaffoldCreatesTemplates(t *testing.T) { + repo := t.TempDir() + + result, err := ensureDocumentationScaffold(repo, scaffoldOptions{}) + if err != nil { + t.Fatalf("ensureDocumentationScaffold() error = %v", err) + } + if len(result.Created) == 0 { + t.Fatalf("expected documentation scaffold to create files") + } + + paths := buildPaths(repo) + assertFileContains(t, paths.DocumentationPromptFile, "name: documentation") + assertFileContains(t, paths.DocumentationPromptFile, "你是当前仓库的文档维护助手") + assertFileContains(t, paths.DocumentationRulesFile, "name: 文档专项规范") + assertFileContains(t, paths.DocumentationRulesFile, "适用于仓库文档写作与维护") +} + +func assertFileContains(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !strings.Contains(string(content), want) { + t.Fatalf("expected %s to contain %q, got:\n%s", path, want, string(content)) + } +} diff --git a/cmds/docscmd/files.go b/cmds/docscmd/files.go new file mode 100644 index 0000000..f8c526a --- /dev/null +++ b/cmds/docscmd/files.go @@ -0,0 +1,118 @@ +package docscmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type docPaths struct { + RepoRoot string + GitHubDir string + PromptsDir string + InstructionsDir string + DocumentationPromptFile string + CommitMessagePromptFile string + DocumentationRulesFile string +} + +type scaffoldOptions struct { + Force bool +} + +type scaffoldResult struct { + Created []string + Updated []string +} + +func resolveRepoRoot(input string) (string, error) { + path := strings.TrimSpace(input) + if path == "" { + var err error + path, err = os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + } + path, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve repo path: %w", err) + } + return path, nil +} + +func buildPaths(repoRoot string) docPaths { + return docPaths{ + RepoRoot: repoRoot, + GitHubDir: filepath.Join(repoRoot, ".github"), + PromptsDir: filepath.Join(repoRoot, ".github", "prompts"), + InstructionsDir: filepath.Join(repoRoot, ".github", "instructions"), + DocumentationPromptFile: filepath.Join(repoRoot, ".github", "prompts", "documentation.prompt.md"), + CommitMessagePromptFile: filepath.Join(repoRoot, ".github", "prompts", "commit-message.prompt.md"), + DocumentationRulesFile: filepath.Join(repoRoot, ".github", "instructions", "documentation.instructions.md"), + } +} + +func ensureDocumentationScaffold(repoRoot string, opts scaffoldOptions) (scaffoldResult, error) { + paths := buildPaths(repoRoot) + if err := os.MkdirAll(paths.PromptsDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create prompts directory: %w", err) + } + if err := os.MkdirAll(paths.InstructionsDir, 0o755); err != nil { + return scaffoldResult{}, fmt.Errorf("create instructions directory: %w", err) + } + + result := scaffoldResult{} + + state, err := writeManagedFile(paths.DocumentationPromptFile, renderDocumentationPromptTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.DocumentationPromptFile, state) + + state, err = writeManagedFile(paths.CommitMessagePromptFile, renderCommitMessagePromptTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.CommitMessagePromptFile, state) + + state, err = writeManagedFile(paths.DocumentationRulesFile, renderDocumentationInstructionTemplate(), opts.Force) + if err != nil { + return scaffoldResult{}, err + } + recordScaffoldState(&result, paths.DocumentationRulesFile, state) + + return result, nil +} + +func writeManagedFile(path, content string, force bool) (string, error) { + if fileExists(path) && !force { + return "skipped", nil + } + state := "created" + if fileExists(path) { + state = "updated" + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return state, nil +} + +func recordScaffoldState(result *scaffoldResult, path, state string) { + if result == nil { + return + } + switch state { + case "created": + result.Created = append(result.Created, path) + case "updated": + result.Updated = append(result.Updated, path) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/cmds/pushcmd/cmd.go b/cmds/pushcmd/cmd.go new file mode 100644 index 0000000..378b881 --- /dev/null +++ b/cmds/pushcmd/cmd.go @@ -0,0 +1,78 @@ +package pushcmd + +import ( + "context" + + "github.com/pubgo/fastgit/utils" + "github.com/pubgo/funk/v2/errors" + "github.com/pubgo/funk/v2/log" + "github.com/pubgo/funk/v2/result" + "github.com/pubgo/redant" +) + +func New() *redant.Command { + var flagData = new(struct { + pushAll bool + pushForce bool + }) + + return &redant.Command{ + Use: "push", + Short: "git push to remote origin", + Options: []redant.Option{ + { + Flag: "all", + Description: "push all branches", + Value: redant.BoolOf(&flagData.pushAll), + }, + { + Flag: "force", + Description: "force push current branch with --force-with-lease", + Value: redant.BoolOf(&flagData.pushForce), + }, + }, + Handler: func(ctx context.Context, i *redant.Invocation) (gErr error) { + defer result.RecoveryErr(&gErr, func(err error) error { + if errors.Is(err, context.Canceled) { + return nil + } + + if err.Error() == "signal: interrupt" { + return nil + } + + return err + }) + + command := i.Command + if len(command.Args) > 0 { + log.Error(ctx).Msgf("unknown command:%v", command.Args) + return redant.DefaultHelpFn()(ctx, i) + } + + utils.LogConfigAndBranch() + + isDirty := utils.IsDirty().Unwrap() + if isDirty { + return nil + } + + if flagData.pushAll && flagData.pushForce { + return errors.Errorf("--force cannot be used with --all") + } + + if flagData.pushAll { + utils.GitPush(ctx, "--all", "origin") + return nil + } + + if flagData.pushForce { + utils.GitPush(ctx, "--force-with-lease", "--set-upstream", "origin", utils.GetBranchName()) + return nil + } + + utils.GitPush(ctx, "--set-upstream", "origin", utils.GetBranchName()) + return nil + }, + } +} diff --git a/cmds/pushcmd/cmd_test.go b/cmds/pushcmd/cmd_test.go new file mode 100644 index 0000000..207e828 --- /dev/null +++ b/cmds/pushcmd/cmd_test.go @@ -0,0 +1,20 @@ +package pushcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + cmd := New() + + require.NotNil(t, cmd) + require.Equal(t, "push", cmd.Use) + require.Equal(t, "git push to remote origin", cmd.Short) + require.NotNil(t, cmd.Handler) + + require.Len(t, cmd.Options, 2) + require.Equal(t, "all", cmd.Options[0].Flag) + require.Equal(t, "force", cmd.Options[1].Flag) +} diff --git a/docs/copilot-dcos.md b/docs/copilot-dcos.md new file mode 100644 index 0000000..d0ee186 --- /dev/null +++ b/docs/copilot-dcos.md @@ -0,0 +1,89 @@ +# Copilot 融合开发 DCOS 落地清单 + +> DCOS 这里采用:**Design(设计)- Code(开发)- Operate(运行)- Scale(扩展)**。 + +## D - Design(设计) + +1. 统一入口 + - 正式入口:`fastgit copilot` + - 示例入口:`example/copilot-demo` 仅作为薄封装,直接调用 `cmds/copilotcmd.New()`。 +2. 统一交互层 + - 默认 `copilot` 根命令进入 `agentline` 交互模式。 + - 子命令(`chat/resume/sessions/status/models`)支持直接 CLI 调用与 `/slash` 调用。 +3. 统一会话运行时 + - 在 `cmds/copilotcmd` 内维护单进程 runtime:client 复用 + session 缓存。 +4. 风险约束 + - 当前权限策略使用 `ApproveAll`(仅 MVP)。 + - 后续需要替换为策略化审批(allow/deny/ask)。 + +## C - Code(开发) + +### 已完成(MVP) + +- 新增 `cmds/copilotcmd/cmd.go` + - 提供命令:`chat`、`resume`、`sessions`、`status`、`models`、`interactive-demo` + - 集成 `agentlineapp.Run` 作为默认交互入口。 + - 已支持高级配置:`ConfigDir`、`SystemMessage`、`Skills`、`MCP`、`CustomAgents`、`CustomTools`。 +- 修改 `bootstrap/boot.go` + - 注册 `copilotcmd.New()`,接入主程序命令树。 +- 修改 `example/copilot-demo/main.go` + - 简化为复用 `cmds/copilotcmd.New()`,防止示例与正式实现分叉。 + +### 下一步(P1) + +1. 事件管理完善 + - 补回 `events` 命令(timeline/summary/jsonl 导出)。 +2. 权限策略中心化 + - 增加 `--permission-mode=ask|allow|deny`,并与 agentline `/permissions` 打通。 +3. 配置体系对齐 + - 接入 `configs` + `dix`,减少命令参数重复传递。 +4. 会话持久化 + - 追加本地存储(jsonl/sqlite)与恢复索引。 + +## O - Operate(运行) + +1. 配置环境变量 + - 项目根目录 `.env` 维护 `GITHUB_TOKEN`。 +2. 基础验证流程 + - `fastgit copilot status` + - `fastgit copilot chat --prompt "hello"` + - `fastgit copilot sessions` +3. 交互模式验证 + - `fastgit copilot` + - 在交互中执行:`/chat --prompt "帮我总结当前仓库"` + +4. Repo Prompt 模板初始化 + - changelog:`fastgit changelog init` + - documentation:`fastgit docs init` + - 初始化后,可在仓库内直接复用 `.github/prompts/*.prompt.md` 与 `.github/instructions/*.instructions.md` + - `fastgit docs init` 当前会生成: + - `.github/prompts/documentation.prompt.md` + - `.github/prompts/commit-message.prompt.md` + - `.github/instructions/documentation.instructions.md` + - 适合沉淀 changelog、文档维护、提交信息等高频工作流 + +## S - Scale(扩展) + +1. 分层重构 + - `pkg/copilotruntime`: client/session 生命周期。 + - `pkg/copilotflow`: 事件渲染与导出。 +2. 质量保障 + - 单测:runtime 生命周期、session 缓存失效恢复。 + - 集成测试:`chat -> resume -> sessions` 闭环。 +3. 安全与可观测 + - 权限审计日志、事件追踪 ID、错误分类与退出码。 + +## Prompt Workflow 补充说明 + +为降低重复提示词编写成本,仓库级模板采用“两层结构”: + +1. `prompt`:描述任务目标与执行步骤 +2. `instructions`:定义目标文件的写作约束与风格规则 + +当前已落地的模板方向: + +- changelog 维护 +- documentation 文档维护 +- commit message / commit workflow(已随 documentation 模板一并初始化) + +这样可以把一次性聊天经验沉淀为 repo 内的长期工作流,而不是依赖个人记忆反复口述。 diff --git a/example/auto-agentline-input/README.md b/example/auto-agentline-input/README.md new file mode 100644 index 0000000..afbb76a --- /dev/null +++ b/example/auto-agentline-input/README.md @@ -0,0 +1,40 @@ +# auto-agentline-input + +这个示例提供一个**独立 PTY 封装**,用于自动控制交互命令(例如 copilot)。 + +它不依赖 `copilotcmd` 包,而是直接启动子进程并通过 PTY 发送输入。 + +## 运行 + +默认执行: + +- 命令:`go run . copilot` +- 不自动发送脚本 +- 默认不进入 raw 接管(可控模式) +- 你的输入会按行转发给子进程 + +在仓库根目录执行: + +`go run ./example/auto-agentline-input` + +## 常用参数 + +- `-cmd`:要封装的交互命令 +- `-script`:自动输入脚本,使用 `\n` 分隔(默认空) +- `-stdin`:是否直接进入 raw 接管(默认 false) +- `-line-input`:非 raw 模式下按行转发输入(默认 true) +- `-expect`:先等待输出出现某文本再发送脚本(可选) +- `-step-delay`:每步输入间隔 +- `-timeout`:整体超时(默认 0,不超时) + +示例: + +`go run ./example/auto-agentline-input -cmd "go run . copilot"` + +若你确实想启动后立刻接管为完整交互(raw 模式): + +`go run ./example/auto-agentline-input -cmd "go run . copilot" -stdin=true` + +自动脚本示例: + +`go run ./example/auto-agentline-input -cmd "go run . copilot" -expect "copilot>" -script "/help\n/quit"` \ No newline at end of file diff --git a/example/auto-agentline-input/main.go b/example/auto-agentline-input/main.go new file mode 100644 index 0000000..d7b20d3 --- /dev/null +++ b/example/auto-agentline-input/main.go @@ -0,0 +1,294 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + "golang.org/x/term" +) + +type PTYRunner struct { + cmd *exec.Cmd + pty *os.File + mu sync.Mutex + buf bytes.Buffer + done chan struct{} + wmu sync.Mutex + werr error + wset bool +} + +func NewPTYRunner(cmd *exec.Cmd) *PTYRunner { + return &PTYRunner{cmd: cmd, done: make(chan struct{})} +} + +func (r *PTYRunner) Start() error { + if r == nil || r.cmd == nil { + return fmt.Errorf("runner/cmd 不能为空") + } + f, err := pty.Start(r.cmd) + if err != nil { + return err + } + r.pty = f + return nil +} + +func (r *PTYRunner) AttachInteractive() error { + if r == nil || r.pty == nil { + return fmt.Errorf("pty 未启动") + } + + inFD := int(os.Stdin.Fd()) + if !term.IsTerminal(inFD) { + go func() { _, _ = io.Copy(r.pty, os.Stdin) }() + _, err := io.Copy(os.Stdout, r.pty) + if errors.Is(err, syscall.EIO) { + return nil + } + return err + } + + oldState, err := term.MakeRaw(inFD) + if err != nil { + return fmt.Errorf("切换 raw 模式失败: %w", err) + } + defer func() { _ = term.Restore(inFD, oldState) }() + + resizeCh := make(chan os.Signal, 1) + signal.Notify(resizeCh, syscall.SIGWINCH) + defer signal.Stop(resizeCh) + + _ = pty.InheritSize(os.Stdin, r.pty) + go func() { + for range resizeCh { + _ = pty.InheritSize(os.Stdin, r.pty) + } + }() + resizeCh <- syscall.SIGWINCH + + go func() { + _, _ = io.Copy(r.pty, os.Stdin) + }() + + _, err = io.Copy(os.Stdout, r.pty) + if errors.Is(err, syscall.EIO) { + return nil + } + return err +} + +func (r *PTYRunner) AttachCapture(stdout io.Writer) { + if r == nil || r.pty == nil { + return + } + if stdout == nil { + stdout = os.Stdout + } + + go func() { + defer close(r.done) + buf := make([]byte, 4096) + for { + n, err := r.pty.Read(buf) + if n > 0 { + chunk := buf[:n] + r.mu.Lock() + _, _ = r.buf.Write(chunk) + r.mu.Unlock() + _, _ = stdout.Write(chunk) + } + if err != nil { + return + } + } + }() + +} + +func (r *PTYRunner) SendLine(line string) error { + if r == nil || r.pty == nil { + return fmt.Errorf("pty 未启动") + } + _, err := io.WriteString(r.pty, strings.TrimRight(line, "\n")+"\n") + return err +} + +func (r *PTYRunner) ExpectContains(substr string, timeout time.Duration) error { + if r == nil { + return fmt.Errorf("runner 不能为空") + } + substr = strings.TrimSpace(substr) + if substr == "" { + return nil + } + + deadline := time.Now().Add(timeout) + for { + r.mu.Lock() + text := r.buf.String() + r.mu.Unlock() + if strings.Contains(text, substr) { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("expect timeout: %q", substr) + } + time.Sleep(50 * time.Millisecond) + } +} + +func (r *PTYRunner) Close() error { + if r == nil { + return nil + } + if r.pty != nil { + _ = r.pty.Close() + } + select { + case <-r.done: + default: + } + return nil +} + +func (r *PTYRunner) Wait() error { + if r == nil || r.cmd == nil { + return nil + } + r.wmu.Lock() + if r.wset { + err := r.werr + r.wmu.Unlock() + return err + } + r.wmu.Unlock() + + err := r.cmd.Wait() + + r.wmu.Lock() + r.werr = err + r.wset = true + r.wmu.Unlock() + return err +} + +func (r *PTYRunner) Interrupt() error { + if r == nil || r.cmd == nil || r.cmd.Process == nil { + return nil + } + return r.cmd.Process.Signal(syscall.SIGINT) +} + +func forwardLineInput(r *PTYRunner, in io.Reader, stderr io.Writer) { + if r == nil || in == nil { + return + } + sc := bufio.NewScanner(in) + for sc.Scan() { + line := sc.Text() + switch strings.TrimSpace(line) { + case ":interrupt", ":ctrlc": + _ = r.Interrupt() + continue + } + if err := r.SendLine(line); err != nil { + if stderr != nil { + _, _ = fmt.Fprintf(stderr, "line input forward failed: %v\n", err) + } + return + } + } +} + +func main() { + command := flag.String("cmd", "copilot", "要封装的命令(建议 interactive 命令)") + script := flag.String("script", "", "自动输入脚本,使用 \\n 分隔;默认空表示不自动发送") + pipeStdin := flag.Bool("stdin", false, "是否直接进入 raw 交互接管(true=立即进入交互)") + lineInput := flag.Bool("line-input", true, "非 raw 模式下,按行转发你的输入到子进程") + stepDelay := flag.Duration("step-delay", 300*time.Millisecond, "每条输入之间的间隔") + timeout := flag.Duration("timeout", 0, "整体超时;0 表示不设置超时") + expect := flag.String("expect", "", "发送脚本前等待输出包含该文本(可选)") + flag.Parse() + + ctx := context.Background() + cancel := func() {} + if timeout != nil && *timeout > 0 { + ctx, cancel = context.WithTimeout(context.Background(), *timeout) + } + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-lc", strings.TrimSpace(*command)) + runner := NewPTYRunner(cmd) + if err := runner.Start(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "start pty failed: %v\n", err) + os.Exit(1) + } + defer func() { _ = runner.Close() }() + + interactiveMode := *pipeStdin + if interactiveMode && strings.TrimSpace(*expect) != "" { + _, _ = fmt.Fprintln(os.Stderr, "warn: interactive 模式下忽略 -expect(建议配合 -script 使用固定步骤)") + } + + if !interactiveMode { + runner.AttachCapture(os.Stdout) + if *lineInput { + go forwardLineInput(runner, os.Stdin, os.Stderr) + _, _ = fmt.Fprintln(os.Stderr, "line-input 已启用:直接输入文本会转发给子进程;输入 :interrupt 可发送 Ctrl+C。") + } + } + + if !interactiveMode && strings.TrimSpace(*expect) != "" { + if err := runner.ExpectContains(*expect, 8*time.Second); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "expect failed: %v\n", err) + os.Exit(1) + } + } + + steps := strings.Split(strings.ReplaceAll(*script, "\\r\\n", "\\n"), "\\n") + for _, step := range steps { + step = strings.TrimSpace(step) + if step == "" { + continue + } + if err := runner.SendLine(step); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "send failed: %v\n", err) + os.Exit(1) + } + time.Sleep(*stepDelay) + } + + if interactiveMode { + if err := runner.AttachInteractive(); err != nil { + if ctx.Err() != nil { + _, _ = fmt.Fprintf(os.Stderr, "command timeout/canceled: %v\n", ctx.Err()) + os.Exit(1) + } + _, _ = fmt.Fprintf(os.Stderr, "interactive attach failed: %v\n", err) + os.Exit(1) + } + } + + if err := runner.Wait(); err != nil { + if ctx.Err() != nil { + _, _ = fmt.Fprintf(os.Stderr, "command timeout/canceled: %v\n", ctx.Err()) + os.Exit(1) + } + _, _ = fmt.Fprintf(os.Stderr, "command exited with error: %v\n", err) + os.Exit(1) + } +} diff --git a/example/copilot-demo/README.md b/example/copilot-demo/README.md new file mode 100644 index 0000000..0303222 --- /dev/null +++ b/example/copilot-demo/README.md @@ -0,0 +1,127 @@ +# copilot-demo 示例 + +> 当前示例已改为复用正式命令实现:`cmds/copilotcmd`。 +> 推荐优先使用 `fastgit copilot ...`,示例仅用于快速本地验证。 + +用于演示如何在 `redant` 中通过 `github/copilot-sdk/go` 复用 Copilot CLI 能力,并与 `agentline` 交互模式打通。 + +## 功能覆盖 + +- 连接与健康检查:`status`(包含 `Ping`、`GetStatus`、`GetAuthStatus`) +- 新会话对话:`chat` +- 恢复会话:`resume` +- 只读查看会话事件:`events` +- 会话管理:`sessions`、`last-session`、`delete-session` +- 模型发现:`models` +- 可视化控制台:`web`(集成 `cmds/webcmd`) +- ACP 权限回合演示:`acp-turn`(支持 allow/deny/cancel 三种决策) +- 双向交互协议演示:`interactive-demo`(通过 interaction bridge 执行 Emit + Ask) +- SDK 能力演示: + - `OnPermissionRequest`(默认 `ApproveAll`) + - `OnUserInputRequest`(自动回答) + - `SessionHooks`(start/end) + - 自定义 Tool(`demo_echo`) + - 流式输出(`--stream`) + +## 快速运行 + +- 查看状态: + - `fastgit copilot status` + - `go run example/copilot-demo/main.go status` +- 新建会话并对话: + - `fastgit copilot chat --prompt "解释一下 redant 的命令分发"` + - `go run example/copilot-demo/main.go chat --prompt "解释一下 redant 的命令分发"` +- 使用 `chat` 继续指定会话(提供 `session-id` 时自动进入 resume 模式): + - `go run example/copilot-demo/main.go chat --session-id --prompt "继续"` + - `go run example/copilot-demo/main.go chat --session-id --prompt "继续" --dump-events` +- 恢复会话继续对话: + - `go run example/copilot-demo/main.go resume --session-id --prompt "继续"` + - `go run example/copilot-demo/main.go resume --session-id --prompt "继续" --dump-events` + - `go run example/copilot-demo/main.go resume --session-id --prompt "继续" --dump-events --events-limit 200 --events-raw` +- 只读查看会话事件(不发送 prompt): + - `go run example/copilot-demo/main.go events --session-id ` + - `go run example/copilot-demo/main.go events --session-id --events-limit 200 --events-raw` + - `go run example/copilot-demo/main.go events --session-id --events-out data.jsonl --events-view timeline` + - `go run example/copilot-demo/main.go events --session-id --events-out data.jsonl --events-view summary` + +> 说明:`--events-out` 默认为 `data.jsonl`(JSONL 逐行记录),便于后续 grep/jq 分析;`--events-view` 用于控制终端展示样式:`timeline`(默认,适合回放全过程)/`summary`(仅统计)/`none`(只导出文件)。 +- 列出会话: + - `go run example/copilot-demo/main.go sessions` +- 补全会话摘要(恢复会话并抓最近消息): + - `go run example/copilot-demo/main.go sessions --hydrate` + - `go run example/copilot-demo/main.go sessions --hydrate --hydrate-timeout 6s --hydrate-max-events 120` + +> 说明:若你看到会话列表大多只有 `id`,通常是 Copilot CLI 当前仅返回了 `sessionId`,未返回 `summary/start/modified/context`。示例会在这种情况下显示 `meta=empty` 并给出提示信息。 + +启用 `--hydrate` 后,示例会尝试短时恢复每个会话并读取最近事件,从而补充 `hydrate.assistant`(最近一条 assistant 消息摘要)与 `hydrate.messages`(事件总数)。 +- 模型列表: + - `go run example/copilot-demo/main.go models` +- 启动可视化页面(默认会自动打开浏览器): + - `go run example/copilot-demo/main.go web` +- 指定地址并禁用自动打开浏览器: + - `go run example/copilot-demo/main.go web --addr 127.0.0.1:18080 --open=false` +- 运行一次 ACP 权限回合(显式命令入口): + - `go run example/copilot-demo/main.go acp-turn --prompt "请修改一个文件"` + - `go run example/copilot-demo/main.go acp-turn --permission-decision deny` + - `go run example/copilot-demo/main.go acp-turn --permission-decision cancel` +- 在 agentline 中演示双向交互协议(推荐): + - `go run example/copilot-demo/main.go agentline` + - 在交互里执行:`/interactive-demo --question "是否继续执行下一步?"` + - 查看并回复待答问题:`/questions`,然后 `/reply 1 继续`(或 `/skip 1`) + +## 与 agentline 联动 + +- 启动交互: + - `go run example/copilot-demo/main.go agentline` +- 启动交互并自动恢复会话: + - `go run example/copilot-demo/main.go agentline --resume-session-id ` + - `go run example/copilot-demo/main.go agentline --resume-session-id --resume-prompt "继续修复这个问题"` +- 在交互中执行 slash 命令: + - `/chat --prompt "给我一个 Go CLI 设计建议"` + - `/resume --session-id --prompt "继续"` + - `/interactive-demo --question "是否继续执行下一步?"` + - `/acp-demo --prompt "请执行一次需要权限确认的操作"` + - `/permissions`(查看待审批请求) + - `/allow 1` / `/deny 1`(对指定请求决策) + - `/questions`、`/reply 1 `、`/skip 1`(处理运行中问答) + +`chat` 与 `resume` 已标记 `agent.command=true`,可作为 slash command 使用。 + +`sessions` 也已标记 `agent.command=true`,可在交互中使用:`/sessions`、`/sessions --hydrate`。 + +## 常用参数 + +- `--copilot-token`:显式传入 GitHub Token(也支持环境变量 `GITHUB_TOKEN`) +- `--copilot-cli-path`:指定 Copilot CLI 可执行路径 +- `--model`:指定模型(默认 `gpt-5`) +- `--reasoning-effort`:推理强度(`low/medium/high/xhigh`) +- `--system-message`:追加系统提示词 +- `--stream`:启用流式输出 + +## 高级配置(当前重点) + +`fastgit copilot` 现已支持会话级高级配置,覆盖你关心的点: + +- `ConfigDir`:`--config-dir` +- 系统提示词:`--system-message-mode`、`--system-message`、`--system-sections-json` +- Skills:`--skill-dirs`、`--disabled-skills` +- 自定义 Tool:`--custom-tools-json`、`--enable-demo-echo-tool` +- MCP:`--mcp-servers-json` +- 自定义 Agent:`--custom-agents-json`、`--agent` + +示例: + +- 指定模型 + 配置目录 + 系统提示词 + - `fastgit copilot chat --prompt "分析当前仓库" --model gpt-5 --config-dir ~/.config/fastgit/copilot --system-message-mode append --system-message "你是资深 Go 架构师"` + +- 启用 skills 目录并禁用某些技能 + - `fastgit copilot chat --prompt "继续" --skill-dirs ./skills --skill-dirs ~/.copilot/skills --disabled-skills experimental-skill` + +- 配置 MCP(JSON) + - `fastgit copilot chat --prompt "读取项目上下文" --mcp-servers-json '{"fs":{"type":"local","command":"node","args":["mcp-fs.js"],"tools":["read_file"]}}'` + +- 配置自定义 agent(JSON)并激活 + - `fastgit copilot chat --prompt "请帮我重构" --custom-agents-json '[{"name":"go-refactor","description":"专注 Go 重构","prompt":"你是 Go 重构专家"}]' --agent go-refactor` + +- 配置自定义 tools(JSON) + - `fastgit copilot chat --prompt "调用自定义工具" --custom-tools-json '[{"name":"ticket_lookup","description":"查询工单","resultText":"TICKET-123: in progress"}]'` diff --git a/example/copilot-demo/main.go b/example/copilot-demo/main.go new file mode 100644 index 0000000..4c4809a --- /dev/null +++ b/example/copilot-demo/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/pubgo/fastgit/cmds/copilotcmd" +) + +func main() { + if err := copilotcmd.New().Invoke().WithOS().Run(); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/example/fastcommit/main.go b/example/fastcommit/main.go new file mode 100644 index 0000000..2efe647 --- /dev/null +++ b/example/fastcommit/main.go @@ -0,0 +1,393 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "time" + + "github.com/pubgo/redant" + agentlineapp "github.com/pubgo/fastgit/cmds/agentlineapp" + "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" + agentlinemodule "github.com/pubgo/fastgit/pkg/agentline" +) + +// 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() { + rootCmd := &redant.Command{ + Use: "fastcommit", + Short: "A fast commit tool.", + Long: "A tool for making fast commits with rich command tree and complex option types.", + } + + 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 advanced options and typed values.", + Metadata: map[string]string{ + agentlinemodule.CommandMetaAgentCommand: "true", + }, + Options: redant.OptionSet{ + { + Flag: "message", + Shorthand: "m", + Description: "Commit message.", + Value: redant.StringOf(&commitMessage), + Default: "update: default message", + }, + { + Flag: "amend", + Description: "Amend the previous commit.", + 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 (positional).", Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + 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 + }, + } + + var ( + detailedAuthor string + detailedVerbose bool + detailedMode string + ) + + detailedCmd := &redant.Command{ + Use: "detailed", + Short: "Detailed commit.", + Long: "Commit with detailed options.", + Options: redant.OptionSet{ + { + Flag: "author", + Description: "Author of the commit.", + Value: redant.StringOf(&detailedAuthor), + }, + { + Flag: "verbose", + Shorthand: "v", + Description: "Verbose output.", + 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.", Value: redant.StringOf(new(string))}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + 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{ + agentlinemodule.CommandMetaAgentCommand: "true", + }, + 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{ + agentlinemodule.CommandMetaAgentCommand: "true", + }, + 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 + }, + } + + commitCmd.Children = append(commitCmd.Children, detailedCmd) + + rootCmd.Children = append(rootCmd.Children, + commitCmd, + releaseShipCmd, + projectCmd, + profileCmd, + completioncmd.New(), + readlinecmd.New(), + richlinecmd.New(), + mcpcmd.New(), + webcmd.New(), + webttycmd.New(), + ) + + rootCmd.Handler = func(ctx context.Context, inv *redant.Invocation) error { + return agentlineapp.Run(ctx, rootCmd, &agentlineapp.RuntimeOptions{ + Prompt: "agent> ", + Stdin: inv.Stdin, + Stdout: inv.Stdout, + }) + } + + err := rootCmd.Invoke().WithOS().Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 0bc96e7..d49e344 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module github.com/pubgo/fastgit -go 1.24.0 - -toolchain go1.24.9 +go 1.25.0 replace ( google.golang.org/genproto => google.golang.org/genproto v0.0.0-20250324211829-b45e905df463 @@ -12,6 +10,9 @@ replace ( require ( atomicgo.dev/cursor v0.2.0 + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 + charm.land/lipgloss/v2 v2.0.2 github.com/a8m/envsubst v1.4.3 github.com/adrg/xdg v0.5.3 github.com/bitfield/script v0.24.1 @@ -19,13 +20,16 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/term v0.2.1 + github.com/charmbracelet/x/term v0.2.2 github.com/cheggaaa/pb/v3 v3.1.7 + github.com/coder/acp-go-sdk v0.6.3 + github.com/creack/pty v1.1.24 github.com/dave/jennifer v1.7.0 github.com/deckarep/golang-set/v2 v2.8.0 github.com/docker/go-units v0.5.0 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 + github.com/github/copilot-sdk/go v0.2.0 github.com/go-git/go-git/v6 v6.0.0-20250922101824-23ffe67a3eb3 github.com/google/go-github/v71 v71.0.0 github.com/gorilla/websocket v1.5.3 @@ -37,7 +41,7 @@ require ( github.com/olekukonko/tablewriter v1.0.8 github.com/pubgo/dix/v2 v2.0.0-beta.10 github.com/pubgo/funk/v2 v2.0.0-beta.8 - github.com/pubgo/redant v0.0.4 + github.com/pubgo/redant v0.1.0 github.com/rs/zerolog v1.34.0 github.com/samber/lo v1.52.0 github.com/sashabaranov/go-openai v1.40.5 @@ -45,7 +49,7 @@ require ( github.com/tidwall/match v1.1.1 github.com/yarlson/tap v0.10.5 golang.org/x/crypto v0.43.0 - golang.org/x/term v0.36.0 + golang.org/x/term v0.41.0 google.golang.org/genai v1.24.0 gopkg.in/yaml.v3 v3.0.1 mvdan.cc/sh/v3 v3.12.0 @@ -73,13 +77,18 @@ require ( github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charlievieth/fastwalk v1.0.12 // indirect - github.com/charmbracelet/colorprofile v0.3.1 // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // 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/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/chzyer/readline v1.5.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect - github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect @@ -100,6 +109,7 @@ require ( github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect @@ -112,16 +122,17 @@ require ( github.com/junegunn/go-shellwords v0.0.0-20250127100254-2aa3b3277741 // indirect github.com/k0kubun/pp/v3 v3.5.0 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/lmittmann/tint v1.1.2 // 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-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -134,6 +145,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/xid v1.6.0 // indirect github.com/samber/slog-common v0.19.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect @@ -141,6 +154,7 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/errs v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.37.0 // indirect @@ -154,9 +168,9 @@ require ( go.uber.org/atomic v1.10.0 // indirect golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect golang.org/x/net v0.46.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect google.golang.org/api v0.241.0 // indirect diff --git a/go.sum b/go.sum index 14df2c0..7bdf39c 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,12 @@ cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +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.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= +charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= @@ -406,8 +412,8 @@ github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= @@ -431,28 +437,44 @@ github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40= -github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +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/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI= github.com/cheggaaa/pb/v3 v3.1.7/go.mod h1:/Ji89zfVPeC/u5j8ukD0MBPHt2bzTYp74lQ7KlgFWTQ= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= +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 v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic= +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 v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +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/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= @@ -481,10 +503,14 @@ github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1Ig github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 h1:aQ3y1lwWyqYPiWZThqv1aFbZMiM9vblcSArJRf2Irls= github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 h1:3A0ES21Ke+FxEM8CXx9n47SZOKOpgSE1bbJzlE4qPVs= -github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0/go.mod h1:5UuS2Ts+nTToAMeOjNlnHFkPahrtDkmpydBen/3wgZc= +github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ= +github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/dave/jennifer v1.7.0 h1:uRbSBH9UTS64yXbh4FrMHfgfY762RD+C7bUPKODpSJE= @@ -566,6 +592,8 @@ github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeekl github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0= +github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= @@ -609,6 +637,8 @@ github.com/goccy/go-yaml v1.9.8/go.mod h1:JubOolP3gh0HpiBc4BLRD4YmjEjHAmIIB2aaXK github.com/goccy/go-yaml v1.11.0/go.mod h1:H+mJrWtjPTJAHvRbV09MCK9xYwODM+wRTVFFTWckfng= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= @@ -687,6 +717,8 @@ github.com/google/go-pkcs11 v0.3.0/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMc github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -822,8 +854,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= github.com/klauspost/compress v1.15.11/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= @@ -843,8 +875,9 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -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/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA= github.com/lyft/protoc-gen-star/v2 v2.0.1/go.mod h1:RcCdONR2ScXaYnQC5tUzxzlpA3WVYF7/opLeUgcQs/o= @@ -867,8 +900,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= @@ -877,6 +910,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk 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/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= @@ -921,8 +956,8 @@ github.com/pubgo/dix/v2 v2.0.0-beta.10 h1:HE1gqY8vzNPPdz4FwN91hWVZpeWkfvuIRAT7dG github.com/pubgo/dix/v2 v2.0.0-beta.10/go.mod h1:jV/9KWf+YxtoQATuZLyUraACduxHvfaum5EZDSCK5gE= github.com/pubgo/funk/v2 v2.0.0-beta.8 h1:zYL/4Cp4T1QuQnEp1EI4jGMsRKaHB91s7sdANSrkebw= github.com/pubgo/funk/v2 v2.0.0-beta.8/go.mod h1:uMQn+vuKx++99J+QZnYTekqzwJHT/j7lAz/qwqQ8PyY= -github.com/pubgo/redant v0.0.4 h1:Yweyxj33Y+j4eE9b36QAn9FcOWPymUE0CxaqOrJgTvs= -github.com/pubgo/redant v0.0.4/go.mod h1:FOBNjL8pPLOBcZS3SL2R5GusFz/bNBwDJzSinGuKs7A= +github.com/pubgo/redant v0.1.0 h1:o4FTEZKth+940QVISIhY7uazGBZFaKaM3/nea5xe0FI= +github.com/pubgo/redant v0.1.0/go.mod h1:pJXH/Im4+1yrUO7AmmQ5lspYzjfKWyW4bCiLg6B41pk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= @@ -949,6 +984,10 @@ github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89 github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= github.com/sashabaranov/go-openai v1.40.5 h1:SwIlNdWflzR1Rxd1gv3pUg6pwPc6cQ2uMoHs8ai+/NY= github.com/sashabaranov/go-openai v1.40.5/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +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/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -1001,6 +1040,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yarlson/tap v0.10.5 h1:wKOsoB1oisxXAAcDbcFO/Vjj9YlvhSTJNktJRv7yrUY= github.com/yarlson/tap v0.10.5/go.mod h1:4cB/n3I9P6D1uS04Jqo8O57ZlzXLT+ebzsqM+24CMC4= +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= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -1366,8 +1407,8 @@ golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1394,8 +1435,8 @@ golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1500,8 +1541,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -1536,8 +1577,8 @@ golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1654,8 +1695,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/agentline/module.go b/pkg/agentline/module.go new file mode 100644 index 0000000..3b8a74b --- /dev/null +++ b/pkg/agentline/module.go @@ -0,0 +1,90 @@ +package agentline + +import "strings" + +const ( + // CommandMetaMode is the metadata key used to mark command mode. + CommandMetaMode = "mode" + // CommandMetaModeAgent indicates the command should run in agent mode. + CommandMetaModeAgent = "agent" + // CommandMetaAgentCommand marks a command as an agent command. + CommandMetaAgentCommand = "agent.command" + // CommandMetaAgentEntry marks a command as an agent entry command. + CommandMetaAgentEntry = "agent.entry" + + // CommandName is the built-in agentline command name. + CommandName = "agentline" + // InitialArgKey is the hidden argument key used for bootstrap argv injection. + InitialArgKey = "initial-arg" +) + +// AgentCommandMetadata returns metadata marking a command as an agent command. +func AgentCommandMetadata() map[string]string { + return map[string]string{CommandMetaAgentCommand: "true"} +} + +// AgentEntryMetadata returns metadata marking a command as an agent entry. +func AgentEntryMetadata() map[string]string { + return map[string]string{CommandMetaAgentEntry: "true"} +} + +// Meta returns metadata value by key. Key lookup is case-insensitive. +func Meta(metadata map[string]string, key string) string { + if strings.TrimSpace(key) == "" || len(metadata) == 0 { + return "" + } + + if v, ok := metadata[key]; ok { + return strings.TrimSpace(v) + } + + for k, v := range metadata { + if strings.EqualFold(strings.TrimSpace(k), key) { + return strings.TrimSpace(v) + } + } + + return "" +} + +// IsAgentCommand reports whether metadata marks a command as agent command. +func IsAgentCommand(metadata map[string]string) bool { + if IsAgentEntryCommand(metadata) { + return true + } + if metaTruthy(Meta(metadata, CommandMetaAgentCommand)) { + return true + } + return false +} + +// IsAgentEntryCommand reports whether metadata marks a command as an +// interactive entry that should auto-route to agentline. +func IsAgentEntryCommand(metadata map[string]string) bool { + if strings.EqualFold(Meta(metadata, CommandMetaMode), CommandMetaModeAgent) { + return true + } + return metaTruthy(Meta(metadata, CommandMetaAgentEntry)) +} + +func metaTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "y", "on": + return true + default: + return false + } +} + +// BuildBootstrapArgs converts raw argv into repeated hidden-flag format. +func BuildBootstrapArgs(initialArgv []string) []string { + if len(initialArgv) == 0 { + return nil + } + + out := make([]string, 0, len(initialArgv)*2) + for _, arg := range initialArgv { + out = append(out, "--"+InitialArgKey, arg) + } + return out +} diff --git a/pkg/gitshell/gitshell.go b/pkg/gitshell/gitshell.go new file mode 100644 index 0000000..8d38b71 --- /dev/null +++ b/pkg/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/pkg/gitshell/gitshell_test.go b/pkg/gitshell/gitshell_test.go new file mode 100644 index 0000000..f30a23d --- /dev/null +++ b/pkg/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/pkg/skills/interface.go b/pkg/skills/interface.go new file mode 100644 index 0000000..1bc61e2 --- /dev/null +++ b/pkg/skills/interface.go @@ -0,0 +1,124 @@ +package skills + +// LocalManager 定义本地 skills 管理能力边界。 +// 目标:让上层(copilotcmd / runtime / mcp tool)只依赖接口而不是具体实现。 +type LocalManager interface { + // Discover 扫描 skills 根目录列表,返回可用技能条目与告警信息。 + // + // 参数: + // - skillDirs: skills 根目录列表(例如 ./skills、./.copilot/skills)。 + // + // 返回: + // - []Entry: 已成功解析的技能集合(已去重)。 + // - []string: 非致命告警(如某个目录不存在、某个 SKILL.md 解析失败)。 + Discover(skillDirs []string) ([]Entry, []string) + + // FindByName 在 Discover 返回的条目中按技能名查找。 + // name 匹配不区分大小写;找不到时返回 error。 + FindByName(entries []Entry, name string) (Entry, error) + + // ReadSkill 读取指定 SKILL.md 原始文本内容。 + // path 应指向具体文件路径,路径为空或读取失败会返回 error。 + ReadSkill(path string) (string, error) + + // CreateSkill 根据输入参数创建技能目录与 SKILL.md 文件。 + // 默认会生成标准模板;若 Force=false 且文件已存在会返回 error。 + CreateSkill(in CreateInput) (Entry, error) + + // BuildTemplate 生成标准 SKILL.md 模板文本(包含 frontmatter 与基础章节)。 + BuildTemplate(name string) string + + // SanitizeName 规范化技能名:小写、空格转中划线,并校验字符合法性。 + // 返回空字符串表示名称非法。 + SanitizeName(name string) string + + // ParseFile 解析单个 SKILL.md 文件,返回结构化结果。 + // fallbackName 用于当前文件缺失可用名称时的兜底值(通常传目录名)。 + ParseFile(path string, fallbackName string) (ParsedSkill, error) + + // ParseContent 解析 SKILL.md 文本内容。 + // 支持:frontmatter 元数据、一级/二级/三级标题提取,以及名称来源判定。 + ParseContent(content, fallbackName string) (ParsedSkill, error) + + // FindSectionContent 按标题路径提取正文内容。 + // + // 用法: + // - headings=[]{"二级标题"} => 返回该 H2 的内容 + // - headings=[]{"二级标题","三级标题"} => 返回该 H3 的内容 + FindSectionContent(sections []Section, headings ...string) (string, bool) + + // ExistingDirs 过滤并返回实际存在的目录列表。 + ExistingDirs(candidates []string) []string + + // DirExists 判断路径是否为存在的目录。 + DirExists(path string) bool + + // CompactStringSlice 清理字符串数组(trim + 去空项)。 + CompactStringSlice(in []string) []string +} + +// Service 是 LocalManager 的兼容别名(保留旧名称,避免外部调用方破坏)。 +// Deprecated: 请优先使用 LocalManager。 +type Service = LocalManager + +// DefaultLocalManager 是 LocalManager 的默认实现,直接复用当前包内函数。 +type DefaultLocalManager struct{} + +// NewLocalManager 返回默认 LocalManager 实现。 +func NewLocalManager() LocalManager { + return DefaultLocalManager{} +} + +// NewService 返回默认 Service 实现(兼容旧接口名)。 +// 上层可以用这个构造默认行为,也可以注入自定义实现用于 mock/替换 runtime。 +func NewService() Service { + return NewLocalManager() +} + +func (DefaultLocalManager) Discover(skillDirs []string) ([]Entry, []string) { + return Discover(skillDirs) +} + +func (DefaultLocalManager) FindByName(entries []Entry, name string) (Entry, error) { + return FindByName(entries, name) +} + +func (DefaultLocalManager) ReadSkill(path string) (string, error) { + return ReadSkill(path) +} + +func (DefaultLocalManager) CreateSkill(in CreateInput) (Entry, error) { + return CreateSkill(in) +} + +func (DefaultLocalManager) BuildTemplate(name string) string { + return BuildTemplate(name) +} + +func (DefaultLocalManager) SanitizeName(name string) string { + return SanitizeName(name) +} + +func (DefaultLocalManager) ParseFile(path string, fallbackName string) (ParsedSkill, error) { + return ParseFile(path, fallbackName) +} + +func (DefaultLocalManager) ParseContent(content, fallbackName string) (ParsedSkill, error) { + return ParseContent(content, fallbackName) +} + +func (DefaultLocalManager) FindSectionContent(sections []Section, headings ...string) (string, bool) { + return FindSectionContent(sections, headings...) +} + +func (DefaultLocalManager) ExistingDirs(candidates []string) []string { + return ExistingDirs(candidates) +} + +func (DefaultLocalManager) DirExists(path string) bool { + return DirExists(path) +} + +func (DefaultLocalManager) CompactStringSlice(in []string) []string { + return CompactStringSlice(in) +} diff --git a/pkg/skills/management.go b/pkg/skills/management.go new file mode 100644 index 0000000..4ab4071 --- /dev/null +++ b/pkg/skills/management.go @@ -0,0 +1,30 @@ +package skills + +// Manager 聚合本地与远程两类管理能力。 +// 上层只依赖 Manager,可在不同 runtime 中替换具体实现。 +type Manager interface { + Local() LocalManager + Remote() RemoteManager +} + +// DefaultManager 是默认管理器:本地能力使用当前模块实现,远程能力默认占位实现。 +type DefaultManager struct { + local LocalManager + remote RemoteManager +} + +// NewManager 构建默认聚合管理器。 +func NewManager() Manager { + return DefaultManager{ + local: NewLocalManager(), + remote: NewUnsupportedRemoteManager(), + } +} + +func (m DefaultManager) Local() LocalManager { + return m.local +} + +func (m DefaultManager) Remote() RemoteManager { + return m.remote +} diff --git a/pkg/skills/module.go b/pkg/skills/module.go new file mode 100644 index 0000000..0d3fbe2 --- /dev/null +++ b/pkg/skills/module.go @@ -0,0 +1,663 @@ +package skills + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +type Entry struct { + ID string `json:"id,omitempty"` + Kind string `json:"kind,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Slug string `json:"slug,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Summary string `json:"summary,omitempty"` + Version string `json:"version,omitempty"` + Tags []string `json:"tags,omitempty"` + UseWhen []string `json:"useWhen,omitempty"` + Tools []string `json:"tools,omitempty"` + Path string `json:"path"` + Dir string `json:"dir"` + Description string `json:"description,omitempty"` + Title string `json:"title,omitempty"` + H2 []string `json:"h2,omitempty"` + H3 []string `json:"h3,omitempty"` + Sections []Section `json:"sections,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + Source string `json:"source,omitempty"` +} + +type Section struct { + Title string `json:"title"` + Content string `json:"content,omitempty"` + Subsections []Subsection `json:"subsections,omitempty"` +} + +type Subsection struct { + Title string `json:"title"` + Content string `json:"content,omitempty"` +} + +type CreateInput struct { + Name string + BaseDir string + Force bool + Template string + DirPerm os.FileMode + FilePerm os.FileMode +} + +type frontmatter struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Summary string `yaml:"summary"` +} + +type ParsedSkill struct { + Name string + Slug string + Description string + Summary string + Version string + Tags []string + UseWhen []string + Tools []string + Title string + H2 []string + H3 []string + Sections []Section + Metadata map[string]any + Source string +} + +func Discover(skillDirs []string) ([]Entry, []string) { + warns := make([]string, 0) + entries := make([]Entry, 0) + seen := map[string]struct{}{} + + for _, root := range CompactStringSlice(skillDirs) { + st, err := os.Stat(root) + if err != nil { + warns = append(warns, fmt.Sprintf("skill dir not available: %s (%v)", root, err)) + continue + } + if !st.IsDir() { + warns = append(warns, fmt.Sprintf("skill path is not directory: %s", root)) + continue + } + children, err := os.ReadDir(root) + if err != nil { + warns = append(warns, fmt.Sprintf("read skill dir failed: %s (%v)", root, err)) + continue + } + for _, child := range children { + if !child.IsDir() { + continue + } + dirName := strings.TrimSpace(child.Name()) + if dirName == "" { + continue + } + skillDir := filepath.Join(root, dirName) + skillPath := filepath.Join(skillDir, "SKILL.md") + if _, err := os.Stat(skillPath); err != nil { + continue + } + parsed, err := ParseFile(skillPath, dirName) + if err != nil { + warns = append(warns, fmt.Sprintf("parse skill failed: %s (%v)", skillPath, err)) + continue + } + if parsed.Source == "frontmatter.name" && !strings.EqualFold(parsed.Name, dirName) { + warns = append(warns, fmt.Sprintf("skill name mismatch: dir=%s frontmatter.name=%s", dirName, parsed.Name)) + } + + key := strings.ToLower(parsed.Name) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + entries = append(entries, Entry{ + ID: buildSkillID(root, parsed.Name), + Kind: "local", + Namespace: strings.TrimSpace(root), + Name: parsed.Name, + Slug: parsed.Slug, + DisplayName: parsed.Title, + Summary: parsed.Summary, + Version: parsed.Version, + Tags: parsed.Tags, + UseWhen: parsed.UseWhen, + Tools: parsed.Tools, + Path: skillPath, + Dir: skillDir, + Description: parsed.Description, + Title: parsed.Title, + H2: parsed.H2, + H3: parsed.H3, + Sections: parsed.Sections, + Metadata: parsed.Metadata, + Source: parsed.Source, + }) + } + } + + sort.Slice(entries, func(i, j int) bool { + return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) + }) + return entries, warns +} + +func FindByName(entries []Entry, name string) (Entry, error) { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + return Entry{}, fmt.Errorf("skill name is required") + } + for _, s := range entries { + if strings.ToLower(strings.TrimSpace(s.Name)) == name { + return s, nil + } + } + return Entry{}, fmt.Errorf("skill not found: %s", name) +} + +func ReadSkill(path string) (string, error) { + path = strings.TrimSpace(path) + if path == "" { + return "", fmt.Errorf("skill path is required") + } + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read skill(%s): %w", path, err) + } + return string(content), nil +} + +func CreateSkill(in CreateInput) (Entry, error) { + name := SanitizeName(in.Name) + if name == "" { + return Entry{}, fmt.Errorf("invalid skill name") + } + + baseDir := strings.TrimSpace(in.BaseDir) + if baseDir == "" { + baseDir = "./skills" + } + targetDir := filepath.Join(baseDir, name) + targetFile := filepath.Join(targetDir, "SKILL.md") + + dirPerm := in.DirPerm + if dirPerm == 0 { + dirPerm = 0o755 + } + filePerm := in.FilePerm + if filePerm == 0 { + filePerm = 0o644 + } + if err := os.MkdirAll(targetDir, dirPerm); err != nil { + return Entry{}, fmt.Errorf("mkdir skill dir(%s): %w", targetDir, err) + } + if !in.Force { + if _, err := os.Stat(targetFile); err == nil { + return Entry{}, fmt.Errorf("skill already exists: %s (use force to overwrite)", targetFile) + } + } + + tpl := strings.TrimSpace(in.Template) + if tpl == "" { + tpl = BuildTemplate(name) + } + if !strings.HasSuffix(tpl, "\n") { + tpl += "\n" + } + + if err := os.WriteFile(targetFile, []byte(tpl), filePerm); err != nil { + return Entry{}, fmt.Errorf("write skill file(%s): %w", targetFile, err) + } + return Entry{ + ID: buildSkillID(baseDir, name), + Kind: "local", + Namespace: strings.TrimSpace(baseDir), + Name: name, + Slug: name, + Path: targetFile, + Dir: targetDir, + }, nil +} + +func ExistingDirs(candidates []string) []string { + out := make([]string, 0, len(candidates)) + for _, dir := range CompactStringSlice(candidates) { + if DirExists(dir) { + out = append(out, dir) + } + } + return out +} + +func DirExists(path string) bool { + if strings.TrimSpace(path) == "" { + return false + } + st, err := os.Stat(path) + return err == nil && st.IsDir() +} + +func SanitizeName(name string) string { + name = strings.TrimSpace(strings.ToLower(name)) + name = strings.ReplaceAll(name, " ", "-") + name = strings.ReplaceAll(name, "_", "-") + name = strings.Trim(name, "-/") + if name == "" { + return "" + } + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return "" + } + return name +} + +func BuildTemplate(name string) string { + name = SanitizeName(name) + lines := []string{ + "---", + "name: " + name, + "description: \"Use when: 描述这个 skill 适用场景(关键词越具体越好)\"", + "argument-hint: \"输入任务目标、约束和涉及模块\"", + "metadata:", + " summary: \"" + name + " 技能摘要\"", + " version: \"0.1.0\"", + " tags: [\"repo\", \"workflow\"]", + " use_when: [\"当你需要处理该类任务时\"]", + " tools: [\"skills_tool\"]", + "---", + "", + "# " + name, + "", + "## 目标", + "- 简要描述这个 skill 的结果目标。", + "", + "## 执行步骤", + "1. 理解问题与边界。", + "2. 检索并确认相关文件。", + "3. 给出最小可执行方案并验证。", + "", + "## 约束", + "- 不要臆造事实。", + "- 信息不足时先说明缺失上下文。", + "", + "## 输出契约", + "- 变更摘要(文件 + 目的)", + "- 验证结果(测试/检查)", + "- 已知风险与后续动作", + } + return strings.Join(lines, "\n") +} + +func ParseFile(path string, fallbackName string) (ParsedSkill, error) { + content, err := ReadSkill(path) + if err != nil { + return ParsedSkill{}, err + } + return ParseContent(content, fallbackName) +} + +func ParseContent(content, fallbackName string) (ParsedSkill, error) { + fallbackName = SanitizeName(fallbackName) + if fallbackName == "" { + fallbackName = "skill" + } + + result := ParsedSkill{Name: fallbackName, Slug: fallbackName, Source: "directory", Metadata: map[string]any{}} + body := strings.TrimSpace(content) + if body == "" { + return result, nil + } + + fm, fmRaw, rest, hasFM, err := splitFrontmatter(body) + if err != nil { + return ParsedSkill{}, err + } + if hasFM { + meta, err := parseFrontmatterMap(fmRaw) + if err != nil { + return ParsedSkill{}, err + } + result.Metadata = meta + result.Summary = strings.TrimSpace(fm.Summary) + if strings.TrimSpace(fm.Name) != "" { + n := SanitizeName(fm.Name) + if n == "" { + return ParsedSkill{}, fmt.Errorf("invalid frontmatter name: %q", fm.Name) + } + result.Name = n + result.Slug = n + result.Source = "frontmatter.name" + } + if strings.TrimSpace(fm.Description) != "" { + result.Description = strings.TrimSpace(fm.Description) + } + spec := extractSkillSpec(meta) + if result.Summary == "" { + result.Summary = spec.Summary + } + if result.Description == "" { + result.Description = spec.Summary + } + result.Version = spec.Version + result.Tags = spec.Tags + result.UseWhen = spec.UseWhen + result.Tools = spec.Tools + body = rest + } + + title, h2, h3, sections := extractHeadings(body) + if title != "" { + result.Title = title + h := SanitizeName(title) + if h != "" && result.Source == "directory" { + result.Name = h + result.Slug = h + result.Source = "heading" + } + } + result.H2 = h2 + result.H3 = h3 + result.Sections = sections + + return result, nil +} + +func splitFrontmatter(content string) (frontmatter, string, string, bool, error) { + lines := strings.Split(content, "\n") + if len(lines) == 0 { + return frontmatter{}, "", content, false, nil + } + if strings.TrimSpace(lines[0]) != "---" { + return frontmatter{}, "", content, false, nil + } + + end := -1 + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "---" { + end = i + break + } + } + if end == -1 { + return frontmatter{}, "", "", true, fmt.Errorf("frontmatter start found but closing --- missing") + } + + raw := strings.Join(lines[1:end], "\n") + var fm frontmatter + if strings.TrimSpace(raw) != "" { + if err := yaml.Unmarshal([]byte(raw), &fm); err != nil { + return frontmatter{}, raw, "", true, fmt.Errorf("invalid frontmatter yaml: %w", err) + } + } + rest := strings.Join(lines[end+1:], "\n") + return fm, raw, strings.TrimSpace(rest), true, nil +} + +func extractHeadings(content string) (title string, h2 []string, h3 []string, sections []Section) { + h2 = make([]string, 0) + h3 = make([]string, 0) + sections = make([]Section, 0) + + currentH2 := -1 + currentH3 := -1 + + appendContent := func(line string) { + if currentH2 < 0 { + return + } + if currentH3 >= 0 { + if sections[currentH2].Subsections[currentH3].Content == "" { + sections[currentH2].Subsections[currentH3].Content = line + } else { + sections[currentH2].Subsections[currentH3].Content += "\n" + line + } + return + } + if sections[currentH2].Content == "" { + sections[currentH2].Content = line + } else { + sections[currentH2].Content += "\n" + line + } + } + + for _, line := range strings.Split(content, "\n") { + raw := line + line = strings.TrimSpace(raw) + if line == "" { + appendContent("") + continue + } + if strings.HasPrefix(line, "# ") { + if title == "" { + title = strings.TrimSpace(strings.TrimPrefix(line, "# ")) + } + continue + } + if strings.HasPrefix(line, "### ") { + label := strings.TrimSpace(strings.TrimPrefix(line, "### ")) + if label != "" { + if currentH2 < 0 { + sections = append(sections, Section{Title: "ungrouped"}) + h2 = append(h2, "ungrouped") + currentH2 = len(sections) - 1 + } + sections[currentH2].Subsections = append(sections[currentH2].Subsections, Subsection{Title: label}) + currentH3 = len(sections[currentH2].Subsections) - 1 + h3 = append(h3, label) + } + continue + } + if strings.HasPrefix(line, "## ") { + label := strings.TrimSpace(strings.TrimPrefix(line, "## ")) + if label != "" { + sections = append(sections, Section{Title: label}) + currentH2 = len(sections) - 1 + currentH3 = -1 + h2 = append(h2, label) + } + continue + } + if strings.HasPrefix(line, "---") { + continue + } + appendContent(raw) + } + + for i := range sections { + sections[i].Content = strings.TrimSpace(sections[i].Content) + for j := range sections[i].Subsections { + sections[i].Subsections[j].Content = strings.TrimSpace(sections[i].Subsections[j].Content) + } + } + + return title, h2, h3, sections +} + +func FindSectionContent(sections []Section, headings ...string) (string, bool) { + if len(headings) == 0 { + return "", false + } + h2Key := strings.TrimSpace(headings[0]) + if h2Key == "" { + return "", false + } + + for _, sec := range sections { + if !strings.EqualFold(strings.TrimSpace(sec.Title), h2Key) { + continue + } + if len(headings) == 1 { + return sec.Content, true + } + h3Key := strings.TrimSpace(headings[1]) + for _, sub := range sec.Subsections { + if strings.EqualFold(strings.TrimSpace(sub.Title), h3Key) { + return sub.Content, true + } + } + return "", false + } + + return "", false +} + +func parseFrontmatterMap(raw string) (map[string]any, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return map[string]any{}, nil + } + var meta map[string]any + if err := yaml.Unmarshal([]byte(raw), &meta); err != nil { + return nil, fmt.Errorf("invalid frontmatter yaml: %w", err) + } + if meta == nil { + return map[string]any{}, nil + } + return meta, nil +} + +type skillSpec struct { + Summary string + Version string + Tags []string + UseWhen []string + Tools []string +} + +func extractSkillSpec(meta map[string]any) skillSpec { + if meta == nil { + return skillSpec{} + } + return skillSpec{ + Summary: firstString(meta, "summary", "brief", "abstract", "metadata.summary", "metadata.brief", "metadata.abstract"), + Version: firstString(meta, "version", "skill_version", "metadata.version", "metadata.skill_version"), + Tags: firstStringSlice(meta, "tags", "keywords", "metadata.tags", "metadata.keywords"), + UseWhen: firstStringSlice(meta, "use_when", "useWhen", "when", "metadata.use_when", "metadata.useWhen", "metadata.when"), + Tools: firstStringSlice(meta, "tools", "available_tools", "tool_allowlist", "metadata.tools", "metadata.available_tools", "metadata.tool_allowlist"), + } +} + +func firstString(meta map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := lookupMeta(meta, k); ok { + s := strings.TrimSpace(fmt.Sprint(v)) + if s != "" && s != "" { + return s + } + } + } + return "" +} + +func firstStringSlice(meta map[string]any, keys ...string) []string { + for _, k := range keys { + if v, ok := lookupMeta(meta, k); ok { + items := toStringSlice(v) + if len(items) > 0 { + return items + } + } + } + return nil +} + +func toStringSlice(v any) []string { + switch tv := v.(type) { + case string: + trimmed := strings.TrimSpace(tv) + if trimmed == "" { + return nil + } + return []string{trimmed} + case []string: + return CompactStringSlice(tv) + case []any: + out := make([]string, 0, len(tv)) + for _, item := range tv { + s := strings.TrimSpace(fmt.Sprint(item)) + if s != "" && s != "" { + out = append(out, s) + } + } + return CompactStringSlice(out) + default: + s := strings.TrimSpace(fmt.Sprint(tv)) + if s == "" || s == "" { + return nil + } + return []string{s} + } +} + +func lookupMeta(meta map[string]any, key string) (any, bool) { + if meta == nil { + return nil, false + } + if !strings.Contains(key, ".") { + v, ok := meta[key] + return v, ok + } + parts := strings.Split(key, ".") + current := any(meta) + for _, p := range parts { + switch node := current.(type) { + case map[string]any: + v, ok := node[p] + if !ok { + return nil, false + } + current = v + case map[any]any: + v, ok := node[p] + if !ok { + return nil, false + } + current = v + default: + return nil, false + } + } + return current, true +} + +func buildSkillID(namespace, name string) string { + namespace = strings.TrimSpace(namespace) + name = SanitizeName(name) + if namespace == "" { + namespace = "local" + } + if name == "" { + name = "skill" + } + return namespace + ":" + name +} + +func CompactStringSlice(in []string) []string { + out := make([]string, 0, len(in)) + for _, item := range in { + trimmed := strings.TrimSpace(item) + if trimmed == "" { + continue + } + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/pkg/skills/module_test.go b/pkg/skills/module_test.go new file mode 100644 index 0000000..053db4b --- /dev/null +++ b/pkg/skills/module_test.go @@ -0,0 +1,376 @@ +package skills + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParseContent_FrontmatterPreferred(t *testing.T) { + content := `--- +name: go-review +description: "Use when: review go changes" +summary: "review staged diff and suggest commit" +version: "1.2.3" +tags: ["go", "review"] +use_when: ["review go changes", "check staged diff"] +tools: ["git", "skills_tool"] +--- + +# should-be-ignored +` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + if parsed.Name != "go-review" { + t.Fatalf("expected name go-review, got %s", parsed.Name) + } + if parsed.Description != "Use when: review go changes" { + t.Fatalf("unexpected description: %s", parsed.Description) + } + if parsed.Summary != "review staged diff and suggest commit" { + t.Fatalf("unexpected summary: %s", parsed.Summary) + } + if parsed.Version != "1.2.3" { + t.Fatalf("unexpected version: %s", parsed.Version) + } + if len(parsed.Tags) != 2 || parsed.Tags[0] != "go" { + t.Fatalf("unexpected tags: %#v", parsed.Tags) + } + if len(parsed.UseWhen) != 2 { + t.Fatalf("unexpected use_when: %#v", parsed.UseWhen) + } + if len(parsed.Tools) != 2 { + t.Fatalf("unexpected tools: %#v", parsed.Tools) + } + if parsed.Source != "frontmatter.name" { + t.Fatalf("expected source frontmatter.name, got %s", parsed.Source) + } +} + +func TestParseContent_FallbackToHeading(t *testing.T) { + content := `# Repo Context + +something` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + if parsed.Name != "repo-context" { + t.Fatalf("expected name repo-context, got %s", parsed.Name) + } + if parsed.Slug != "repo-context" { + t.Fatalf("expected slug repo-context, got %s", parsed.Slug) + } + if parsed.Source != "heading" { + t.Fatalf("expected source heading, got %s", parsed.Source) + } +} + +func TestParseContent_InvalidFrontmatterName(t *testing.T) { + content := `--- +name: "bad name !" +--- + +# test` + + _, err := ParseContent(content, "fallback") + if err == nil { + t.Fatalf("expected error for invalid frontmatter name") + } +} + +func TestParseContent_MetadataAndHeadingHierarchy(t *testing.T) { + content := `--- +name: runtime-bridge +description: "Use when: building runtime adapter" +owner: infra +tags: + - runtime + - adapter +--- + +# Runtime Bridge + +## Scope +text + +### Step 1 +text + +### Step 2 +text + +## Notes +` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + if parsed.Name != "runtime-bridge" { + t.Fatalf("expected name runtime-bridge, got %s", parsed.Name) + } + if parsed.Title != "Runtime Bridge" { + t.Fatalf("expected title Runtime Bridge, got %s", parsed.Title) + } + if len(parsed.H2) != 2 || parsed.H2[0] != "Scope" || parsed.H2[1] != "Notes" { + t.Fatalf("unexpected h2 headings: %#v", parsed.H2) + } + if len(parsed.H3) != 2 || parsed.H3[0] != "Step 1" || parsed.H3[1] != "Step 2" { + t.Fatalf("unexpected h3 headings: %#v", parsed.H3) + } + if parsed.Metadata["owner"] != "infra" { + t.Fatalf("expected metadata owner=infra, got %#v", parsed.Metadata["owner"]) + } + if parsed.Source != "frontmatter.name" { + t.Fatalf("expected source frontmatter.name, got %s", parsed.Source) + } + if len(parsed.Sections) != 2 { + t.Fatalf("expected 2 sections, got %d", len(parsed.Sections)) + } + if parsed.Sections[0].Title != "Scope" { + t.Fatalf("expected first section Scope, got %s", parsed.Sections[0].Title) + } + if len(parsed.Sections[0].Subsections) != 2 { + t.Fatalf("expected 2 subsections under Scope, got %d", len(parsed.Sections[0].Subsections)) + } + if parsed.Sections[0].Subsections[0].Title != "Step 1" { + t.Fatalf("expected first subsection Step 1, got %s", parsed.Sections[0].Subsections[0].Title) + } +} + +func TestFindSectionContent(t *testing.T) { + content := `# Skill Title + +## Scope +scope line 1 +scope line 2 + +### Step 1 +step1 content + +### Step 2 +step2 content + +## Notes +notes content` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + + scope, ok := FindSectionContent(parsed.Sections, "Scope") + if !ok || !strings.Contains(scope, "scope line 1") { + t.Fatalf("expected to find Scope content, got ok=%v content=%q", ok, scope) + } + + step2, ok := FindSectionContent(parsed.Sections, "Scope", "Step 2") + if !ok || !strings.Contains(step2, "step2 content") { + t.Fatalf("expected to find Step 2 content, got ok=%v content=%q", ok, step2) + } + + _, ok = FindSectionContent(parsed.Sections, "Scope", "Unknown") + if ok { + t.Fatalf("expected not found for unknown subsection") + } +} + +func TestDiscover_HeadingAndFrontmatterExtraction(t *testing.T) { + root := t.TempDir() + + headingDir := filepath.Join(root, "heading-only") + if err := os.MkdirAll(headingDir, 0o755); err != nil { + t.Fatalf("mkdir heading dir: %v", err) + } + if err := os.WriteFile(filepath.Join(headingDir, "SKILL.md"), []byte(`# Repo Context + +plain heading skill`), 0o644); err != nil { + t.Fatalf("write heading skill: %v", err) + } + + frontmatterDir := filepath.Join(root, "frontmatter-skill") + if err := os.MkdirAll(frontmatterDir, 0o755); err != nil { + t.Fatalf("mkdir frontmatter dir: %v", err) + } + if err := os.WriteFile(filepath.Join(frontmatterDir, "SKILL.md"), []byte(`--- +name: go-review +description: "Use when: review go changes" +--- + +# ignored-heading`), 0o644); err != nil { + t.Fatalf("write frontmatter skill: %v", err) + } + + brokenDir := filepath.Join(root, "broken-skill") + if err := os.MkdirAll(brokenDir, 0o755); err != nil { + t.Fatalf("mkdir broken dir: %v", err) + } + if err := os.WriteFile(filepath.Join(brokenDir, "SKILL.md"), []byte("---\nname: bad\n"), 0o644); err != nil { + t.Fatalf("write broken skill: %v", err) + } + + entries, warns := Discover([]string{root}) + if len(entries) != 2 { + t.Fatalf("expected 2 valid discovered skills, got %d", len(entries)) + } + + foundHeading := false + foundFrontmatter := false + for _, e := range entries { + switch e.Name { + case "repo-context": + foundHeading = true + if e.Source != "heading" { + t.Fatalf("expected repo-context source=heading, got %s", e.Source) + } + if e.Kind != "local" { + t.Fatalf("expected kind=local, got %s", e.Kind) + } + if e.Slug != "repo-context" { + t.Fatalf("expected slug repo-context, got %s", e.Slug) + } + if e.Title != "Repo Context" { + t.Fatalf("expected title Repo Context, got %s", e.Title) + } + case "go-review": + foundFrontmatter = true + if e.Source != "frontmatter.name" { + t.Fatalf("expected go-review source=frontmatter.name, got %s", e.Source) + } + if e.Description != "Use when: review go changes" { + t.Fatalf("unexpected description: %s", e.Description) + } + if e.ID == "" { + t.Fatalf("expected non-empty id") + } + if e.Namespace != root { + t.Fatalf("expected namespace=%s, got %s", root, e.Namespace) + } + if e.Metadata["name"] != "go-review" { + t.Fatalf("expected metadata name=go-review, got %#v", e.Metadata["name"]) + } + } + } + if !foundHeading { + t.Fatalf("expected discovered heading-based skill repo-context") + } + if !foundFrontmatter { + t.Fatalf("expected discovered frontmatter-based skill go-review") + } + + hasBrokenWarn := false + for _, w := range warns { + if strings.Contains(w, "parse skill failed") && strings.Contains(w, "broken-skill") { + hasBrokenWarn = true + break + } + } + if !hasBrokenWarn { + t.Fatalf("expected parse warning for broken-skill, got warns=%v", warns) + } +} + +func TestParseContent_SpecFallbackKeys(t *testing.T) { + content := `--- +name: codex-like +abstract: "portable skill summary" +skill_version: "2026.03" +keywords: ["agent", "skill"] +when: "when model requests workspace context" +available_tools: ["skills_tool", "grep"] +--- + +# Codex Like +` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + if parsed.Summary != "portable skill summary" { + t.Fatalf("expected summary from abstract, got %s", parsed.Summary) + } + if parsed.Version != "2026.03" { + t.Fatalf("expected version from skill_version, got %s", parsed.Version) + } + if len(parsed.Tags) != 2 || parsed.Tags[1] != "skill" { + t.Fatalf("unexpected tags: %#v", parsed.Tags) + } + if len(parsed.UseWhen) != 1 || parsed.UseWhen[0] != "when model requests workspace context" { + t.Fatalf("unexpected use_when: %#v", parsed.UseWhen) + } + if len(parsed.Tools) != 2 || parsed.Tools[0] != "skills_tool" { + t.Fatalf("unexpected tools: %#v", parsed.Tools) + } +} + +func TestParseContent_NestedMetadataFields(t *testing.T) { + content := `--- +name: nested-meta +description: "Use when: parse metadata nested fields" +metadata: + summary: "nested summary" + version: "2.0.0" + tags: ["nested", "meta"] + use_when: ["need nested support"] + tools: ["skills_tool"] +--- + +# Nested Meta +` + + parsed, err := ParseContent(content, "fallback") + if err != nil { + t.Fatalf("ParseContent returned error: %v", err) + } + if parsed.Summary != "nested summary" { + t.Fatalf("expected nested summary, got %s", parsed.Summary) + } + if parsed.Version != "2.0.0" { + t.Fatalf("expected nested version, got %s", parsed.Version) + } + if len(parsed.Tags) != 2 || parsed.Tags[0] != "nested" { + t.Fatalf("unexpected tags: %#v", parsed.Tags) + } + if len(parsed.UseWhen) != 1 || parsed.UseWhen[0] != "need nested support" { + t.Fatalf("unexpected use_when: %#v", parsed.UseWhen) + } + if len(parsed.Tools) != 1 || parsed.Tools[0] != "skills_tool" { + t.Fatalf("unexpected tools: %#v", parsed.Tools) + } +} + +func TestBuildTemplate_CompatibleSchemaAndParseable(t *testing.T) { + tpl := BuildTemplate("Repo Context") + if !strings.Contains(tpl, "argument-hint:") { + t.Fatalf("expected argument-hint in template") + } + if !strings.Contains(tpl, "metadata:") { + t.Fatalf("expected metadata block in template") + } + parts := strings.Split(tpl, "---") + if len(parts) < 3 { + t.Fatalf("expected template with frontmatter separators") + } + frontmatter := parts[1] + if strings.Contains(frontmatter, "\t") { + t.Fatalf("frontmatter should not contain tabs in YAML indentation") + } + + parsed, err := ParseContent(tpl, "fallback") + if err != nil { + t.Fatalf("template should be parseable, got error: %v", err) + } + if parsed.Name != "repo-context" { + t.Fatalf("expected parsed name repo-context, got %s", parsed.Name) + } + if parsed.Summary == "" { + t.Fatalf("expected parsed summary from metadata") + } +} diff --git a/pkg/skills/remote_interface.go b/pkg/skills/remote_interface.go new file mode 100644 index 0000000..f8c5b81 --- /dev/null +++ b/pkg/skills/remote_interface.go @@ -0,0 +1,51 @@ +package skills + +import "context" + +// RemoteManager 定义远程 skills 管理能力(远端仓库/服务的增删查改与同步)。 +// 该文件仅承载“接口与数据结构设计”,不包含具体实现。 +type RemoteManager interface { + // List 列出命名空间下的远程技能摘要。 + List(ctx context.Context, namespace string) ([]RemoteEntry, error) + // Get 获取远程技能完整内容。 + Get(ctx context.Context, namespace, name string) (RemoteSkill, error) + // Upsert 创建或更新远程技能。 + Upsert(ctx context.Context, skill RemoteSkill) (RemoteEntry, error) + // Delete 删除远程技能。 + Delete(ctx context.Context, namespace, name string) error + // Pull 从远端拉取技能并落地到本地。 + Pull(ctx context.Context, req PullRequest) (Entry, error) + // Push 将本地技能推送到远端。 + Push(ctx context.Context, req PushRequest) (RemoteEntry, error) +} + +// RemoteEntry 表示远程技能条目的摘要信息。 +type RemoteEntry struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + Description string `json:"description,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// RemoteSkill 表示远程技能的完整实体。 +type RemoteSkill struct { + RemoteEntry + Content string `json:"content"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// PullRequest 描述从远端拉取并落地到本地目录的请求。 +type PullRequest struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + TargetDir string `json:"targetDir"` + Force bool `json:"force"` +} + +// PushRequest 描述将本地 skill 推送到远端的请求。 +type PushRequest struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Path string `json:"path"` +} diff --git a/pkg/skills/remote_unsupported.go b/pkg/skills/remote_unsupported.go new file mode 100644 index 0000000..2bd394d --- /dev/null +++ b/pkg/skills/remote_unsupported.go @@ -0,0 +1,56 @@ +package skills + +import ( + "context" + "fmt" +) + +// UnsupportedRemoteManager 是默认远程管理占位实现。 +// 当项目尚未接入远程存储(HTTP/Git/MCP)时,所有远程操作会返回清晰错误。 +type UnsupportedRemoteManager struct{} + +func NewUnsupportedRemoteManager() RemoteManager { + return UnsupportedRemoteManager{} +} + +func (UnsupportedRemoteManager) List(ctx context.Context, namespace string) ([]RemoteEntry, error) { + _ = ctx + _ = namespace + return nil, errRemoteNotConfigured("List") +} + +func (UnsupportedRemoteManager) Get(ctx context.Context, namespace, name string) (RemoteSkill, error) { + _ = ctx + _ = namespace + _ = name + return RemoteSkill{}, errRemoteNotConfigured("Get") +} + +func (UnsupportedRemoteManager) Upsert(ctx context.Context, skill RemoteSkill) (RemoteEntry, error) { + _ = ctx + _ = skill + return RemoteEntry{}, errRemoteNotConfigured("Upsert") +} + +func (UnsupportedRemoteManager) Delete(ctx context.Context, namespace, name string) error { + _ = ctx + _ = namespace + _ = name + return errRemoteNotConfigured("Delete") +} + +func (UnsupportedRemoteManager) Pull(ctx context.Context, req PullRequest) (Entry, error) { + _ = ctx + _ = req + return Entry{}, errRemoteNotConfigured("Pull") +} + +func (UnsupportedRemoteManager) Push(ctx context.Context, req PushRequest) (RemoteEntry, error) { + _ = ctx + _ = req + return RemoteEntry{}, errRemoteNotConfigured("Push") +} + +func errRemoteNotConfigured(method string) error { + return fmt.Errorf("skills remote manager not configured: %s", method) +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..b16f1ab --- /dev/null +++ b/skills/README.md @@ -0,0 +1,32 @@ +# Project Skills + +这个目录用于存放项目级 Skills。 + +建议结构: + +- `skills//SKILL.md` + +示例: + +- `skills/repo-context/SKILL.md` + +推荐 frontmatter(兼容 schema): + +- `name` +- `description` +- `argument-hint` +- `metadata`(将扩展字段放到这里,如 `summary/version/tags/use_when/tools`) + +推荐正文结构: + +1. `# ` +2. `## 目标` +3. `## 执行步骤` +4. `## 约束` +5. `## 输出契约` + +实践建议: + +- 任务相关的“何时使用”写在 `description` 与 `metadata.use_when`。 +- 工具边界写在 `metadata.tools`,避免技能行为漂移。 +- 保持最小可执行流程,减少抽象描述。 diff --git a/skills/repo-context/SKILL.md b/skills/repo-context/SKILL.md new file mode 100644 index 0000000..d0dd221 --- /dev/null +++ b/skills/repo-context/SKILL.md @@ -0,0 +1,46 @@ +--- +name: repo-context +description: "Use when: 需要理解项目结构、定位代码入口、确认真实实现路径与依赖关系。" +"argument-hint": "说明你要分析的功能、报错或文件线索。" +metadata: + summary: "在实现或评审前快速建立仓库事实基线,避免臆测。" + version: "1.0.0" + tags: ["codebase", "navigation", "analysis", "copilot"] + use_when: + - "用户要求修改/重构现有代码" + - "需要回答某功能在哪里实现" + - "需要做根因分析但上下文不完整" + tools: ["semantic_search", "grep_search", "read_file", "list_dir", "file_search"] +--- + +# Repo Context + +## 目标 +- 在动手改代码前,先建立“可验证的仓库事实”。 +- 所有结论都应可追溯到真实文件、符号和调用链。 + +## 输入 +- 用户需求或报错描述。 +- 当前工作区目录结构。 + +## 执行步骤 +1. 优先定位入口与装配点(CLI 入口、注册点、DI、路由)。 +2. 向下追踪到具体实现(handler/service/module)。 +3. 确认依赖与副作用(配置、环境变量、外部调用、测试覆盖)。 +4. 输出“最小修改面”,避免无关重构。 + +## 约束 +- 不臆造文件、命令、配置项。 +- 不用猜测替代证据;证据不足时明确指出缺失。 +- 引用符号时优先给出文件路径与函数名。 + +## 输出契约 +- 给出: + - 关键文件清单(入口/实现/测试) + - 根因或需求映射 + - 最小可执行改动建议 +- 若存在风险,给出风险点与验证方式。 + +## 失败与回退 +- 如果未找到实现入口:先扩大检索关键词,再按目录分层扫描。 +- 如果存在多实现分支:列出选择依据后再继续改动。