feat(templates): 新增 shortcuts/_template 与 skills/_template 开发脚手架(起步资源包缺失项)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
maidamaliziasimnw 2026-07-10 16:14:27 +00:00
parent 604303e953
commit bb89f671b1
4 changed files with 295 additions and 0 deletions

View File

@ -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 描述中附生产实测复现步骤

View File

@ -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/repoflag 优先,其次 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)
},
},
}
}

View File

@ -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)
}
}

74
skills/_template/SKILL.md Normal file
View File

@ -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) 了解认证和全局参数。
---
## 功能概述
< 24 条编号列表说明 Skill 做什么产出什么>
1. **<能力一>** — <说明>
2. **<能力二>** — <说明>
## 使用场景
- <场景一用户会怎么说>
- <场景二>
## 执行步骤
### 第 1 步:<采集>
```bash
gitlink-cli <命令> --owner <owner> --repo <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 声明必须保留并按读/写性质改写第二条