feat(meta): add attachment and metadata shortcuts

This commit is contained in:
wangyue789 2026-05-28 17:04:53 +08:00
parent 52b7093846
commit 07f4e66fc1
12 changed files with 780 additions and 42 deletions

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, attachments, metadata lookup, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@ -83,7 +83,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
## Why gitlink-cli?
- **Agent-Native Design** — Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **Wide Coverage** — Repository, Issue, PR, Webhook, Attachment, Metadata, Member, Branch, Release, CI, Pipeline, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -106,6 +106,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔧 CI | View builds, logs, CI/CD operations |
| ⚙️ Pipeline | Run, inspect, enable, disable, delete pipeline workflows and logs |
| 🔔 Webhook | Manage repo webhooks and test deliveries |
| 📎 Attachment | Upload files and delete attachments with dry-run safety |
| 🧭 Metadata | List license and .gitignore templates |
| 🔍 Search | Search repositories, users |
| 👤 User | View user profiles and info |
| 📋 PM | Sprint management, kanban boards, weekly reports |
@ -477,6 +479,25 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### Attachment and Metadata
```bash
# List license templates
gitlink-cli meta +licenses --name MIT
# List .gitignore templates
gitlink-cli meta +ignores --name Go
# Preview an attachment upload without changing data
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot" --dry-run
# Upload an attachment
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot"
# Preview attachment deletion
gitlink-cli attachment +delete --uuid <uuid> --dry-run
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
@ -645,6 +666,8 @@ See [skills/README.md](skills/README.md) for details.
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
| `gitlink-pipeline` | Pipeline workflow operations (runs, logs, enable, disable, delete, etc.) |
| `gitlink-attachment` | Attachment upload and deletion workflows |
| `gitlink-meta` | Read-only license and .gitignore template lookup |
| `gitlink-search` | Search (repositories, users, etc.) |
| `gitlink-org` | Organization management (members, teams, etc.) |
| `gitlink-user` | User management (profile info, etc.) |
@ -681,6 +704,8 @@ gitlink-cli/
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # Search shortcuts
│ ├── user/ # User shortcuts
│ ├── attachment/ # Attachment shortcuts
│ ├── meta/ # Metadata shortcuts
│ └── register.go # Registration entry point
├── skills/ # AI Agent Skills
│ ├── README.md # Skills guide

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、附件、元数据查询、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
**[English](./README.md)**
@ -83,7 +83,7 @@
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **广泛覆盖** — 仓库、Issue、PR、Webhook、附件、元数据、成员、分支、Release、CI、Pipeline、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -105,6 +105,8 @@
| 🏢 组织 | 管理组织、成员、团队 |
| 🔧 CI | 查看构建、日志、CI/CD 操作 |
| ⚙️ Pipeline | 运行、查看、启停、删除流水线工作流并查询日志 |
| 📎 附件 | 上传文件、删除附件,支持 dry-run 预览 |
| 🧭 元数据 | 查询许可证模板和 .gitignore 模板 |
| 🔍 搜索 | 搜索仓库、用户 |
| 👤 用户 | 查看用户资料和信息 |
| 📋 项目管理 | Sprint 管理、看板、周报 |
@ -455,6 +457,25 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### 附件和元数据
```bash
# 查询许可证模板
gitlink-cli meta +licenses --name MIT
# 查询 .gitignore 模板
gitlink-cli meta +ignores --name Go
# 预览附件上传,不修改线上数据
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot" --dry-run
# 上传附件
gitlink-cli attachment +upload --file screenshot.png --description "issue screenshot"
# 预览删除附件
gitlink-cli attachment +delete --uuid <uuid> --dry-run
```
### Raw API
Shortcuts 未覆盖的接口可通过 Raw API 直接调用:
@ -524,6 +545,8 @@ git push gitlink
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
| `gitlink-pipeline` | 流水线工作流操作(运行、日志、启停、删除等) |
| `gitlink-attachment` | 附件上传与删除操作 |
| `gitlink-meta` | 许可证和 .gitignore 模板查询 |
| `gitlink-search` | 搜索功能(仓库、用户等) |
| `gitlink-user` | 用户管理(个人信息等) |
| `gitlink-pm` | 项目管理Sprint、看板、周报等 |
@ -558,6 +581,8 @@ gitlink-cli/
│ ├── pipeline/ # Pipeline shortcuts
│ ├── search/ # 搜索 shortcuts
│ ├── user/ # 用户 shortcuts
│ ├── attachment/ # 附件 shortcuts
│ ├── meta/ # 元数据 shortcuts
│ └── register.go # 注册入口
├── skills/ # AI Agent Skills
│ ├── README.md # Skills 使用指南

View File

@ -0,0 +1,21 @@
# Meta and Attachment Shortcuts
## Summary
This change adds high-level shortcuts for GitLink OpenAPI endpoints that were previously only reachable through Raw API:
- `meta +licenses``GET /api/licenses.json`
- `meta +ignores``GET /api/ignores.json`
- `attachment +upload``POST /api/attachments.json`
- `attachment +delete``DELETE /api/attachments/{uuid}.json`
## User Value
- Maintainers can query license and `.gitignore` templates before creating repositories.
- Agents can upload files once, capture the returned attachment UUID/URL, and reuse it in Issue/PR/comment workflows.
- Destructive attachment deletion supports `--dry-run` to preview the request before remote mutation.
## Validation
- Unit tests cover query parameters, multipart upload fields, dry-run behavior, missing local files, and deletion.
- README and Skill docs include command examples and Agent safety guidance.

View File

@ -0,0 +1,183 @@
package attachment
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns attachment upload/delete shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "upload",
Description: "Upload an attachment file",
Flags: []common.Flag{
{Name: "file", Short: "f", Usage: "Local file path to upload", Required: true},
{Name: "description", Short: "d", Usage: "Attachment description"},
{Name: "container-id", Usage: "Optional container model ID"},
{Name: "container-type", Usage: "Optional container model type"},
{Name: "dry-run", Usage: "Preview the multipart fields without uploading the file", Bool: true, Default: "false"},
},
Run: runUpload,
},
{
Name: "delete",
Description: "Delete an attachment by UUID",
Flags: []common.Flag{
{Name: "uuid", Short: "u", Usage: "Attachment UUID", Required: true},
{Name: "dry-run", Usage: "Preview the delete request without deleting the attachment", Bool: true, Default: "false"},
},
Run: runDelete,
},
}
}
func runUpload(ctx *common.RuntimeContext) error {
filePath, err := ctx.RequireArg("file")
if err != nil {
return err
}
fields := attachmentFields(ctx)
if parseBool(ctx.Arg("dry-run")) {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "upload_attachment",
"method": "POST",
"path": "/attachments",
"file": filePath,
"filename": filepath.Base(filePath),
"fields": fields,
})
}
env, err := uploadAttachment(ctx, filePath, fields)
if err != nil {
return err
}
return ctx.Output(env)
}
func runDelete(ctx *common.RuntimeContext) error {
uuid, err := ctx.RequireArg("uuid")
if err != nil {
return err
}
path := fmt.Sprintf("/attachments/%s", uuid)
if parseBool(ctx.Arg("dry-run")) {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "delete_attachment",
"method": "DELETE",
"path": path,
})
}
env, err := ctx.CallAPI("DELETE", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func attachmentFields(ctx *common.RuntimeContext) map[string]string {
fields := map[string]string{}
for _, name := range []string{"description", "container-id", "container-type"} {
if value := ctx.Arg(name); value != "" {
fields[apiFieldName(name)] = value
}
}
return fields
}
func apiFieldName(flagName string) string {
switch flagName {
case "container-id":
return "container_id"
case "container-type":
return "container_type"
default:
return flagName
}
}
func uploadAttachment(ctx *common.RuntimeContext, filePath string, fields map[string]string) (*output.Envelope, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("open attachment file: %w", err)
}
defer file.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
if err != nil {
return nil, fmt.Errorf("create multipart file field: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("read attachment file: %w", err)
}
for key, value := range fields {
if err := writer.WriteField(key, value); err != nil {
return nil, fmt.Errorf("write multipart field %s: %w", key, err)
}
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close multipart writer: %w", err)
}
url := apiURL(ctx.Client.BaseURL, "/attachments")
req, err := http.NewRequest("POST", url, &body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
httpClient := ctx.Client.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData)))
}
var parsed interface{}
if err := json.Unmarshal(respData, &parsed); err != nil {
return output.SuccessEnvelope(string(respData), nil), nil
}
if data, ok := parsed.(map[string]interface{}); ok {
if status, ok := data["status"].(float64); ok && status != 0 && status != 1 && status != 200 {
message, _ := data["message"].(string)
return nil, fmt.Errorf("[%v] %s", status, message)
}
}
return output.SuccessEnvelope(parsed, nil), nil
}
func apiURL(baseURL, path string) string {
fullPath := path
if !strings.HasSuffix(fullPath, ".json") {
fullPath += ".json"
}
return strings.TrimRight(baseURL, "/") + fullPath
}
func parseBool(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "true")
}

View File

@ -0,0 +1,183 @@
package attachment
import (
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestAttachmentUploadDryRunDoesNotCallAPI(t *testing.T) {
server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runAttachmentShortcut(t, server, "upload", map[string]string{
"file": filepath.Join(t.TempDir(), "missing.txt"),
"description": "design screenshot",
"container-id": "123",
"container-type": "Issue",
"dry-run": "true",
})
if err != nil {
t.Fatalf("upload dry-run failed: %v", err)
}
}
func TestAttachmentUploadMultipartPayload(t *testing.T) {
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "note.txt")
if err := os.WriteFile(filePath, []byte("hello attachment"), 0600); err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertAttachmentRequest(t, r, "POST", "/attachments.json")
if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "multipart/form-data;") {
t.Fatalf("got content-type %q, want multipart/form-data", got)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Fatalf("failed to parse multipart form: %v", err)
}
assertFormValue(t, r.MultipartForm, "description", "design screenshot")
assertFormValue(t, r.MultipartForm, "container_id", "123")
assertFormValue(t, r.MultipartForm, "container_type", "Issue")
file, header, err := r.FormFile("file")
if err != nil {
t.Fatalf("file field missing: %v", err)
}
defer file.Close()
if header.Filename != "note.txt" {
t.Fatalf("got filename %q, want note.txt", header.Filename)
}
data, err := io.ReadAll(file)
if err != nil {
t.Fatalf("failed to read uploaded file: %v", err)
}
if string(data) != "hello attachment" {
t.Fatalf("got file content %q", string(data))
}
writeAttachmentJSON(t, w, map[string]interface{}{
"id": "uuid-1",
"title": "note.txt",
"filesize": "16 Bytes",
"is_pdf": false,
"url": "/api/attachments/uuid-1",
"content_type": "text/plain",
})
})
defer server.Close()
err := runAttachmentShortcut(t, server, "upload", map[string]string{
"file": filePath,
"description": "design screenshot",
"container-id": "123",
"container-type": "Issue",
})
if err != nil {
t.Fatalf("upload shortcut failed: %v", err)
}
}
func TestAttachmentUploadMissingFile(t *testing.T) {
server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("missing file should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runAttachmentShortcut(t, server, "upload", map[string]string{"file": filepath.Join(t.TempDir(), "missing.txt")})
if err == nil {
t.Fatal("expected missing file to return an error")
}
if !strings.Contains(err.Error(), "open attachment file") {
t.Fatalf("got error %q, want open attachment file", err.Error())
}
}
func TestAttachmentDelete(t *testing.T) {
server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertAttachmentRequest(t, r, "DELETE", "/attachments/uuid-1.json")
writeAttachmentJSON(t, w, map[string]interface{}{"status": 0, "message": "删除成功"})
})
defer server.Close()
if err := runAttachmentShortcut(t, server, "delete", map[string]string{"uuid": "uuid-1"}); err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestAttachmentDeleteDryRunDoesNotCallAPI(t *testing.T) {
server := newAttachmentTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
if err := runAttachmentShortcut(t, server, "delete", map[string]string{"uuid": "uuid-1", "dry-run": "true"}); err != nil {
t.Fatalf("delete dry-run failed: %v", err)
}
}
func runAttachmentShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findAttachmentShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findAttachmentShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newAttachmentTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertAttachmentRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func assertFormValue(t *testing.T, form *multipart.Form, key, want string) {
t.Helper()
values := form.Value[key]
if len(values) != 1 || values[0] != want {
t.Fatalf("got form field %s=%v, want %q", key, values, want)
}
}
func writeAttachmentJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

49
shortcuts/meta/meta.go Normal file
View File

@ -0,0 +1,49 @@
package meta
import (
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns read-only metadata lookup shortcuts.
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "licenses",
Description: "List repository license templates",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Filter license templates by name"},
},
Run: func(ctx *common.RuntimeContext) error {
q := url.Values{}
if name := ctx.Arg("name"); name != "" {
q.Set("name", name)
}
env, err := ctx.CallAPIWithQuery("GET", "/licenses", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "ignores",
Description: "List .gitignore templates",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Filter ignore templates by name"},
},
Run: func(ctx *common.RuntimeContext) error {
q := url.Values{}
if name := ctx.Arg("name"); name != "" {
q.Set("name", name)
}
env, err := ctx.CallAPIWithQuery("GET", "/ignores", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}

View File

@ -0,0 +1,93 @@
package meta
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestMetaLicensesWithNameFilter(t *testing.T) {
server := newMetaTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertMetaRequest(t, r, "GET", "/licenses.json")
if got := r.URL.Query().Get("name"); got != "MIT" {
t.Fatalf("got name query %q, want MIT", got)
}
writeMetaJSON(t, w, map[string]interface{}{
"licenses": []map[string]interface{}{{"id": 1, "name": "MIT"}},
})
})
defer server.Close()
if err := runMetaShortcut(t, server, "licenses", map[string]string{"name": "MIT"}); err != nil {
t.Fatalf("licenses shortcut failed: %v", err)
}
}
func TestMetaIgnoresWithNameFilter(t *testing.T) {
server := newMetaTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertMetaRequest(t, r, "GET", "/ignores.json")
if got := r.URL.Query().Get("name"); got != "Go" {
t.Fatalf("got name query %q, want Go", got)
}
writeMetaJSON(t, w, map[string]interface{}{
"ignores": []map[string]interface{}{{"id": 2, "name": "Go"}},
})
})
defer server.Close()
if err := runMetaShortcut(t, server, "ignores", map[string]string{"name": "Go"}); err != nil {
t.Fatalf("ignores shortcut failed: %v", err)
}
}
func runMetaShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findMetaShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findMetaShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func newMetaTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
return httptest.NewServer(handler)
}
func assertMetaRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func writeMetaJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -4,6 +4,7 @@ import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/attachment"
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
@ -13,6 +14,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/meta"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pipeline"
@ -32,45 +34,49 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(tr),
"health": health.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
"attachment": attachment.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"license": license.Shortcuts(),
"member": member.Shortcuts(),
"meta": meta.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(tr),
"health": health.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"webhook": tr.T("cmd.webhook.short"),
"health": "Project health data collection",
"workflow": "AI agent workflow analysis",
"attachment": "Attachment upload and delete operations",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"license": "License operations",
"member": "Repository member operations",
"meta": "GitLink metadata template operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": tr.T("cmd.pr.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"webhook": tr.T("cmd.webhook.short"),
"health": "Project health data collection",
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {

View File

@ -14,7 +14,7 @@ func TestRegisterAll(t *testing.T) {
"repo", "issue", "label", "license", "pr", "release", "branch",
"org", "user", "search", "ci", "workflow",
"compare", "member", "milestone", "pipeline", "webhook",
"health",
"health", "attachment", "meta",
}
groupSet := map[string]bool{}

View File

@ -98,6 +98,10 @@ skills/
│ ├── SKILL.md # 搜索操作指南
│ └── examples/
│ └── search-workflow.md # 搜索工作流
├── gitlink-attachment/ # 附件管理
│ └── SKILL.md # 附件上传与删除指南
├── gitlink-meta/ # 公开元数据查询
│ └── SKILL.md # 许可证和 .gitignore 模板查询指南
├── gitlink-user/ # 用户管理
│ └── SKILL.md # 用户操作指南
├── gitlink-org/ # 组织管理
@ -150,6 +154,8 @@ skills/
| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` |
| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` |
| **gitlink-pipeline** | 流水线工作流 | `pipeline +runs`, `pipeline +run`, `pipeline +logs` |
| **gitlink-attachment** | 附件管理 | `attachment +upload`, `attachment +delete` |
| **gitlink-meta** | 公开元数据查询 | `meta +licenses`, `meta +ignores` |
| **gitlink-pm** | 项目管理 | 通过 Raw API 访问 |
| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes |
| **gitlink-health** | 开源项目健康度 | 详情见SKILL.md |
@ -226,6 +232,20 @@ gitlink-cli org +info -i Gitlink
详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md)
### 场景 5附件和模板元数据
```bash
# 查询创建仓库时可用的模板元数据
gitlink-cli meta +licenses --name MIT
gitlink-cli meta +ignores --name Go
# 先 dry-run再上传附件
gitlink-cli attachment +upload --file screenshot.png --description "复现截图" --dry-run
gitlink-cli attachment +upload --file screenshot.png --description "复现截图"
```
详见: [gitlink-attachment/SKILL.md](gitlink-attachment/SKILL.md)、[gitlink-meta/SKILL.md](gitlink-meta/SKILL.md)
---
## 📚 文档导航
@ -249,10 +269,12 @@ gitlink-cli org +info -i Gitlink
- [gitlink-pr/SKILL.md](gitlink-pr/SKILL.md) - PR 命令
- [gitlink-issue/examples/issue-workflow.md](gitlink-issue/examples/issue-workflow.md) - Issue 工作流
**发布和搜索**:
**发布、搜索和附件**:
- [gitlink-release/SKILL.md](gitlink-release/SKILL.md) - Release 命令
- [gitlink-pipeline/SKILL.md](gitlink-pipeline/SKILL.md) - Pipeline 命令
- [gitlink-search/SKILL.md](gitlink-search/SKILL.md) - 搜索命令
- [gitlink-attachment/SKILL.md](gitlink-attachment/SKILL.md) - 附件命令
- [gitlink-meta/SKILL.md](gitlink-meta/SKILL.md) - 公开元数据命令
**组织和用户**:
- [gitlink-org/SKILL.md](gitlink-org/SKILL.md) - 组织命令

View File

@ -0,0 +1,76 @@
---
name: gitlink-attachment
version: 1.0.0
description: "附件管理:上传文件、删除附件,适用于 Issue/PR/数据集等需要附件 ID 的工作流。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli attachment --help"
---
# gitlink-attachment附件管理
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 上传/删除附件属于写操作。执行真实写入前,优先使用 `--dry-run` 预览并确认用户意图。**
## Shortcuts
| Shortcut | 说明 | 操作类型 |
|----------|------|----------|
| `attachment +upload` | 上传本地文件,返回附件 UUID/URL 等信息 | ⚠️ Write Operation |
| `attachment +delete` | 按 UUID 删除附件 | 🔴 Destructive Operation |
## 参数参考
### attachment +upload
| 参数 | 必填 | 说明 |
|------|------|------|
| `--file, -f` | 是 | 本地文件路径 |
| `--description, -d` | 否 | 附件描述 |
| `--container-id` | 否 | 归属模型 ID例如 Issue/PR/数据集记录 ID |
| `--container-type` | 否 | 归属模型类型 |
| `--dry-run` | 否 | 只预览 multipart 字段,不上传文件 |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
### attachment +delete
| 参数 | 必填 | 说明 |
|------|------|------|
| `--uuid, -u` | 是 | 附件 UUID |
| `--dry-run` | 否 | 只预览删除请求,不删除附件 |
| `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
## 使用示例
```bash
# 预览上传,不修改线上数据
gitlink-cli attachment +upload --file screenshot.png --description "复现截图" --dry-run
# 上传附件
gitlink-cli attachment +upload --file screenshot.png --description "复现截图"
# 上传并绑定到业务对象
gitlink-cli attachment +upload --file design.pdf \
--description "设计文档" \
--container-id 123 \
--container-type Issue
# 预览删除
gitlink-cli attachment +delete --uuid f5838d8f-451b-4793-a0f2-0278430e8207 --dry-run
# 删除附件
gitlink-cli attachment +delete --uuid f5838d8f-451b-4793-a0f2-0278430e8207
```
## Agent 工作流建议
1. 确认用户要上传或删除的文件/附件 UUID。
2. 写操作先执行 `--dry-run --format json`,展示将要调用的方法、路径和字段。
3. 用户确认后再执行真实命令。
4. 对上传结果,保存返回的 `id`/`url`,后续可作为 Issue、评论或数据集附件引用。
## References
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、安全规则
- GitLink OpenAPI`POST /api/attachments.json`、`DELETE /api/attachments/{uuid}.json`

