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:
parent
604303e953
commit
bb89f671b1
|
|
@ -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 描述中附生产实测复现步骤
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <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 声明必须保留并按读/写性质改写第二条
|
||||
Loading…
Reference in New Issue