Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions .version/changelog/Unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,36 @@

## 新增

暂无
- 新增 `ggc acp run`:以 agent 模式执行单轮 prompt(`--agent` / `--permit ask|auto|deny` / `--timeout` / `--client-fs`)
- 新增 ACP client 侧沙箱 FS(`fs/read_text_file` / `fs/write_text_file`),路径限定在 Cwd 内,越界拒绝
- 新增 ACP 权限策略 ask|auto|deny(交互确认 / 自动放行 / 自动拒绝)及 session/update 进度行渲染

## 修复

暂无

## 变更

暂无
- `ggc changelog draft` 默认改走 ACP agent 模式(`--provider acp`;可用 `--provider copilot` 回退旧会话),并在结束后校验 Unreleased.md 是否被改动
- ACP agent 解析改为显式名(flag / `FASTGIT_ACP_AGENT` / `acp.default`)未命中时立即失败,不再静默回退到其它候选

## 文档

暂无

## 影响范围

暂无
- `ggc changelog draft` 默认依赖可用 ACP agent,并默认开启 client FS(沙箱限定仓库目录)
- `ggc acp run` 与 draft 共用权限策略与沙箱 FS 能力
- 显式指定不存在的 agent 名会直接报错,不再自动探测其它 agent

## 验证建议

暂无
- `ggc acp run "ping" --agent <name> --permit ask` 验证连通与权限交互
- `ggc changelog draft` 确认 `.version/changelog/Unreleased.md` 被更新;再用 `--provider copilot` 验证旧路径
- `go test ./pkg/acpclient ./pkg/acpui ./cmds/chglogcmd` 覆盖 FS 沙箱、权限策略与 draft agent 模式

## 回滚建议

暂无
- draft 使用 `--provider copilot` 恢复旧 Copilot 会话路径
- 代码层回退本次提交(`feat(chglog): run changelog draft through ACP agents by default`)
88 changes: 78 additions & 10 deletions cmds/acpcmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,43 @@ package acpcmd
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"

acp "github.com/coder/acp-go-sdk"

"github.com/pubgo/fastgit/configs"
"github.com/pubgo/fastgit/pkg/acpclient"
"github.com/pubgo/fastgit/pkg/acpconfig"
"github.com/pubgo/fastgit/pkg/acpui"
"github.com/pubgo/redant"
"github.com/samber/lo"
)

type flagOptions struct {
command string
args string
env string
prompt string
timeout string
clear bool
command string
args string
env string
prompt string
timeout string
agent string
permit string
clientFS bool
clear bool
}

// permissionHandler 按策略名构造权限回调: ask|auto|deny。
func permissionHandler(mode string) (acpclient.PermissionHandler, error) {
return acpui.Permission(mode)
}

func runTimeout(seconds string) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(seconds)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
return acpclient.DefaultTimeout
}

