// Command spx is a small Gitea/GitHub wrapper for the superpowers-vscode workflow. // // It targets agent (cc/codex) usage: deterministic flags, JSON output mode, // and the same commands whichever forge the repo's git origin points at. package main import ( "context" "encoding/json" "fmt" "io" "os" "strings" "github.com/spf13/cobra" "github.com/cruldra/superpowers-vscode/cli/internal/forge" "github.com/cruldra/superpowers-vscode/cli/internal/marker" "github.com/cruldra/superpowers-vscode/cli/internal/repo" "github.com/cruldra/superpowers-vscode/cli/internal/state" ) // runtimeContext is the bag of resolved configuration we hand to subcommands // via cobra's context. Filled in by the root PersistentPreRunE. type runtimeContext struct { Forge forge.Forge JSON bool } // forgeOpener connects to the repo spx should act on; main resolves it from flags and git origin, tests from a fake server. type forgeOpener func() (forge.Forge, error) 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 ) // issue view flags. var ( ivIssue int ivComments bool ) // pr review-comment flags. var ( prcPR int prcBody string prcBodyFile string ) // pr view flags. var ( pvPR int ) // pr create flags. var ( pcTitle string pcBody string pcBodyFile string pcHead string pcBase string ) func main() { root := buildRootCmd(openForge) if err := root.Execute(); err != nil { // cobra already printed the error to stderr; ensure non-zero exit. os.Exit(1) } } func buildRootCmd(open forgeOpener) *cobra.Command { root := &cobra.Command{ Use: "spx", Short: "superpowers-vscode 工作流 CLI(Gitea / GitHub 薄包装)", Long: "spx 为 cc/codex agent 在 superpowers-vscode 工作流里提供确定性的工单 / PR 操作。\n" + "按 git origin 的 host 选仓库:github.com 走 GitHub(token 取自 `gh auth token`),\n" + "其它 host 走 Gitea(必须先 `tea login add` 过这个 host,token 取自 ~/.config/tea/config.yml 里同 host 的 login,\n" + "该 login 没存 token 时才用 GITEA_TOKEN 补上;GITEA_TOKEN 不会发给 tea 没登录过的 host)。", SilenceUsage: true, SilenceErrors: false, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { f, err := open() if err != nil { return err } rc := &runtimeContext{Forge: f, JSON: flagJSON} cmd.SetContext(context.WithValue(cmd.Context(), ctxKey{}, rc)) return nil }, } root.PersistentFlags().StringVar(&flagRepo, "repo", "", "OWNER/REPO,缺省从当前 git origin 推断") root.PersistentFlags().StringVar(&flagHost, "host", "", "仓库 host(如 github.com 或 https://gitea.example.com),缺省从当前 git origin 推断") root.PersistentFlags().BoolVar(&flagJSON, "json", false, "JSON 输出(默认人类可读)") root.PersistentFlags().StringVar(&flagCwd, "cwd", ".", "repo 探测的工作目录") root.AddCommand(buildIssueCmd()) root.AddCommand(buildPRCmd()) return root } // markerTypeToStateField maps a `spx issue marker --type` value to the state // JSON field it mirrors. All three types share one incremental-update path; // this table is the single source of truth — adding a type only touches it // (plus markerTypesHint below, which is what the user sees). var markerTypeToStateField = map[string]string{ "spec": "specFile", "plan": "planFile", "prototype": "prototypeFile", } // markerTypesHint is the human-readable enumeration used in help text and the // "bad --type" error, kept next to the table so the two can't drift. const markerTypesHint = "spec、plan 或 prototype" func buildIssueCmd() *cobra.Command { issue := &cobra.Command{ Use: "issue", Short: "工单操作", } 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) // 创建即认领:assignee=创建人,是插件 webhook 门禁的路由依据。 // 身份解析失败不阻塞创建(门禁侧有本地终端兜底)。 var assignees []string if login, loginErr := rc.Forge.CurrentUserLogin(); loginErr == nil && login != "" { assignees = []string{login} } else if loginErr != nil { fmt.Fprintf(os.Stderr, "警告: 解析当前用户失败,工单将无 assignee: %v\n", loginErr) } issue, err := rc.Forge.CreateIssue(icTitle, body, assignees) 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 { comment, err := state.FormatComment(mergedState) if err != nil { return fmt.Errorf("工单已建 (#%d),但%w", issue.Number, err) } if _, postErr := rc.Forge.CreateIssueComment(issue.Number, comment); postErr != nil { return fmt.Errorf("工单已建 (#%d),但写入 state JSON comment 失败: %w", issue.Number, postErr) } statePayload = mergedState } if rc.JSON { return emitJSON(cmd.OutOrStdout(), 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 字符串(建工单后写成一条 state 评论;与 --state-file 二选一)") create.Flags().StringVar(&icStateFile, "state-file", "", "从文件读取 state JSON") _ = create.MarkFlagRequired("title") markerCmd := &cobra.Command{ Use: "marker", Short: "增量更新现有工单的 spec/plan/prototype marker,并同步合并 specFile/planFile/prototypeFile 进 state JSON", RunE: func(cmd *cobra.Command, args []string) error { if imIssue <= 0 { return fmt.Errorf("--issue 必须是正整数") } fieldName, ok := markerTypeToStateField[imType] if !ok { return fmt.Errorf("--type 必须是 %s,得到 %q", markerTypesHint, imType) } // Fail fast on a bad path before we touch the network — the // schema's pattern for this field doubles as input check. patch := map[string]any{fieldName: imValue} if err := state.ValidateValue(patch); err != nil { return err } rc := fromCtx(cmd) cur, err := rc.Forge.GetIssue(imIssue) if err != nil { return err } newBody := marker.UpsertMarker(cur.Body, imType, imValue) if newBody != cur.Body { if err := rc.Forge.UpdateIssueBody(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(cmd.OutOrStdout(), map[string]any{ "number": imIssue, "type": imType, "value": imValue, "state": merged, }) } fmt.Fprintf(cmd.OutOrStdout(), "已更新 #%d 的 %s marker → %s(state JSON 已同步合并 %s)\n", imIssue, imType, imValue, fieldName) return nil }, } markerCmd.Flags().IntVar(&imIssue, "issue", 0, "工单编号(必填)") markerCmd.Flags().StringVar(&imType, "type", "", "marker 类型:"+markerTypesHint+"(必填)") markerCmd.Flags().StringVar(&imValue, "value", "", "marker 值(路径,必填,同步写入 state JSON 的 specFile/planFile/prototypeFile)") _ = markerCmd.MarkFlagRequired("issue") _ = markerCmd.MarkFlagRequired("type") _ = markerCmd.MarkFlagRequired("value") issue.AddCommand(create, markerCmd, buildIssueViewCmd(), buildIssueStateCmd()) return issue } // buildIssueViewCmd assembles `spx issue view`, which shows an issue and, with `--comments`, its discussion. // // State comments are machine bookkeeping, so they are left out of the comment list; `spx issue state get` reads them. func buildIssueViewCmd() *cobra.Command { view := &cobra.Command{ Use: "view", Short: "查看工单:标题、正文、状态、作者、指派人、链接;--comments 附带评论(不含 state 评论)", RunE: func(cmd *cobra.Command, args []string) error { if ivIssue <= 0 { return fmt.Errorf("--issue 必须是正整数") } rc := fromCtx(cmd) is, err := rc.Forge.GetIssue(ivIssue) if err != nil { return err } var comments []forge.Comment if ivComments { all, err := rc.Forge.ListIssueComments(ivIssue) if err != nil { return err } comments = withoutStateComments(all) } if rc.JSON { if !ivComments { return emitJSON(cmd.OutOrStdout(), is) } return emitJSON(cmd.OutOrStdout(), struct { *forge.Issue Comments []forge.Comment `json:"comments"` }{is, comments}) } printIssue(cmd.OutOrStdout(), is) if ivComments { printComments(cmd.OutOrStdout(), comments) } return nil }, } view.Flags().IntVar(&ivIssue, "issue", 0, "工单编号(必填)") view.Flags().BoolVar(&ivComments, "comments", false, "附带评论(不含 state 评论)") _ = view.MarkFlagRequired("issue") return view } // withoutStateComments drops the comments that carry state JSON, keeping the human discussion in order. // // @return a non-nil slice, so `--json` prints `[]` rather than `null` when nothing is left func withoutStateComments(all []forge.Comment) []forge.Comment { out := make([]forge.Comment, 0, len(all)) for _, c := range all { if _, isState := state.ParseComment(c.Body); !isState { out = append(out, c) } } return out } // printIssue writes the human-readable form of `spx issue view`. func printIssue(w io.Writer, is *forge.Issue) { assignees := "无" if len(is.Assignees) > 0 { assignees = strings.Join(is.Assignees, ", ") } fmt.Fprintf(w, "#%d %s\n", is.Number, is.Title) fmt.Fprintf(w, "状态: %s\n", is.State) fmt.Fprintf(w, "作者: %s\n", is.Author) fmt.Fprintf(w, "指派: %s\n", assignees) fmt.Fprintf(w, "链接: %s\n", is.HTMLURL) fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(is.Body)) } // printComments writes the comment list of `spx issue view --comments`, one header line per comment. func printComments(w io.Writer, comments []forge.Comment) { fmt.Fprintf(w, "\n评论 (%d):\n", len(comments)) for _, c := range comments { fmt.Fprintf(w, "\n--- %s · %s ---\n%s\n", c.Author, c.CreatedAt, bodyOrPlaceholder(c.Body)) } } // bodyOrPlaceholder returns `body`, or a visible placeholder when it is blank, so empty text is not mistaken for missing output. func bodyOrPlaceholder(body string) string { if strings.TrimSpace(body) == "" { return "(无正文)" } return strings.TrimRight(body, "\n") } // buildIssueStateCmd assembles the `spx issue state` subtree (get + merge). // // The newest state comment on an issue holds its state JSON (see the [state schema](vscode/schemas/state-json.schema.json)); [state.Latest] // finds it from the tail. `get` reads it, `merge` shallow-merges new keys on top and posts the result as a new // state comment in the hidden format of [state.FormatComment]. func buildIssueStateCmd() *cobra.Command { stateCmd := &cobra.Command{ Use: "state", Short: "读取/合并工单的 state JSON(持久化在最新一条 state 评论)", } get := &cobra.Command{ Use: "get", Short: "读取工单最新一条 state 评论里的 state JSON", RunE: func(cmd *cobra.Command, args []string) error { if isgIssue <= 0 { return fmt.Errorf("--issue 必须是正整数") } rc := fromCtx(cmd) cur, err := lastStateMap(rc.Forge, isgIssue) if err != nil { return err } // `cur` may be an empty map; print `{}` in that case. return emitState(cmd.OutOrStdout(), cur, rc.JSON) }, } get.Flags().IntVar(&isgIssue, "issue", 0, "工单编号(必填)") _ = get.MarkFlagRequired("issue") merge := &cobra.Command{ Use: "merge", Short: "浅合并新 state JSON 到现有 state,并追加一条新的 state 评论", 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 the forge 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 } return emitState(cmd.OutOrStdout(), merged, rc.JSON) }, } 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 // (newest state comment), re-validates the result against the schema, and // posts the merged blob as a new state comment. Returns the merged map for // callers that want to echo it back. Shared by `issue marker` and // `issue state merge` so the two paths can't drift. func mergeAndPostState(rc *runtimeContext, issueNumber int, patch map[string]any) (map[string]any, error) { cur, err := lastStateMap(rc.Forge, 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 } comment, err := state.FormatComment(cur) if err != nil { return nil, err } if _, err := rc.Forge.CreateIssueComment(issueNumber, comment); err != nil { return nil, err } return cur, nil } // lastStateMap reads all comments on an issue and returns the state of the // newest state comment, via [state.Latest]. Ordinary comments posted // afterwards (cc chatter, review notes, the forge'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(f forge.Forge, number int) (map[string]any, error) { comments, err := f.ListIssueComments(number) if err != nil { return nil, err } bodies := make([]string, len(comments)) for i, c := range comments { bodies[i] = c.Body } return state.Latest(bodies), nil } // emitState prints a state map for `spx issue state`: one line by default, indented when `pretty` is set. func emitState(w io.Writer, s map[string]any, pretty bool) error { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) if pretty { enc.SetIndent("", " ") } return enc.Encode(s) } // 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: "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.Forge.CreateIssueComment(prcPR, body) if err != nil { return err } if rt.JSON { return emitJSON(cmd.OutOrStdout(), 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") view := &cobra.Command{ Use: "view", Short: "查看 PR:标题、正文、状态、是否已合并、head/base 分支与 SHA、作者、链接", RunE: func(cmd *cobra.Command, args []string) error { if pvPR <= 0 { return fmt.Errorf("--pr 必须是正整数") } rt := fromCtx(cmd) p, err := rt.Forge.GetPullRequest(pvPR) if err != nil { return err } if rt.JSON { return emitJSON(cmd.OutOrStdout(), p) } printPullRequest(cmd.OutOrStdout(), p) return nil }, } view.Flags().IntVar(&pvPR, "pr", 0, "PR 编号(必填)") _ = view.MarkFlagRequired("pr") create := &cobra.Command{ Use: "create", Short: "从 --head 分支向 --base 分支(缺省为仓库默认分支)发起 PR", RunE: func(cmd *cobra.Command, args []string) error { if strings.TrimSpace(pcTitle) == "" { return fmt.Errorf("--title 不能为空") } if strings.TrimSpace(pcHead) == "" { return fmt.Errorf("--head 不能为空") } if pcBody == "" && pcBodyFile == "" { return fmt.Errorf("必须提供 --body 或 --body-file 之一") } body, err := resolveBody(pcBody, pcBodyFile) if err != nil { return err } rt := fromCtx(cmd) base := pcBase if base == "" { if base, err = rt.Forge.DefaultBranch(); err != nil { return fmt.Errorf("没给 --base,查仓库默认分支失败: %w", err) } } p, err := rt.Forge.CreatePullRequest(forge.NewPullRequest{Title: pcTitle, Body: body, Head: pcHead, Base: base}) if err != nil { return err } if rt.JSON { return emitJSON(cmd.OutOrStdout(), map[string]any{ "number": p.Number, "html_url": p.HTMLURL, }) } fmt.Fprintf(cmd.OutOrStdout(), "#%d %s\n", p.Number, p.HTMLURL) return nil }, } create.Flags().StringVar(&pcTitle, "title", "", "PR 标题(必填)") create.Flags().StringVar(&pcBody, "body", "", "PR 正文(与 --body-file 二选一,必须给一个)") create.Flags().StringVar(&pcBodyFile, "body-file", "", "从文件读取 PR 正文") create.Flags().StringVar(&pcHead, "head", "", "要合并进来的分支(必填)") create.Flags().StringVar(&pcBase, "base", "", "合并到的目标分支,缺省取仓库默认分支") _ = create.MarkFlagRequired("title") _ = create.MarkFlagRequired("head") pr.AddCommand(rc, view, create) return pr } // printPullRequest writes the human-readable form of `spx pr view`. func printPullRequest(w io.Writer, p *forge.PullRequest) { merged := "未合并" if p.Merged { merged = "已合并" } fmt.Fprintf(w, "#%d %s\n", p.Number, p.Title) fmt.Fprintf(w, "状态: %s(%s)\n", p.State, merged) fmt.Fprintf(w, "作者: %s\n", p.Author) fmt.Fprintf(w, "分支: %s (%s) → %s (%s)\n", p.Head.Branch, p.Head.SHA, p.Base.Branch, p.Base.SHA) fmt.Fprintf(w, "链接: %s\n", p.HTMLURL) fmt.Fprintf(w, "\n%s\n", bodyOrPlaceholder(p.Body)) } // 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 } // openForge connects to the repo the global flags and git origin point at, on the forge its host names. func openForge() (forge.Forge, error) { o, err := resolveOrigin() if err != nil { return nil, err } return forge.Open(o, forge.SystemEnv()) } // resolveOrigin works out the repo to act on from `git remote get-url origin`, with `--host` and `--repo` overriding // its parts; git is not consulted when both flags are given. func resolveOrigin() (repo.Origin, error) { var o repo.Origin if flagHost == "" || flagRepo == "" { detected, err := repo.DetectOrigin(flagCwd) if err != nil { return repo.Origin{}, fmt.Errorf("从 git origin 推断仓库失败,请用 --host 和 --repo 显式指定: %w", err) } o = detected } if flagHost != "" { host, webURL, err := repo.ParseHost(flagHost) if err != nil { return repo.Origin{}, err } o.Host, o.WebURL = host, webURL } if flagRepo != "" { owner, name, ok := strings.Cut(flagRepo, "/") if !ok || owner == "" || name == "" { return repo.Origin{}, fmt.Errorf("--repo 必须是 OWNER/REPO 形式,得到 %q", flagRepo) } o.Owner, o.Repo = owner, name } return o, 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 w. func emitJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetEscapeHTML(false) return enc.Encode(v) }