This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
.PHONY: build install test clean sync-schema
BINARY := spx
BUILD_DIR := bin
INSTALL_PATH := $(HOME)/.local/bin
SCHEMA_SRC := ../schemas/state-json.schema.json
SCHEMA_DST := internal/state/schema.json
# Optional: if the skill repo is present on this machine, mirror the schema
# into the using-spx-cli skill's references/ so agents loading the skill see
# the same version. Skipped silently when the path doesn't exist.
SKILL_SCHEMA_DST := $(HOME)/Sources/skills/skills/using-spx-cli/references/state-json.schema.json
sync-schema:
@cp $(SCHEMA_SRC) $(SCHEMA_DST)
@if [ -d "$(dir $(SKILL_SCHEMA_DST))" ]; then cp $(SCHEMA_SRC) $(SKILL_SCHEMA_DST); fi
build: sync-schema
go build -o $(BUILD_DIR)/$(BINARY) ./cmd/spx
install: build
mkdir -p $(INSTALL_PATH)
cp $(BUILD_DIR)/$(BINARY) $(INSTALL_PATH)/
test:
go test ./...
clean:
rm -rf $(BUILD_DIR)
+47
View File
@@ -0,0 +1,47 @@
# spx
superpowers-vscode 工作流 CLI 工具,给 cc/codex agent 用的 Gitea 操作薄包装。
## 安装
```
make install
# 把 $HOME/.local/bin 加进 PATH
```
## 前置
需要先用 [`tea`](https://gitea.com/gitea/tea) 登录过(`~/.config/tea/config.yml` 有默认 login)。
如果 tea 用 keyring 存 tokenv0.10+ 默认行为),可以另外设环境变量 `GITEA_TOKEN` 作为回退:
```
export GITEA_TOKEN=...
```
## 用法
```
spx issue create --title "新功能 X" --spec docs/specs/x.md --plan docs/plans/x.md
spx issue marker --issue 79 --type plan --value docs/plans/issue-79/plan.md
spx pr review-comment --pr 73 --body "审查意见: ..."
```
全局 flag
- `--repo OWNER/REPO` 默认从当前 git origin 推断
- `--host URL` 默认从 tea config 默认 login 取
- `--json` JSON 输出
- `--cwd PATH` repo 探测的工作目录,默认 `.`
## Marker 约定
issue/PR body 里用 HTML 注释行携带 spx 元数据:
```
<!-- spx:spec=docs/specs/foo.md -->
<!-- spx:plan=docs/plans/foo.md -->
<!-- spx:review=1 -->
```
`spx issue marker` 会做增量更新(已存在则替换那一行,不存在则追加),其他类型的 marker 不会被动到。
+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)
}
+14
View File
@@ -0,0 +1,14 @@
module github.com/cruldra/superpowers-vscode/cli
go 1.21
require (
github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+137
View File
@@ -0,0 +1,137 @@
// Package gitea is a minimal Gitea REST client for the three calls spx needs:
// create issue, get/update issue body, create issue comment.
package gitea
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client talks to a Gitea instance using a personal access token.
type Client struct {
BaseURL string // host without trailing slash, e.g. "https://gitea.example.com"
Token string
HTTPClient *http.Client
}
// New builds a Client with sane defaults.
func New(baseURL, token string) *Client {
return &Client{
BaseURL: strings.TrimRight(baseURL, "/"),
Token: token,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// Issue is the subset of Gitea's Issue object spx cares about.
type Issue struct {
Number int `json:"number"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
}
// Comment is the subset of Gitea's Comment object spx cares about.
type Comment struct {
ID int64 `json:"id"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
}
// do executes an HTTP request with auth + JSON content type and decodes the
// response into out (if non-nil and the body is non-empty).
func (c *Client) do(method, path string, payload any, out any) error {
var body io.Reader
if payload != nil {
buf, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("编码请求体失败: %w", err)
}
body = bytes.NewReader(buf)
}
url := c.BaseURL + path
req, err := http.NewRequest(method, url, body)
if err != nil {
return fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Authorization", "token "+c.Token)
req.Header.Set("Accept", "application/json")
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("%s %s 请求失败: %w", method, url, err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s 返回 %d: %s", method, url, resp.StatusCode, strings.TrimSpace(string(respBytes)))
}
if out == nil || len(respBytes) == 0 {
return nil
}
if err := json.Unmarshal(respBytes, out); err != nil {
return fmt.Errorf("解析响应失败 (%s %s): %w; body=%s", method, url, err, string(respBytes))
}
return nil
}
// CreateIssue creates a new issue and returns the created object.
func (c *Client) CreateIssue(owner, repo, title, body string) (*Issue, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues", owner, repo)
payload := map[string]any{"title": title, "body": body}
var out Issue
if err := c.do(http.MethodPost, path, payload, &out); err != nil {
return nil, err
}
return &out, nil
}
// GetIssue fetches an existing issue (or PR — Gitea treats PRs as issues for
// metadata purposes).
func (c *Client) GetIssue(owner, repo string, number int) (*Issue, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
var out Issue
if err := c.do(http.MethodGet, path, nil, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpdateIssueBody patches an issue's body field.
func (c *Client) UpdateIssueBody(owner, repo string, number int, body string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d", owner, repo, number)
payload := map[string]any{"body": body}
return c.do(http.MethodPatch, path, payload, nil)
}
// ListIssueComments returns the issue/PR comments in Gitea's default order
// (ascending by creation time). Used by `spx issue state get|merge` to find
// the last comment, which is where the state JSON blob lives.
func (c *Client) ListIssueComments(owner, repo string, number int) ([]Comment, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
var out []Comment
if err := c.do(http.MethodGet, path, nil, &out); err != nil {
return nil, err
}
return out, nil
}
// CreateIssueComment posts a comment on an issue or PR.
//
// In Gitea, PR comments (not review threads) live under the same endpoint as
// issue comments — pass the PR number as the issue number.
func (c *Client) CreateIssueComment(owner, repo string, number int, body string) (*Comment, error) {
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", owner, repo, number)
payload := map[string]any{"body": body}
var out Comment
if err := c.do(http.MethodPost, path, payload, &out); err != nil {
return nil, err
}
return &out, nil
}
+58
View File
@@ -0,0 +1,58 @@
// Package marker manages spx HTML-comment markers embedded in issue/PR bodies.
//
// Markers look like: <!-- spx:spec=docs/specs/foo.md -->
//
// They let spx and the surrounding tooling round-trip metadata (spec/plan
// paths, review flags, etc.) without dedicated Gitea fields.
package marker
import (
"fmt"
"regexp"
"strings"
)
// markerRegex builds a regex matching a full marker line of the given type.
//
// The "value" capture group accepts any sequence of non-whitespace, non-'>'
// characters, mirroring how the marker is written by UpsertMarker.
func markerRegex(markerType string) *regexp.Regexp {
return regexp.MustCompile(`<!--\s*spx:` + regexp.QuoteMeta(markerType) + `=([^\s>]*)\s*-->`)
}
// reviewMarkerRegex matches the review-comment lead marker.
var reviewMarkerRegex = regexp.MustCompile(`<!--\s*spx:review=1\s*-->`)
// UpsertMarker inserts or updates a marker of the given type in body.
//
// If a marker of this type already exists, the matching marker (just the tag,
// not the whole line) is replaced in place. Otherwise the marker is appended
// to the end of the body, separated by a newline so it sits on its own line.
//
// Other marker types in the body are preserved untouched.
func UpsertMarker(body, markerType, value string) string {
newMarker := fmt.Sprintf("<!-- spx:%s=%s -->", markerType, value)
re := markerRegex(markerType)
if re.MatchString(body) {
return re.ReplaceAllString(body, newMarker)
}
if body == "" {
return newMarker
}
// Ensure separation: end with at least one newline before the new marker.
if strings.HasSuffix(body, "\n") {
return body + newMarker
}
return body + "\n" + newMarker
}
// PrependReviewMarker prepends "<!-- spx:review=1 -->\n\n" to body if not
// already present. Idempotent: bodies already containing the marker are
// returned unchanged.
func PrependReviewMarker(body string) string {
if reviewMarkerRegex.MatchString(body) {
return body
}
const lead = "<!-- spx:review=1 -->\n\n"
return lead + body
}
+64
View File
@@ -0,0 +1,64 @@
package marker
import (
"strings"
"testing"
)
func TestUpsertMarkerAppendsWhenAbsent(t *testing.T) {
got := UpsertMarker("hello body", "spec", "docs/specs/x.md")
want := "hello body\n<!-- spx:spec=docs/specs/x.md -->"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestUpsertMarkerReplacesWhenPresent(t *testing.T) {
body := "intro\n<!-- spx:spec=old.md -->\nmore"
got := UpsertMarker(body, "spec", "new.md")
if strings.Contains(got, "old.md") {
t.Fatalf("old value should be gone: %q", got)
}
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
t.Fatalf("new marker missing: %q", got)
}
// Other content preserved.
if !strings.Contains(got, "intro") || !strings.Contains(got, "more") {
t.Fatalf("surrounding content lost: %q", got)
}
}
func TestUpsertMarkerLeavesOtherTypesAlone(t *testing.T) {
body := "<!-- spx:plan=p.md -->\n<!-- spx:spec=old.md -->"
got := UpsertMarker(body, "spec", "new.md")
if !strings.Contains(got, "<!-- spx:plan=p.md -->") {
t.Fatalf("plan marker should be preserved: %q", got)
}
if !strings.Contains(got, "<!-- spx:spec=new.md -->") {
t.Fatalf("spec marker should be updated: %q", got)
}
}
func TestUpsertMarkerEmptyBody(t *testing.T) {
got := UpsertMarker("", "plan", "p.md")
want := "<!-- spx:plan=p.md -->"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestPrependReviewMarkerAdds(t *testing.T) {
got := PrependReviewMarker("body")
want := "<!-- spx:review=1 -->\n\nbody"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}
func TestPrependReviewMarkerIdempotent(t *testing.T) {
body := "<!-- spx:review=1 -->\n\nbody"
got := PrependReviewMarker(body)
if got != body {
t.Fatalf("expected unchanged, got %q", got)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package repo detects the owner/repo pair from a git working directory's
// origin remote.
package repo
import (
"fmt"
"net/url"
"os/exec"
"regexp"
"strings"
)
// sshLikeRegex matches `git@host:owner/repo(.git)?` style remotes.
//
// Excludes URI-shaped strings (anything with "://") so those go through
// url.Parse instead.
var sshLikeRegex = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+?)/?$`)
// DetectOwnerRepo runs `git -C cwd remote get-url origin` and parses the URL.
//
// Supported formats:
// - https://host/owner/repo(.git)
// - http://host/owner/repo(.git)
// - ssh://git@host[:port]/owner/repo(.git)
// - git@host:owner/repo(.git)
//
// owner may include subgroups (e.g. "group/subgroup") if the host supports it;
// repo is the last path segment without the .git suffix.
func DetectOwnerRepo(cwd string) (owner, repo string, err error) {
cmd := exec.Command("git", "-C", cwd, "remote", "get-url", "origin")
out, err := cmd.Output()
if err != nil {
return "", "", fmt.Errorf("git remote get-url origin 失败 (cwd=%s): %w", cwd, err)
}
raw := strings.TrimSpace(string(out))
if raw == "" {
return "", "", fmt.Errorf("origin remote 是空的 (cwd=%s)", cwd)
}
return parseRemoteURL(raw)
}
// parseRemoteURL is the pure-logic core of DetectOwnerRepo, extracted for testing.
func parseRemoteURL(raw string) (owner, repo string, err error) {
// Try scp-like ssh: `git@host:owner/repo.git` (only if not URI-shaped).
if !strings.Contains(raw, "://") {
if m := sshLikeRegex.FindStringSubmatch(raw); m != nil {
return splitOwnerRepo(m[2])
}
}
// Try URL form (http/https/ssh://).
u, perr := url.Parse(raw)
if perr != nil {
return "", "", fmt.Errorf("无法解析 origin URL %q: %w", raw, perr)
}
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return "", "", fmt.Errorf("origin URL %q 没有 path 部分", raw)
}
return splitOwnerRepo(path)
}
// splitOwnerRepo turns "owner/repo(.git)" into ("owner", "repo").
func splitOwnerRepo(path string) (owner, repo string, err error) {
path = strings.TrimSuffix(path, "/")
path = strings.TrimSuffix(path, ".git")
idx := strings.LastIndex(path, "/")
if idx <= 0 || idx == len(path)-1 {
return "", "", fmt.Errorf("origin path %q 不是 owner/repo 形式", path)
}
return path[:idx], path[idx+1:], nil
}
+41
View File
@@ -0,0 +1,41 @@
package repo
import "testing"
func TestParseRemoteURL(t *testing.T) {
cases := []struct {
raw, owner, repo string
}{
{"https://gitea.example.com/foo/bar.git", "foo", "bar"},
{"https://gitea.example.com/foo/bar", "foo", "bar"},
{"http://localhost:3000/foo/bar.git", "foo", "bar"},
{"git@gitea.example.com:foo/bar.git", "foo", "bar"},
{"git@gitea.example.com:foo/bar", "foo", "bar"},
{"ssh://git@gitea.example.com:22/foo/bar.git", "foo", "bar"},
}
for _, c := range cases {
t.Run(c.raw, func(t *testing.T) {
owner, repo, err := parseRemoteURL(c.raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if owner != c.owner || repo != c.repo {
t.Fatalf("got %s/%s want %s/%s", owner, repo, c.owner, c.repo)
}
})
}
}
func TestParseRemoteURLRejectsBadInput(t *testing.T) {
bad := []string{
"",
"not a url",
"https://gitea.example.com/",
"https://gitea.example.com/onlyowner",
}
for _, raw := range bad {
if _, _, err := parseRemoteURL(raw); err == nil {
t.Fatalf("expected error for %q", raw)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
// Package state embeds the issue state JSON schema and exposes a Validate
// helper for spx subcommands.
//
// The schema lives in the repo root under schemas/ so non-Go tooling
// (TypeScript / docs) can pick it up too; we embed it at build time so the
// shipped binary doesn't depend on the source tree.
package state
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/santhosh-tekuri/jsonschema/v5"
)
//go:embed schema.json
var schemaBytes []byte
var (
compiled *jsonschema.Schema
compiledOnce sync.Once
compiledErr error
)
// SchemaBytes returns the embedded schema document. Useful for tooling that
// wants to dump or re-publish the schema.
func SchemaBytes() []byte {
out := make([]byte, len(schemaBytes))
copy(out, schemaBytes)
return out
}
// compile parses the embedded schema once and caches it.
func compile() (*jsonschema.Schema, error) {
compiledOnce.Do(func() {
c := jsonschema.NewCompiler()
c.Draft = jsonschema.Draft7
// Use a synthetic URL — the real $id in the schema is just a marker.
const url = "memory://state-json.schema.json"
if err := c.AddResource(url, strings.NewReader(string(schemaBytes))); err != nil {
compiledErr = fmt.Errorf("加载内嵌 schema 失败: %w", err)
return
}
sch, err := c.Compile(url)
if err != nil {
compiledErr = fmt.Errorf("编译内嵌 schema 失败: %w", err)
return
}
compiled = sch
})
return compiled, compiledErr
}
// Validate parses jsonBytes as JSON and validates the resulting value against
// the embedded state schema. Returns nil on success and a descriptive error
// (listing failing fields and rules) on validation failure.
func Validate(jsonBytes []byte) error {
sch, err := compile()
if err != nil {
return err
}
var doc any
if err := json.Unmarshal(jsonBytes, &doc); err != nil {
return fmt.Errorf("state JSON 解析失败: %w", err)
}
if err := sch.Validate(doc); err != nil {
return formatValidationError(err)
}
return nil
}
// ValidateValue validates an already-parsed value (e.g. the merged map).
func ValidateValue(v any) error {
sch, err := compile()
if err != nil {
return err
}
if err := sch.Validate(v); err != nil {
return formatValidationError(err)
}
return nil
}
// formatValidationError flattens a jsonschema validation tree into a
// human-readable, multi-line error listing each failing field and rule.
func formatValidationError(err error) error {
ve, ok := err.(*jsonschema.ValidationError)
if !ok {
return fmt.Errorf("state JSON 校验失败: %w", err)
}
var lines []string
collectValidationLines(ve, &lines)
if len(lines) == 0 {
return fmt.Errorf("state JSON 校验失败: %s", ve.Error())
}
return fmt.Errorf("state JSON 校验失败:\n - %s", strings.Join(lines, "\n - "))
}
// collectValidationLines walks the validation tree and emits one line per leaf
// failure: "<json-pointer>: <message>".
func collectValidationLines(ve *jsonschema.ValidationError, out *[]string) {
if ve == nil {
return
}
if len(ve.Causes) == 0 {
loc := ve.InstanceLocation
if loc == "" {
loc = "(root)"
}
*out = append(*out, fmt.Sprintf("%s: %s", loc, ve.Message))
return
}
for _, c := range ve.Causes {
collectValidationLines(c, out)
}
}
+91
View File
@@ -0,0 +1,91 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/cruldra/superpowers-vscode/schemas/state-json.schema.json",
"title": "Superpowers VSCode issue state JSON",
"description": "持久化在 issue 最后一条 comment 里的 JSON blobKanbanPanel 用来恢复 issue 在看板上的列、关联的 spec/plan/PR/branch/worktree、以及各个会话 id 等运行时状态。所有字段均可选,缺失字段由 issueLoader 用默认值兜底。",
"type": "object",
"additionalProperties": false,
"properties": {
"column": {
"type": "string",
"description": "看板列 id;缺失时 issueLoader 按 issue.state 兜底为 todo/done。",
"enum": ["todo", "in-progress", "review", "done"]
},
"sessionId": {
"type": "string",
"description": "讨论/头脑风暴会话的 Claude Code session id,用于 resume 已有对话。",
"minLength": 1
},
"implementSessionId": {
"type": "string",
"description": "实施阶段 (implement) 的 Claude Code session id,与讨论 sessionId 隔离。",
"minLength": 1
},
"reviewSessionId": {
"type": "string",
"description": "审查阶段的会话 idv1 存 codex thread id),用于 webhook synchronize 时 resume。",
"minLength": 1
},
"testSessionId": {
"type": "string",
"description": "测试阶段的 Claude Code session idPR 合并后手动启动的测试会话,用于 resume。",
"minLength": 1
},
"profilePath": {
"type": "string",
"description": "创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。",
"minLength": 1
},
"specFile": {
"type": "string",
"description": "本工单的 spec 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"planFile": {
"type": "string",
"description": "本工单的 plan 文档路径,相对 workspace,必须形如 `dir/.../name.md`。",
"minLength": 1,
"pattern": "^[^\\s]+/[^\\s]+\\.md$"
},
"prDiffFile": {
"type": "string",
"description": "本工单的 PR 变更摘要文件路径,相对 workspace,必须位于 docs/pr-diff/ 下。",
"minLength": 1,
"pattern": "^docs/pr-diff/[^\\s]+\\.md$"
},
"pr": {
"type": "string",
"description": "本工单关联的 PR number(字符串形式,webhook 触发后写入)。",
"minLength": 1
},
"prMerged": {
"type": "boolean",
"description": "关联 PR 是否已合并;优先以 PR API 实时结果为准,此字段是 fallback。"
},
"branch": {
"type": "string",
"description": "实施分支名,例如 `feature/<hash>`。",
"minLength": 1
},
"worktreePath": {
"type": "string",
"description": "实施 worktree 的 workspace 相对路径。",
"minLength": 1
},
"implementStatus": {
"type": "string",
"description": "实施流程生命周期状态。",
"enum": ["running", "done", "failed"]
},
"color": {
"type": "string",
"description": "Terminal/tab 配色(一个 terminal.ansi* ThemeColor key),首次开会话时固化,后续所有会话复用同一色。",
"minLength": 1
},
"autoReview": {
"type": "boolean",
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package state
import (
"bytes"
"os"
"path/filepath"
"runtime"
"testing"
)
// TestSchemaMirrorInSync makes sure the embedded copy under internal/state/
// matches the canonical schemas/state-json.schema.json at the repo root.
// The two-file layout exists so plain `go build` keeps working (embed needs
// the file inside the package tree) while non-Go tooling can still consume
// the root schemas/ directory. Drift between the two would be silent without
// this check.
func TestSchemaMirrorInSync(t *testing.T) {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("无法定位测试文件路径")
}
pkgDir := filepath.Dir(thisFile)
canonical := filepath.Join(pkgDir, "..", "..", "..", "schemas", "state-json.schema.json")
canonicalBytes, err := os.ReadFile(canonical)
if err != nil {
t.Fatalf("读取根目录 schema 失败: %v", err)
}
if !bytes.Equal(canonicalBytes, schemaBytes) {
t.Fatalf("internal/state/schema.json 与 schemas/state-json.schema.json 内容不一致;请 `make sync-schema` 同步")
}
}
func TestValidateAcceptsEmpty(t *testing.T) {
if err := Validate([]byte(`{}`)); err != nil {
t.Fatalf("空对象应当通过校验,得到: %v", err)
}
}
func TestValidateRejectsBadColumn(t *testing.T) {
err := Validate([]byte(`{"column":"todoo"}`))
if err == nil {
t.Fatal("非法 column 应当被拒绝")
}
}
func TestValidateRejectsUnknownField(t *testing.T) {
err := Validate([]byte(`{"weirdField":1}`))
if err == nil {
t.Fatal("未声明字段应当被 additionalProperties:false 拒绝")
}
}
func TestValidateRejectsBadSpecFile(t *testing.T) {
err := Validate([]byte(`{"specFile":"..."}`))
if err == nil {
t.Fatal("占位 `...` 应当被 pattern 拒绝")
}
}
func TestValidateRejectsBadPrDiffFile(t *testing.T) {
err := Validate([]byte(`{"prDiffFile":"docs/superpowers/plans/foo.md"}`))
if err == nil {
t.Fatal("非 docs/pr-diff/*.md 路径应当被 prDiffFile pattern 拒绝")
}
}
func TestValidateAcceptsFullState(t *testing.T) {
full := `{
"column":"in-progress",
"sessionId":"abc",
"implementSessionId":"impl-1",
"reviewSessionId":"rev-1",
"profilePath":"/home/x/.claude/settings.json",
"specFile":"docs/superpowers/specs/foo.md",
"planFile":"docs/superpowers/plans/foo.md",
"prDiffFile":"docs/pr-diff/pr-42-issue-7.md",
"pr":"42",
"prMerged":true,
"branch":"feature/abc",
"worktreePath":".worktrees/foo",
"implementStatus":"done",
"color":"terminal.ansiBlue",
"autoReview":true
}`
if err := Validate([]byte(full)); err != nil {
t.Fatalf("完整合法 state 应当通过校验,得到: %v", err)
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package tea reads the tea CLI config to extract Gitea host + token.
//
// tea config typically lives at $HOME/.config/tea/config.yml. Older versions
// of tea (<v0.10) stored the token inline in the YAML; newer versions move
// tokens into a system keyring and only keep metadata in the file.
//
// We support the inline form here (matches the spx task contract). If the
// token field is missing, callers should consider falling back to a
// GITEA_TOKEN environment variable and emit a clear error otherwise.
package tea
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// TeaLogin represents a single login entry in tea config.yml.
type TeaLogin struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Default bool `yaml:"default"`
}
// TeaConfig is the top-level structure of tea config.yml.
type TeaConfig struct {
Logins []TeaLogin `yaml:"logins"`
}
// DefaultConfigPath returns the conventional path to tea's config.yml.
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("解析 home 目录失败: %w", err)
}
return filepath.Join(home, ".config", "tea", "config.yml"), nil
}
// load reads and parses the tea config file at the given path.
func load(path string) (*TeaConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取 tea config %s 失败: %w", path, err)
}
var cfg TeaConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析 tea config %s 失败: %w", path, err)
}
return &cfg, nil
}
// LoadDefault returns host and token from the default login entry.
//
// If multiple logins have default=true, the first wins. If no entry is marked
// default, the first entry is used.
func LoadDefault() (host, token string, err error) {
path, err := DefaultConfigPath()
if err != nil {
return "", "", err
}
cfg, err := load(path)
if err != nil {
return "", "", err
}
if len(cfg.Logins) == 0 {
return "", "", fmt.Errorf("tea config %s 里没有 logins 条目,请先运行 `tea login add`", path)
}
var chosen *TeaLogin
for i := range cfg.Logins {
if cfg.Logins[i].Default {
chosen = &cfg.Logins[i]
break
}
}
if chosen == nil {
chosen = &cfg.Logins[0]
}
return chosen.URL, chosen.Token, nil
}
// LoadByHost returns the token for the login whose URL matches host.
//
// Host matching is case-insensitive and ignores trailing slashes.
func LoadByHost(host string) (token string, err error) {
path, err := DefaultConfigPath()
if err != nil {
return "", err
}
cfg, err := load(path)
if err != nil {
return "", err
}
want := strings.TrimRight(strings.ToLower(host), "/")
for _, l := range cfg.Logins {
if strings.TrimRight(strings.ToLower(l.URL), "/") == want {
return l.Token, nil
}
}
return "", fmt.Errorf("tea config 里没找到 host=%s 的 login", host)
}