func parseAgentFlags(flags *flagOptions) (acpconfig.AgentConfig, error) {
Expand Down Expand Up @@ -158,6 +177,58 @@ func New() *redant.Command {
return nil
},
},
{
Use: "run",
Short: "run one agent-mode prompt through an acp agent: run <prompt...> [--agent name] [--permit ask|auto|deny] [--timeout s] [--client-fs]",
Options: []redant.Option{
{Flag: "agent", Description: "acp agent 名(默认按 acp.default/自动探测)", Value: redant.StringOf(&flags.agent)},
{Flag: "permit", Description: "权限策略 ask|auto|deny", Value: redant.StringOf(&flags.permit), Default: "ask"},
{Flag: "timeout", Description: "整轮超时秒数", Value: redant.StringOf(&flags.timeout), Default: "300"},
{Flag: "client-fs", Description: "声明并处理 client 侧 fs 读写(沙箱限定当前目录)", Value: redant.BoolOf(&flags.clientFS)},
},
Handler: func(ctx context.Context, i *redant.Invocation) error {
parts := make([]string, 0, len(i.Command.Args))
for _, arg := range i.Command.Args {
parts = append(parts, arg.Value.String())
}
prompt := strings.TrimSpace(strings.Join(parts, " "))
if prompt == "" {
return fmt.Errorf("usage: ggc acp run <prompt...>")
}

cfg, err := acpconfig.Load()
if err != nil {
return err
}
agent, err := cfg.Resolve(strings.TrimSpace(flags.agent))
if err != nil {
return err
}
onPermission, err := permissionHandler(flags.permit)
if err != nil {
return err
}

fmt.Printf("→ acp run agent=%q permit=%s timeout=%s\n", agent.Name, strings.ToLower(strings.TrimSpace(flags.permit)), runTimeout(flags.timeout))
result, err := acpclient.Run(ctx, agent, acpclient.Options{
Prompt: prompt,
Timeout: runTimeout(flags.timeout),
OnPermission: onPermission,
ClientFS: flags.clientFS,
OnUpdate: func(update acp.SessionUpdate) error {
if line := acpclient.UpdateLine(update); line != "" {
fmt.Fprintln(os.Stderr, line)
}
return nil
},
})
if err != nil {
return err
}
fmt.Println(result.Text)
return nil
},
},
{
Use: "test",
Short: "spawn the agent, initialize an ACP session and run a ping prompt",
Expand All @@ -180,10 +251,7 @@ func New() *redant.Command {
return err
}

timeout := acpclient.DefaultTimeout
if secs, convErr := strconv.Atoi(strings.TrimSpace(flags.timeout)); convErr == nil && secs > 0 {
timeout = time.Duration(secs) * time.Second
}
timeout := runTimeout(flags.timeout)

fmt.Printf("→ testing acp agent %q: %s (timeout %s)\n", agent.Name, agent.Command+" "+strings.Join(agent.Args, " "), timeout)
start := time.Now()
Expand Down
64 changes: 43 additions & 21 deletions cmds/chglogcmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,16 @@ func newDraftCommand() *redant.Command {
streaming bool
autoUserAnswer string
permissionMode string
provider string
acpAgent string
acpPermit string
acpTimeout string
acpClientFS bool
)

return &redant.Command{
Use: "draft",
Short: "使用 Copilot 根据当前改动更新 Unreleased.md",
Short: "根据当前改动更新 Unreleased.md(默认 acp agent 模式,可 --provider copilot)",
Options: redant.OptionSet{
{Flag: "repo", Description: "目标仓库目录(默认当前目录)", Value: redant.StringOf(&repoPath)},
{Flag: "base", Description: "diff 基线(默认自动探测)", Value: redant.StringOf(&baseRef)},
Expand All @@ -106,6 +111,11 @@ func newDraftCommand() *redant.Command {
{Flag: "stream", Description: "启用流式输出", Value: redant.BoolOf(&streaming), Default: "false"},
{Flag: "auto-user-answer", Description: "ask_user 触发时自动回答内容", Value: redant.StringOf(&autoUserAnswer), Default: "继续执行"},
{Flag: "permission-mode", Description: "Copilot 权限策略 ask|allow|deny(默认 deny;可被 config/env 覆盖)", Value: redant.StringOf(&permissionMode)},
{Flag: "provider", Description: "draft 执行方式 acp|copilot(默认 acp agent 模式)", Value: redant.StringOf(&provider), Default: "acp"},
{Flag: "agent", Description: "acp agent 名(默认按 acp.default/自动探测)", Value: redant.StringOf(&acpAgent)},
{Flag: "acp-permit", Description: "acp 权限策略 ask|auto|deny", Value: redant.StringOf(&acpPermit), Default: "ask"},
{Flag: "acp-timeout", Description: "acp 整轮超时秒数", Value: redant.StringOf(&acpTimeout), Default: "300"},
{Flag: "acp-client-fs", Description: "声明 client 侧 fs 读写能力(沙箱限定仓库目录,claude 类 agent 依赖此通道落盘)", Value: redant.BoolOf(&acpClientFS), Default: "true"},
},
Handler: func(ctx context.Context, inv *redant.Invocation) error {
repoRoot, err := resolveExistingGitRepo(strings.TrimSpace(repoPath))
Expand Down Expand Up @@ -153,33 +163,45 @@ func newDraftCommand() *redant.Command {
return nil
}

mode, err := copilotperm.ResolveMode(strings.TrimSpace(permissionMode), copilotperm.ModeDeny)
if err != nil {
return err
}
switch strings.ToLower(strings.TrimSpace(provider)) {
case "copilot":
mode, err := copilotperm.ResolveMode(strings.TrimSpace(permissionMode), copilotperm.ModeDeny)
if err != nil {
return err
}

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),
PermissionMode: string(mode),
})
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),
PermissionMode: string(mode),
})
case "", "acp":
return runDraftWithAgent(ctx, inv, prompt, repoRoot, agentParams{
Agent: strings.TrimSpace(acpAgent),
Permit: strings.TrimSpace(acpPermit),
Timeout: strings.TrimSpace(acpTimeout),
ClientFS: acpClientFS,
})
default:
return fmt.Errorf("invalid --provider %q, want acp|copilot", provider)
}
},
}
}