View File

@ -0,0 +1,55 @@
---
name: gitlink-meta
version: 1.0.0
description: "公开元数据查询:查询 GitLink 许可证模板和 .gitignore 模板。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli meta --help"
---
# gitlink-meta公开元数据查询
**CRITICAL — 开始前建议阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、全局参数和输出格式说明。**
`meta` 命令只读取公开模板数据,不修改远端资源,适合创建仓库前查询可用的许可证和 `.gitignore` 模板。
## Shortcuts
| Shortcut | 说明 | 操作类型 |
|----------|------|----------|
| `meta +licenses` | 查询许可证模板列表 | Read |
| `meta +ignores` | 查询 `.gitignore` 模板列表 | Read |
## 参数参考
| 命令 | 参数 | 必填 | 说明 |
|------|------|------|------|
| `meta +licenses` | `--name, -n` | 否 | 按许可证名称过滤,例如 `MIT` |
| `meta +ignores` | `--name, -n` | 否 | 按模板名称过滤,例如 `Go` |
| 两者 | `--format` | 否 | 输出格式:`json`/`table`/`yaml` |
## 使用示例
```bash
# 查询许可证模板
gitlink-cli meta +licenses --name MIT --format json
# 查询 .gitignore 模板
gitlink-cli meta +ignores --name Go --format json
# 列出全部模板
gitlink-cli meta +licenses
gitlink-cli meta +ignores
```
## Agent 工作流建议
1. 创建仓库前先用 `meta +licenses` / `meta +ignores` 查询模板名称或 ID。
2. 将查询结果与用户需求对齐,例如开源许可证选择、语言模板选择。
3. 后续再调用仓库创建或更新命令,减少用户手动查网页的成本。
## References
- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证、全局参数、输出格式
- GitLink OpenAPI`GET /api/licenses.json`、`GET /api/ignores.json`