From bb89f671b18819823a10ccf7124bd2e1aa8f724b Mon Sep 17 00:00:00 2001 From: maidamaliziasimnw Date: Fri, 10 Jul 2026 16:14:27 +0000 Subject: [PATCH] =?UTF-8?q?feat(templates):=20=E6=96=B0=E5=A2=9E=20shortcu?= =?UTF-8?q?ts/=5Ftemplate=20=E4=B8=8E=20skills/=5Ftemplate=20=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E8=84=9A=E6=89=8B=E6=9E=B6=EF=BC=88=E8=B5=B7=E6=AD=A5?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8C=85=E7=BC=BA=E5=A4=B1=E9=A1=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- shortcuts/_template/README.md | 33 ++++++++++ shortcuts/_template/template.go | 99 ++++++++++++++++++++++++++++ shortcuts/_template/template_test.go | 89 +++++++++++++++++++++++++ skills/_template/SKILL.md | 74 +++++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 shortcuts/_template/README.md create mode 100644 shortcuts/_template/template.go create mode 100644 shortcuts/_template/template_test.go create mode 100644 skills/_template/SKILL.md diff --git a/shortcuts/_template/README.md b/shortcuts/_template/README.md new file mode 100644 index 0000000..b937f87 --- /dev/null +++ b/shortcuts/_template/README.md @@ -0,0 +1,33 @@ +# Shortcut 开发模板(shortcuts/_template) + +对应起步资源包「Shortcut 开发模板」:新增一个 CLI 命令组的标准模板与脚手架。 +目录以 `_` 开头,Go 工具链自动忽略,**不影响构建**;复制改名后即成为真实命令组。 + +## 使用方法(5 步,详见 [doc/dev-guide.md](../../doc/dev-guide.md)) + +```bash +# 1. 复制模板为你的命令组(例如 gadget) +cp -r shortcuts/_template shortcuts/gadget +mv shortcuts/gadget/template.go shortcuts/gadget/gadget.go +mv shortcuts/gadget/template_test.go shortcuts/gadget/gadget_test.go +# 把包名 template 全部改为 gadget,按需实现子命令 +``` + +2. 在 `shortcuts/register.go` 注册:import 你的包,并在 `groups` 列表加一行 + `{Name: "gadget", Short: tr.T("cmd.gadget.short"), Shortcuts: gadget.Shortcuts()}` +3. 在 `internal/i18n/locales/en-US.json` 与 `zh-CN.json` 中按字母序添加 `cmd.gadget.short` 等文案键 +4. 补 `httptest` 单测(模板已含可直接改写的样例)并在两份 README 补使用示例 +5. 在 `doc/changes/` 新增一篇变更说明,`make check` 全绿后提 PR + +## 模板内容 + +- `template.go`:一个最小命令组——`+list`(GET 带查询参数与分页)、`+create`(POST 必填 flag)、`+delete`(DELETE,`--yes` 确认保护),覆盖读/写/删三种典型形态与本仓库全部惯例(ResolveOwnerRepo、CallAPIWithQuery、ctx.Output 信封) +- `template_test.go`:基于 `net/http/httptest` 的单测样例(断言请求方法、路径、查询参数、请求体;不访问真实网络) + +## 惯例清单(评审常看的点) + +- 子命令一律 `+verb` 形式;写操作必须有 `--yes` 或等价确认保护 +- 所有网络调用经 `shortcuts/common` 助手,禁止在命令组内直接 `http.Get` +- 输出统一走 `ctx.Output(env)` 信封(`{ok, data}`),自动兼容 `--format json/table/yaml` 与 `--jq` +- 文案不硬编码,走 i18n 键(中英两份 locale 同步添加) +- 涉及平台 API 行为的改动,PR 描述中附生产实测复现步骤 diff --git a/shortcuts/_template/template.go b/shortcuts/_template/template.go new file mode 100644 index 0000000..0cfab79 --- /dev/null +++ b/shortcuts/_template/template.go @@ -0,0 +1,99 @@ +// Package template is the starter scaffold for a new shortcut group. +// +// 复制本目录并改名后使用(见同目录 README.md 的 5 步流程)。 +// 目录以 `_` 开头,Go 工具链自动忽略,不参与构建。 +package template + +import ( + "errors" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Shortcuts returns the command group's subcommands. +// 三个样例覆盖读 / 写 / 删三种典型形态与本仓库全部惯例。 +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List gadgets", // 实际命令请改用 i18n 键(见 dev-guide 第 4 节) + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Filter by keyword"}, + {Name: "page", Usage: "Page number", Default: "1"}, + {Name: "limit", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + // 惯例 1:先解析 owner/repo(flag 优先,其次 git remote 自动探测) + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if v := ctx.Arg("keyword"); v != "" { + q.Set("keyword", v) + } + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + // 惯例 2:所有网络调用走 common 助手(认证/错误处理/重试统一) + env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/gadgets", q) + if err != nil { + return err + } + // 惯例 3:输出统一走信封(自动兼容 --format 与 --jq) + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a gadget", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Gadget name", Required: true}, + {Name: "description", Short: "d", Usage: "Gadget description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { + return err + } + body := map[string]interface{}{"name": name} + if v := ctx.Arg("description"); v != "" { + body["description"] = v + } + env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/gadgets", body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a gadget", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Gadget ID", Required: true}, + {Name: "yes", Short: "y", Usage: "Confirm deletion", Bool: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + // 惯例 4:破坏性操作必须有 --yes 确认保护 + if ctx.Arg("yes") != "true" { + return errors.New("destructive operation: re-run with --yes to confirm") + } + env, err := ctx.CallAPI("DELETE", "/v1"+ctx.RepoPath()+"/gadgets/"+id, nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/_template/template_test.go b/shortcuts/_template/template_test.go new file mode 100644 index 0000000..f016f20 --- /dev/null +++ b/shortcuts/_template/template_test.go @@ -0,0 +1,89 @@ +package template + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// newTestContext 返回指向本地假服务的 RuntimeContext(惯例:单测不访问真实网络)。 +func newTestContext(t *testing.T, handler http.HandlerFunc, args map[string]string) (*common.RuntimeContext, *httptest.Server) { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "demo-owner", + Repo: "demo-repo", + Format: "json", + Args: args, + }, server +} + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func TestListSendsQueryParams(t *testing.T) { + ctx, _ := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("method = %s, want GET", r.Method) + } + if r.URL.Path != "/v1/demo-owner/demo-repo/gadgets.json" { + t.Fatalf("path = %s", r.URL.Path) + } + if r.URL.Query().Get("keyword") != "abc" || r.URL.Query().Get("page") != "2" { + t.Fatalf("query = %s", r.URL.RawQuery) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"gadgets":[]}`)) + }, map[string]string{"keyword": "abc", "page": "2", "limit": "20"}) + + if err := findShortcut(t, "list").Run(ctx); err != nil { + t.Fatalf("list error: %v", err) + } +} + +func TestCreateRequiresName(t *testing.T) { + ctx, _ := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request expected when required flag missing") + }, map[string]string{}) + + if err := findShortcut(t, "create").Run(ctx); err == nil { + t.Fatal("expected error for missing --name") + } +} + +func TestDeleteRefusesWithoutYes(t *testing.T) { + ctx, _ := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatal("no request expected without --yes") + }, map[string]string{"id": "7"}) + + if err := findShortcut(t, "delete").Run(ctx); err == nil { + t.Fatal("expected confirmation error without --yes") + } +} + +func TestDeleteSendsDelete(t *testing.T) { + ctx, _ := newTestContext(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != "/v1/demo-owner/demo-repo/gadgets/7.json" { + t.Fatalf("%s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":0}`)) + }, map[string]string{"id": "7", "yes": "true"}) + + if err := findShortcut(t, "delete").Run(ctx); err != nil { + t.Fatalf("delete error: %v", err) + } +} diff --git a/skills/_template/SKILL.md b/skills/_template/SKILL.md new file mode 100644 index 0000000..500145a --- /dev/null +++ b/skills/_template/SKILL.md @@ -0,0 +1,74 @@ +--- +name: gitlink-<你的-skill-名> +version: 1.0.0 +description: "<一句话功能描述>:<能做什么>。当用户需要 <触发场景 1>、<触发场景 2>、<触发关键词> 时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli <相关命令组> --help" +--- + +# gitlink-<你的-skill-名>(<中文名>) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — <只读 Skill 写"本 Skill 为只读操作";写操作 Skill 写"涉及写操作,执行前必须获得用户确认"。>** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +--- + +## 功能概述 + +<用 2–4 条编号列表说明 Skill 做什么、产出什么。> + +1. **<能力一>** — <说明> +2. **<能力二>** — <说明> + +## 使用场景 + +- <场景一(用户会怎么说)> +- <场景二> + +## 执行步骤 + +### 第 1 步:<采集> + +```bash +gitlink-cli <命令> --owner --repo --format json +``` + +<说明关键字段与分页处理(数据多时用 --all 或按 page 循环)。> + +### 第 2 步:<分析/决策> + +<写清确定性规则或判断标准,避免含糊表述,保证同输入同结论。> + +### 第 3 步:<产出/回写> + +<只读 Skill:输出报告的固定结构(建议给出 markdown 模板)。 +写操作 Skill:列出将执行的每条写命令,并要求先向用户展示计划、确认后执行。> + +## 输出格式 + +```markdown +# <报告标题> +- 结论:... +- 依据:... +``` + +## 注意事项 + +- <平台语义坑(如两代端点差异、ID 语义、分页上限),写明规避方法> +- <权限门槛(如需管理员/组织权限的接口)> +- <失败处理(命令报错时如何降级或提示用户)> + +--- + +## 模板使用说明(提交前删除本节) + +1. 复制本目录:`cp -r skills/_template skills/gitlink-<名字>` +2. 逐节填写并删除全部 `<尖括号占位符>`;frontmatter 的 `description` 决定 Agent 何时触发,务必写清触发场景与关键词 +3. 在 [`skills/README.md`](../README.md) 的索引表中加一行 +4. 用真实仓库走一遍执行步骤,把实测输出贴进「输出格式」示例 +5. 惯例:三条 CRITICAL 声明必须保留并按读/写性质改写第二条