func newReleaseCommand() *redant.Command {
var (
repoPath string
version string
nextVersion string
bump string
repoPath string
version string
nextVersion string
bump string
dryRun bool
skipValidate bool
skipBumpCheck bool
Expand Down
74 changes: 74 additions & 0 deletions cmds/chglogcmd/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"

acp "github.com/coder/acp-go-sdk"

"github.com/pubgo/fastgit/pkg/acpclient"
"github.com/pubgo/fastgit/pkg/acpconfig"
"github.com/pubgo/fastgit/pkg/acpui"
"github.com/pubgo/fastgit/pkg/aiprovider"
"github.com/pubgo/fastgit/pkg/copilotperm"
"github.com/pubgo/redant"
Expand Down Expand Up @@ -143,6 +150,73 @@ func runDraftWithCopilot(ctx context.Context, inv *redant.Invocation, prompt str
return nil
}

// agentParams 是 acp agent 模式 draft 的运行参数。
type agentParams struct {
Agent string
Permit string
Timeout string
ClientFS bool
}

// runDraftWithAgent 用 ACP agent(agent 模式)执行 draft:agent 在自己的
// 会话里读 git log/diff 并把结果写回 Unreleased.md(client fs 沙箱限定仓库目录)。
func runDraftWithAgent(ctx context.Context, inv *redant.Invocation, prompt, repoRoot string, params agentParams) error {
cfg, err := acpconfig.Load()
if err != nil {
return err
}
agent, err := cfg.Resolve(params.Agent)
if err != nil {
return err
}
onPermission, err := acpui.Permission(params.Permit)
if err != nil {
return err
}

timeout := 5 * time.Minute
if secs, convErr := strconv.Atoi(params.Timeout); convErr == nil && secs > 0 {
timeout = time.Duration(secs) * time.Second
}

_, _ = fmt.Fprintf(inv.Stdout, "→ drafting changelog with acp agent %q (permit=%s timeout=%s)\n", agent.Name, defaultString(params.Permit, "ask"), timeout)

result, err := acpclient.Run(ctx, agent, acpclient.Options{
Cwd: repoRoot,
Prompt: prompt,
Timeout: timeout,
OnPermission: onPermission,
ClientFS: params.ClientFS,
OnUpdate: func(update acp.SessionUpdate) error {
if line := acpclient.UpdateLine(update); line != "" {
_, _ = fmt.Fprintln(inv.Stdout, line)
}
return nil
},
})
if err != nil {
return err
}

paths := buildPaths(repoRoot)
_, _ = fmt.Fprintf(inv.Stdout, "agent summary:\n%s\n", strings.TrimSpace(result.Text))

if status, statusErr := gitOutput(ctx, repoRoot, "status", "--porcelain", "--", paths.UnreleasedFile); statusErr == nil {
if strings.TrimSpace(status) != "" {
_, _ = fmt.Fprintln(inv.Stdout, "draft updated: .version/changelog/Unreleased.md(review: git diff -- .version/changelog/Unreleased.md)")
} else {
_, _ = fmt.Fprintln(inv.Stdout, "warning: Unreleased.md 未被 agent 改动,请检查 agent 输出或改用 --provider copilot")
}
}

if data, readErr := os.ReadFile(paths.UnreleasedFile); readErr == nil {
if validateErr := ValidateReleaseReadiness(string(data)); validateErr != nil {
_, _ = fmt.Fprintf(inv.Stdout, "note: %v\n", validateErr)
}
}
return nil
}

func defaultString(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
Expand Down
Loading
Loading