This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+585
View File
@@ -0,0 +1,585 @@
// Command spx is a small Gitea wrapper for the superpowers-vscode workflow.
//
// It targets agent (cc/codex) usage: deterministic flags, JSON output mode,
// and tea-config-based credential discovery.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/cruldra/superpowers-vscode/cli/internal/gitea"
"github.com/cruldra/superpowers-vscode/cli/internal/marker"
"github.com/cruldra/superpowers-vscode/cli/internal/repo"
"github.com/cruldra/superpowers-vscode/cli/internal/state"
"github.com/cruldra/superpowers-vscode/cli/internal/tea"
)
// runtimeContext is the bag of resolved configuration we hand to subcommands
// via cobra's context. Filled in by the root PersistentPreRunE.
type runtimeContext struct {
Client *gitea.Client
Owner string
Repo string
Host string
JSON bool
}
type ctxKey struct{}
// global flag values (bound by Cobra).
var (
flagRepo string
flagHost string
flagJSON bool
flagCwd string
)
// issue create flags.
var (
icTitle string
icBody string
icBodyFile string
icSpec string
icPlan string
icStateJSON string
icStateFile string
)
// issue marker flags.
var (
imIssue int
imType string
imValue string
)
// issue state get flags.
var (
isgIssue int
)
// issue state merge flags.
var (
ismIssue int
ismStateJSON string
ismStateFile string
)
// pr review-comment flags.
var (
prcPR int
prcBody string
prcBodyFile string
)
func main() {
root := buildRootCmd()
if err := root.Execute(); err != nil {
// cobra already printed the error to stderr; ensure non-zero exit.
os.Exit(1)
}
}
func buildRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "spx",
Short: "superpowers-vscode 工作流 CLIGitea 薄包装)",
Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的 Gitea 操作。\n复用 ~/.config/tea/config.yml 拿 host + token。",
SilenceUsage: true,
SilenceErrors: false,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
rc, err := resolveRuntime()
if err != nil {
return err
}
cmd.SetContext(context.WithValue(cmd.Context(), ctxKey{}, rc))
return nil
},
}
root.PersistentFlags().StringVar(&flagRepo, "repo", "", "OWNER/REPO,缺省从当前 git origin 推断")
root.PersistentFlags().StringVar(&flagHost, "host", "", "Gitea host URL,缺省从 tea config 默认 login 取")
root.PersistentFlags().BoolVar(&flagJSON, "json", false, "JSON 输出(默认人类可读)")
root.PersistentFlags().StringVar(&flagCwd, "cwd", ".", "repo 探测的工作目录")
root.AddCommand(buildIssueCmd())
root.AddCommand(buildPRCmd())
return root
}
func buildIssueCmd() *cobra.Command {
issue := &cobra.Command{
Use: "issue",
Short: "Gitea issue 操作",
}
create := &cobra.Command{
Use: "create",
Short: "创建一个新工单并可选附加 spec/plan marker 与 state JSON",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(icTitle) == "" {
return fmt.Errorf("--title 不能为空")
}
body, err := resolveBody(icBody, icBodyFile)
if err != nil {
return err
}
if icSpec != "" {
body = marker.UpsertMarker(body, "spec", icSpec)
}
if icPlan != "" {
body = marker.UpsertMarker(body, "plan", icPlan)
}
// Build the merged state map (state-json/state-file as base, then
// fold in --spec / --plan as specFile / planFile so the kanban can
// read them from state JSON, which is the loader's source of truth
// — see superpowers-vscode/src/gitea/issueLoader.ts).
stateBytes, err := resolveStatePayload(icStateJSON, icStateFile)
if err != nil {
return err
}
mergedState := map[string]any{}
if stateBytes != nil {
if err := json.Unmarshal(stateBytes, &mergedState); err != nil {
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
}
}
if icSpec != "" {
mergedState["specFile"] = icSpec
}
if icPlan != "" {
mergedState["planFile"] = icPlan
}
// Validate the merged blob BEFORE hitting the API so e.g. a bad
// --spec path fails fast instead of leaving an issue half-built.
// schema's specFile/planFile pattern doubles as input validation.
hasState := len(mergedState) > 0
if hasState {
if err := state.ValidateValue(mergedState); err != nil {
return err
}
}
rc := fromCtx(cmd)
issue, err := rc.Client.CreateIssue(rc.Owner, rc.Repo, icTitle, body)
if err != nil {
return err
}
// --body-file is a one-shot handoff. Remove it now that the issue
// exists, so a stale file can't mislead a later brainstorm session
// into the wrong topic.
if icBodyFile != "" {
_ = os.Remove(icBodyFile)
}
var statePayload any
if hasState {
mergedBytes, err := json.Marshal(mergedState)
if err != nil {
return fmt.Errorf("工单已建 (#%d),但序列化 state JSON 失败: %w", issue.Number, err)
}
if _, postErr := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issue.Number, string(mergedBytes)); postErr != nil {
return fmt.Errorf("工单已建 (#%d),但写入 state JSON comment 失败: %w", issue.Number, postErr)
}
statePayload = mergedState
}
if rc.JSON {
return emitJSON(map[string]any{
"number": issue.Number,
"html_url": issue.HTMLURL,
"state": statePayload,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "#%d\n", issue.Number)
return nil
},
}
create.Flags().StringVar(&icTitle, "title", "", "工单标题(必填)")
create.Flags().StringVar(&icBody, "body", "", "工单正文(与 --body-file 二选一)")
create.Flags().StringVar(&icBodyFile, "body-file", "", "从文件读取工单正文")
create.Flags().StringVar(&icSpec, "spec", "", "在 body 追加 spx:spec marker,并把 specFile 同步合并进 state JSON comment")
create.Flags().StringVar(&icPlan, "plan", "", "在 body 追加 spx:plan marker,并把 planFile 同步合并进 state JSON comment")
create.Flags().StringVar(&icStateJSON, "state-json", "", "state JSON 字符串(建工单后写入最后一条 comment;与 --state-file 二选一)")
create.Flags().StringVar(&icStateFile, "state-file", "", "从文件读取 state JSON")
_ = create.MarkFlagRequired("title")
markerCmd := &cobra.Command{
Use: "marker",
Short: "增量更新现有工单的 spec/plan marker,并同步合并 specFile/planFile 进 state JSON",
RunE: func(cmd *cobra.Command, args []string) error {
if imIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
}
if imType != "spec" && imType != "plan" {
return fmt.Errorf("--type 必须是 spec 或 plan,得到 %q", imType)
}
// Fail fast on a bad path before we touch the network — the
// schema's specFile/planFile pattern doubles as input check.
fieldName := map[string]string{"spec": "specFile", "plan": "planFile"}[imType]
patch := map[string]any{fieldName: imValue}
if err := state.ValidateValue(patch); err != nil {
return err
}
rc := fromCtx(cmd)
cur, err := rc.Client.GetIssue(rc.Owner, rc.Repo, imIssue)
if err != nil {
return err
}
newBody := marker.UpsertMarker(cur.Body, imType, imValue)
if newBody != cur.Body {
if err := rc.Client.UpdateIssueBody(rc.Owner, rc.Repo, imIssue, newBody); err != nil {
return err
}
}
// Now merge specFile/planFile into the state JSON comment so the
// kanban (which reads state JSON, not body markers) sees the same
// value. If this fails the marker is already written, so we warn
// and exit non-zero rather than panic — re-running spx fixes it.
merged, mergeErr := mergeAndPostState(rc, imIssue, patch)
if mergeErr != nil {
fmt.Fprintf(os.Stderr, "warning: marker 已写入 #%d body,但合并到 state JSON 失败: %v\n", imIssue, mergeErr)
return mergeErr
}
if rc.JSON {
return emitJSON(map[string]any{
"number": imIssue,
"type": imType,
"value": imValue,
"state": merged,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "已更新 #%d 的 %s marker → %sstate JSON 已同步合并 %s\n", imIssue, imType, imValue, fieldName)
return nil
},
}
markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)")
markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:spec 或 plan(必填)")
markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile")
_ = markerCmd.MarkFlagRequired("issue")
_ = markerCmd.MarkFlagRequired("type")
_ = markerCmd.MarkFlagRequired("value")
issue.AddCommand(create, markerCmd, buildIssueStateCmd())
return issue
}
// buildIssueStateCmd assembles the `spx issue state` subtree (get + merge).
//
// The state JSON convention: the most recent state-JSON comment on an issue
// holds a single JSON blob (see schemas/state-json.schema.json), located by
// scanning comments from the tail for the first one carrying a known state
// field. `get` reads it, `merge` does a shallow-merge write of new keys on top.
func buildIssueStateCmd() *cobra.Command {
stateCmd := &cobra.Command{
Use: "state",
Short: "读取/合并工单的 state JSON(持久化在最后一条 comment",
}
get := &cobra.Command{
Use: "get",
Short: "读取工单最后一条 comment 里的 state JSON",
RunE: func(cmd *cobra.Command, args []string) error {
if isgIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
}
rc := fromCtx(cmd)
cur, err := lastStateMap(rc.Client, rc.Owner, rc.Repo, isgIssue)
if err != nil {
return err
}
// `cur` may be an empty map; print `{}` in that case.
out := os.Stdout
enc := json.NewEncoder(out)
enc.SetEscapeHTML(false)
if rc.JSON {
enc.SetIndent("", " ")
}
return enc.Encode(cur)
},
}
get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)")
_ = get.MarkFlagRequired("issue")
merge := &cobra.Command{
Use: "merge",
Short: "浅合并新 state JSON 到现有 state(最后一条 comment",
RunE: func(cmd *cobra.Command, args []string) error {
if ismIssue <= 0 {
return fmt.Errorf("--issue 必须是正整数")
}
incomingBytes, err := resolveStatePayload(ismStateJSON, ismStateFile)
if err != nil {
return err
}
if incomingBytes == nil {
return fmt.Errorf("必须提供 --state-json 或 --state-file 之一")
}
// Validate the incoming payload BEFORE any network call so a
// malformed input fails fast (e.g. wrong enum) without surfacing
// confusing "issue not found" errors from Gitea first.
if err := state.Validate(incomingBytes); err != nil {
return err
}
var incoming map[string]any
if err := json.Unmarshal(incomingBytes, &incoming); err != nil {
return fmt.Errorf("解析 --state-json/--state-file 内容失败: %w", err)
}
rc := fromCtx(cmd)
merged, err := mergeAndPostState(rc, ismIssue, incoming)
if err != nil {
return err
}
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
if rc.JSON {
enc.SetIndent("", " ")
}
return enc.Encode(merged)
},
}
merge.Flags().IntVar(&ismIssue, "issue", 0, "工单编号(必填)")
merge.Flags().StringVar(&ismStateJSON, "state-json", "", "state JSON 字符串(与 --state-file 二选一)")
merge.Flags().StringVar(&ismStateFile, "state-file", "", "从文件读取 state JSON")
_ = merge.MarkFlagRequired("issue")
stateCmd.AddCommand(get, merge)
return stateCmd
}
// mergeAndPostState shallow-merges patch into the issue's current state JSON
// (last comment), re-validates the result against the schema, and posts a new
// comment with the merged blob. Returns the merged map for callers that want
// to echo it back. Shared by `issue create --spec/--plan`, `issue marker`,
// and `issue state merge` so the three paths can't drift.
func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any) (map[string]any, error) {
cur, err := lastStateMap(rc.Client, rc.Owner, rc.Repo, issueNumber)
if err != nil {
return nil, err
}
for k, v := range patch {
cur[k] = v
}
// Re-validate the merged blob before persisting so we never poison the
// issue's comment trail with malformed state.
if err := state.ValidateValue(cur); err != nil {
return nil, err
}
mergedBytes, err := json.Marshal(cur)
if err != nil {
return nil, fmt.Errorf("序列化合并后的 state 失败: %w", err)
}
if _, err := rc.Client.CreateIssueComment(rc.Owner, rc.Repo, issueNumber, string(mergedBytes)); err != nil {
return nil, err
}
return cur, nil
}
// knownStateFields is the set of keys that mark a comment as a state JSON blob
// rather than ordinary chatter. A comment counts as state only if it parses to
// an object carrying at least one of these — mirrors the TypeScript loader so
// the two stay in lockstep.
var knownStateFields = map[string]struct{}{
"column": {},
"sessionId": {},
"implementSessionId": {},
"reviewSessionId": {},
"testSessionId": {},
"profilePath": {},
"specFile": {},
"planFile": {},
"prDiffFile": {},
"pr": {},
"prMerged": {},
"branch": {},
"worktreePath": {},
"implementStatus": {},
"color": {},
"autoReview": {},
}
// parseStateComment returns the parsed map when body is a JSON object carrying
// at least one known state field, or (nil, false) otherwise.
func parseStateComment(body string) (map[string]any, bool) {
trimmed := strings.TrimSpace(body)
if trimmed == "" {
return nil, false
}
var m map[string]any
if err := json.Unmarshal([]byte(trimmed), &m); err != nil {
return nil, false
}
for k := range m {
if _, ok := knownStateFields[k]; ok {
return m, true
}
}
return nil, false
}
// lastStateMap fetches all comments on an issue and returns the most recent
// state JSON blob, found by scanning from the tail for the first comment that
// parses as an object carrying a known state field. Ordinary text/JSON comments
// inserted afterwards (cc chatter, review notes, Gitea's PR auto-link) are
// skipped so they can't shadow real state and wipe history on the next merge.
// Empty issue or no state comment yields an empty (non-nil) map — never an error.
func lastStateMap(c *gitea.Client, owner, repo string, number int) (map[string]any, error) {
comments, err := c.ListIssueComments(owner, repo, number)
if err != nil {
return nil, err
}
for i := len(comments) - 1; i >= 0; i-- {
if m, ok := parseStateComment(comments[i].Body); ok {
return m, nil
}
}
return map[string]any{}, nil
}
// resolveStatePayload picks the state JSON bytes from --state-json /
// --state-file. Returns (nil, nil) when neither is supplied so callers can
// branch on "no state requested". Both set is an error.
func resolveStatePayload(inline, file string) ([]byte, error) {
if inline != "" && file != "" {
return nil, fmt.Errorf("--state-json 和 --state-file 不能同时指定")
}
if inline != "" {
return []byte(inline), nil
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("读取 state 文件 %s 失败: %w", file, err)
}
return data, nil
}
return nil, nil
}
func buildPRCmd() *cobra.Command {
pr := &cobra.Command{
Use: "pr",
Short: "Gitea PR 操作",
}
rc := &cobra.Command{
Use: "review-comment",
Short: "给 PR 发一条带 review marker 的评论",
RunE: func(cmd *cobra.Command, args []string) error {
if prcPR <= 0 {
return fmt.Errorf("--pr 必须是正整数")
}
body, err := resolveBody(prcBody, prcBodyFile)
if err != nil {
return err
}
body = marker.PrependReviewMarker(body)
rt := fromCtx(cmd)
cm, err := rt.Client.CreateIssueComment(rt.Owner, rt.Repo, prcPR, body)
if err != nil {
return err
}
if rt.JSON {
return emitJSON(map[string]any{
"id": cm.ID,
"html_url": cm.HTMLURL,
})
}
fmt.Fprintf(cmd.OutOrStdout(), "已发评论到 PR #%d\n", prcPR)
return nil
},
}
rc.Flags().IntVar(&prcPR, "pr", 0, "PR 编号(必填)")
rc.Flags().StringVar(&prcBody, "body", "", "评论正文(与 --body-file 二选一)")
rc.Flags().StringVar(&prcBodyFile, "body-file", "", "从文件读取评论正文")
_ = rc.MarkFlagRequired("pr")
pr.AddCommand(rc)
return pr
}
// resolveBody picks the body text from --body / --body-file. Both empty is OK
// (callers may then prepend markers); both set is an error.
func resolveBody(body, file string) (string, error) {
if body != "" && file != "" {
return "", fmt.Errorf("--body 和 --body-file 不能同时指定")
}
if file != "" {
data, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("读取 body 文件 %s 失败: %w", file, err)
}
return string(data), nil
}
return body, nil
}
// resolveRuntime turns the global flags + tea config + git remote into a
// ready-to-use Client and owner/repo pair.
func resolveRuntime() (*runtimeContext, error) {
host := flagHost
var token string
if host == "" {
h, t, err := tea.LoadDefault()
if err != nil {
return nil, err
}
host, token = h, t
} else {
t, err := tea.LoadByHost(host)
if err != nil {
return nil, err
}
token = t
}
// Fallback: token can come from env when tea stores it in a keyring
// (newer tea releases) rather than in config.yml.
if token == "" {
if env := os.Getenv("GITEA_TOKEN"); env != "" {
token = env
}
}
if token == "" {
return nil, fmt.Errorf("没有可用的 Gitea tokentea config 里 token 字段为空,且 GITEA_TOKEN 环境变量也没设置")
}
owner, repoName := "", ""
if flagRepo != "" {
parts := strings.SplitN(flagRepo, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return nil, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo)
}
owner, repoName = parts[0], parts[1]
} else {
o, r, err := repo.DetectOwnerRepo(flagCwd)
if err != nil {
return nil, fmt.Errorf("自动推断 owner/repo 失败,请用 --repo 显式指定: %w", err)
}
owner, repoName = o, r
}
return &runtimeContext{
Client: gitea.New(host, token),
Owner: owner,
Repo: repoName,
Host: host,
JSON: flagJSON,
}, nil
}
// fromCtx extracts the runtimeContext set up by PersistentPreRunE.
func fromCtx(cmd *cobra.Command) *runtimeContext {
v := cmd.Context().Value(ctxKey{})
if v == nil {
// Should be unreachable: PersistentPreRunE always populates it.
panic("runtimeContext missing from cobra context")
}
return v.(*runtimeContext)
}
// emitJSON writes v as a single-line JSON object to stdout.
func emitJSON(v any) error {
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
return enc.Encode(v)
}