diff --git a/cmd/root.go b/cmd/root.go index 75f8532..b628ee1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( doctorCmd "github.com/gitlink-org/gitlink-cli/cmd/doctor" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/i18n" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -53,6 +54,7 @@ func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) + rootCmd.PersistentFlags().StringVarP(&output.Query, "query", "q", "", tr.T("flag.query")) rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) diff --git a/doc/changes/output-query.md b/doc/changes/output-query.md new file mode 100644 index 0000000..4c741a8 --- /dev/null +++ b/doc/changes/output-query.md @@ -0,0 +1,29 @@ +# 全局 `--query`/`-q` 输出字段提取 + +## 动机 + +对标 `gh --jq`:Agent 与 shell 脚本消费 CLI 输出时经常只需要单个字段 +(如最新 commit 的 sha、issue 总数),此前必须依赖外部 `jq` 或自行解析 +完整 JSON 信封。新增零依赖的点分路径提取,纯 Go 标准库实现, +不引入任何第三方 JSON 查询库(避免供应链风险)。 + +## 行为 + +- 新增全局持久 flag `--query` / `-q`,对所有命令生效。 +- 路径语法:点分段;段为对象键,或非负整数作为数组下标。 + 例:`-q data.commits.0.sha`、`-q data.total_issues_count`。 +- 标量(字符串/数字/布尔/null)输出裸值,方便 shell 管道直接消费; + 对象与数组输出缩进 JSON。 +- 错误信息可操作:键不存在时列出该层全部可用键(排序后); + 数组下标越界/非数字段给出数组长度提示。 + +## 生产实测 + +- `issue +list --limit 2 -q data.issues.0.subject` → 裸标题字符串 +- `issue +list -q data.total_issues_count` → `4197` +- 误键 `-q data.issues.0.name` → 报错并列出 29 个可用键(含 `subject`) + +## 测试 + +`internal/output/query_test.go` 6 个单测:字符串/数字标量裸值输出、 +对象 JSON 输出、缺键错误含可用键列表、下标越界、数组段非数字。 diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..d734711 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -200,6 +200,7 @@ "flag.profile.start_time": "Start time (Unix timestamp)", "flag.profile.user": "Target user login (defaults to the authenticated user)", "flag.profile.year": "Year for the contribution heatmap (e.g. 2025)", + "flag.query": "Extract a value from the output by dot-separated path (e.g. data.commits.0.sha)", "flag.release.body": "Release notes", "flag.release.id": "Release ID", "flag.release.id_or_tag": "Release ID or tag", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..8b274be 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -200,6 +200,7 @@ "flag.profile.start_time": "开始时间(Unix 时间戳)", "flag.profile.user": "目标用户登录名(默认为当前认证用户)", "flag.profile.year": "贡献热力图的年份(如 2025)", + "flag.query": "按点分路径从输出中提取字段(如 data.commits.0.sha)", "flag.release.body": "发布说明", "flag.release.id": "发布 ID", "flag.release.id_or_tag": "发布 ID 或标签", diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..d104e0e 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -16,6 +16,9 @@ func Print(envelope *Envelope, format string) error { if format == "" { format = "json" } + if Query != "" { + return PrintQuery(os.Stdout, envelope, Query) + } return PrintTo(os.Stdout, envelope, format) } diff --git a/internal/output/query.go b/internal/output/query.go new file mode 100644 index 0000000..892ebf4 --- /dev/null +++ b/internal/output/query.go @@ -0,0 +1,90 @@ +package output + +import ( + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" +) + +// Query holds the global --query dot-path; when non-empty, Print extracts +// and prints only the matching value instead of the full envelope. +var Query string + +// PrintQuery extracts a value from the envelope by a dot-separated path and +// prints it. Path segments are object keys; non-negative integers index into +// arrays (e.g. "data.commits.0.sha"). Strings and other scalars are printed +// as raw values so results can be consumed directly by shell pipelines; +// objects and arrays are printed as indented JSON. +func PrintQuery(w io.Writer, envelope *Envelope, path string) error { + raw, err := json.Marshal(envelope) + if err != nil { + return err + } + var root interface{} + if err := json.Unmarshal(raw, &root); err != nil { + return err + } + value, err := resolvePath(root, path) + if err != nil { + return err + } + switch v := value.(type) { + case string: + _, err = fmt.Fprintln(w, v) + case nil: + _, err = fmt.Fprintln(w, "null") + case float64, bool: + _, err = fmt.Fprintln(w, v) + default: + data, merr := json.MarshalIndent(v, "", " ") + if merr != nil { + return merr + } + _, err = fmt.Fprintln(w, string(data)) + } + return err +} + +func resolvePath(root interface{}, path string) (interface{}, error) { + current := root + if strings.TrimSpace(path) == "" { + return nil, fmt.Errorf("query path is empty") + } + for _, segment := range strings.Split(path, ".") { + if segment == "" { + return nil, fmt.Errorf("query path %q contains an empty segment", path) + } + switch node := current.(type) { + case map[string]interface{}: + value, ok := node[segment] + if !ok { + return nil, fmt.Errorf("query key %q not found (available: %s)", segment, strings.Join(mapKeys(node), ", ")) + } + current = value + case []interface{}: + index, err := strconv.Atoi(segment) + if err != nil { + return nil, fmt.Errorf("query segment %q must be an array index (array has %d items)", segment, len(node)) + } + if index < 0 || index >= len(node) { + return nil, fmt.Errorf("query index %d out of range (array has %d items)", index, len(node)) + } + current = node[index] + default: + return nil, fmt.Errorf("query segment %q cannot descend into a scalar value", segment) + } + } + return current, nil +} + +func mapKeys(node map[string]interface{}) []string { + keys := make([]string, 0, len(node)) + for key := range node { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/output/query_test.go b/internal/output/query_test.go new file mode 100644 index 0000000..e7c97a9 --- /dev/null +++ b/internal/output/query_test.go @@ -0,0 +1,72 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func queryEnvelope() *Envelope { + return SuccessEnvelope(map[string]interface{}{ + "total_count": 2, + "commits": []interface{}{ + map[string]interface{}{"sha": "abc", "message": "first"}, + map[string]interface{}{"sha": "def", "message": "second"}, + }, + }, nil) +} + +func TestPrintQueryScalarString(t *testing.T) { + var buf bytes.Buffer + if err := PrintQuery(&buf, queryEnvelope(), "data.commits.1.sha"); err != nil { + t.Fatalf("PrintQuery returned error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "def" { + t.Fatalf("got %q, want def", got) + } +} + +func TestPrintQueryNumber(t *testing.T) { + var buf bytes.Buffer + if err := PrintQuery(&buf, queryEnvelope(), "data.total_count"); err != nil { + t.Fatalf("PrintQuery returned error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "2" { + t.Fatalf("got %q, want 2", got) + } +} + +func TestPrintQueryObjectAsJSON(t *testing.T) { + var buf bytes.Buffer + if err := PrintQuery(&buf, queryEnvelope(), "data.commits.0"); err != nil { + t.Fatalf("PrintQuery returned error: %v", err) + } + out := buf.String() + if !strings.Contains(out, `"sha": "abc"`) || !strings.Contains(out, `"message": "first"`) { + t.Fatalf("object output missing fields: %s", out) + } +} + +func TestPrintQueryMissingKeyListsAvailable(t *testing.T) { + var buf bytes.Buffer + err := PrintQuery(&buf, queryEnvelope(), "data.nope") + if err == nil || !strings.Contains(err.Error(), "commits, total_count") { + t.Fatalf("expected missing-key error listing available keys, got: %v", err) + } +} + +func TestPrintQueryIndexOutOfRange(t *testing.T) { + var buf bytes.Buffer + err := PrintQuery(&buf, queryEnvelope(), "data.commits.5") + if err == nil || !strings.Contains(err.Error(), "out of range") { + t.Fatalf("expected out-of-range error, got: %v", err) + } +} + +func TestPrintQueryNonIndexSegmentOnArray(t *testing.T) { + var buf bytes.Buffer + err := PrintQuery(&buf, queryEnvelope(), "data.commits.sha") + if err == nil || !strings.Contains(err.Error(), "array index") { + t.Fatalf("expected array-index error, got: %v", err) + } +}