// 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 工作流 CLI(Gitea 薄包装)", 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 → %s(state 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 token:tea 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) }