feat(skills): enhance agent skills with additional tags and commands
Some checks failed
CI / build-and-test (push) Failing after -29m56s
Some checks failed
CI / build-and-test (push) Failing after -29m56s
- Added language_tags, library_tags, framework_tags, and domain_tags to agent skills. - Introduced new commands: get_skill and get_guardrail for fetching specific skills and guardrails. - Updated database schema and migration scripts to accommodate new fields. - Enhanced skill listing functionality to support filtering by new tags. - Improved error handling and response structures for skill-related operations.
This commit is contained in:
@@ -13,7 +13,11 @@ type ModelPublicAgentSkills struct {
|
||||
Content resolvespec_common.SqlString `bun:"content,type:text,notnull," json:"content"`
|
||||
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
|
||||
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
|
||||
DomainTags resolvespec_common.SqlStringArray `bun:"domain_tags,type:text[],default:'{}',notnull," json:"domain_tags"`
|
||||
FrameworkTags resolvespec_common.SqlStringArray `bun:"framework_tags,type:text[],default:'{}',notnull," json:"framework_tags"`
|
||||
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
|
||||
LanguageTags resolvespec_common.SqlStringArray `bun:"language_tags,type:text[],default:'{}',notnull," json:"language_tags"`
|
||||
LibraryTags resolvespec_common.SqlStringArray `bun:"library_tags,type:text[],default:'{}',notnull," json:"library_tags"`
|
||||
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
|
||||
Tags resolvespec_common.SqlStringArray `bun:"tags,type:text[],default:'{}',notnull," json:"tags"`
|
||||
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
|
||||
|
||||
@@ -464,10 +464,16 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "list_skills",
|
||||
Description: "List all agent skills, optionally filtered by tag.",
|
||||
Description: "List agent skills (metadata only by default). Set include_content=true to get full content, or use get_skill for a single skill.",
|
||||
}, toolSet.Skills.ListSkills); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_skill",
|
||||
Description: "Fetch a single agent skill with full content by id or name.",
|
||||
}, toolSet.Skills.GetSkill); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_guardrail",
|
||||
Description: "Store an agent guardrail (constraint or safety rule).",
|
||||
@@ -486,6 +492,12 @@ func registerSkillTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet
|
||||
}, toolSet.Skills.ListGuardrails); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "get_guardrail",
|
||||
Description: "Fetch a single agent guardrail with full content by id or name.",
|
||||
}, toolSet.Skills.GetGuardrail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := addTool(server, logger, &mcp.Tool{
|
||||
Name: "add_project_skill",
|
||||
Description: "Link a skill to a project. Pass project if client is stateless.",
|
||||
@@ -637,12 +649,14 @@ func BuildToolCatalog() []tools.ToolEntry {
|
||||
{Name: "search_maintenance_history", Description: "Search the maintenance log by task name, category, or date range.", Category: "maintenance"},
|
||||
|
||||
// skills
|
||||
{Name: "add_skill", Description: "Store a reusable agent skill (behavioural instruction or capability prompt).", Category: "skills"},
|
||||
{Name: "add_skill", Description: "Store a reusable agent skill. Supports language_tags, library_tags, framework_tags, and domain_tags for precise retrieval.", Category: "skills"},
|
||||
{Name: "remove_skill", Description: "Delete an agent skill by id.", Category: "skills"},
|
||||
{Name: "list_skills", Description: "List all agent skills, optionally filtered by tag.", Category: "skills"},
|
||||
{Name: "list_skills", Description: "List agent skills (metadata only by default). Filter by tag (searches all tag fields). Set include_content=true for full bodies, or use get_skill to load one.", Category: "skills"},
|
||||
{Name: "get_skill", Description: "Fetch a single agent skill with full content by id or name. Prefer this over list_skills when you know which skill you need.", Category: "skills"},
|
||||
{Name: "add_guardrail", Description: "Store a reusable agent guardrail (constraint or safety rule).", Category: "skills"},
|
||||
{Name: "remove_guardrail", Description: "Delete an agent guardrail by id.", Category: "skills"},
|
||||
{Name: "list_guardrails", Description: "List all agent guardrails, optionally filtered by tag or severity.", Category: "skills"},
|
||||
{Name: "get_guardrail", Description: "Fetch a single agent guardrail with full content by id or name.", Category: "skills"},
|
||||
{Name: "add_project_skill", Description: "Link an agent skill to a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||
{Name: "remove_project_skill", Description: "Unlink an agent skill from a project. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||
{Name: "list_project_skills", Description: "List all skills linked to a project. Call this at the start of every project session to load agent behaviour instructions before generating new ones. Only create new skills if none are returned. Pass project explicitly when your client does not preserve MCP sessions.", Category: "skills"},
|
||||
|
||||
@@ -15,11 +15,24 @@ func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkil
|
||||
if skill.Tags == nil {
|
||||
skill.Tags = []string{}
|
||||
}
|
||||
if skill.LanguageTags == nil {
|
||||
skill.LanguageTags = []string{}
|
||||
}
|
||||
if skill.LibraryTags == nil {
|
||||
skill.LibraryTags = []string{}
|
||||
}
|
||||
if skill.FrameworkTags == nil {
|
||||
skill.FrameworkTags = []string{}
|
||||
}
|
||||
if skill.DomainTags == nil {
|
||||
skill.DomainTags = []string{}
|
||||
}
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
insert into agent_skills (name, description, content, tags)
|
||||
values ($1, $2, $3, $4)
|
||||
insert into agent_skills (name, description, content, tags, language_tags, library_tags, framework_tags, domain_tags)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
returning id, guid, created_at, updated_at
|
||||
`, skill.Name, skill.Description, skill.Content, skill.Tags)
|
||||
`, skill.Name, skill.Description, skill.Content, skill.Tags,
|
||||
skill.LanguageTags, skill.LibraryTags, skill.FrameworkTags, skill.DomainTags)
|
||||
|
||||
created := skill
|
||||
var model generatedmodels.ModelPublicAgentSkills
|
||||
@@ -45,11 +58,11 @@ func (db *DB) RemoveSkill(ctx context.Context, id int64) error {
|
||||
}
|
||||
|
||||
func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, error) {
|
||||
q := `select id, name, description, content, tags::text[], created_at, updated_at from agent_skills`
|
||||
q := `select id, name, description, content, tags::text[], language_tags::text[], library_tags::text[], framework_tags::text[], domain_tags::text[], created_at, updated_at from agent_skills`
|
||||
args := []any{}
|
||||
if t := strings.TrimSpace(tag); t != "" {
|
||||
args = append(args, t)
|
||||
q += fmt.Sprintf(" where $%d = any(tags)", len(args))
|
||||
q += fmt.Sprintf(" where $%d = any(tags) or $%d = any(language_tags) or $%d = any(library_tags) or $%d = any(framework_tags) or $%d = any(domain_tags)", len(args), len(args), len(args), len(args), len(args))
|
||||
}
|
||||
q += " order by name"
|
||||
|
||||
@@ -61,52 +74,91 @@ func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, err
|
||||
|
||||
var skills []ext.AgentSkill
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicAgentSkills
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
s, err := scanSkill(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan agent skill: %w", err)
|
||||
}
|
||||
s := ext.AgentSkill{
|
||||
ID: model.ID.Int64(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Tags: tags,
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}
|
||||
if s.Tags == nil {
|
||||
s.Tags = []string{}
|
||||
}
|
||||
skills = append(skills, s)
|
||||
}
|
||||
return skills, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, description, content, tags::text[], created_at, updated_at
|
||||
from agent_skills where id = $1
|
||||
`, id)
|
||||
const skillSelectCols = `id, name, description, content, tags::text[], language_tags::text[], library_tags::text[], framework_tags::text[], domain_tags::text[], created_at, updated_at`
|
||||
|
||||
type skillScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSkill(row skillScanner) (ext.AgentSkill, error) {
|
||||
var model generatedmodels.ModelPublicAgentSkills
|
||||
var tags []string
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
var tags, langTags, libTags, fwTags, domTags []string
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &langTags, &libTags, &fwTags, &domTags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.AgentSkill{}, err
|
||||
}
|
||||
nilToEmpty := func(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ext.AgentSkill{
|
||||
ID: model.ID.Int64(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Tags: nilToEmpty(tags),
|
||||
LanguageTags: nilToEmpty(langTags),
|
||||
LibraryTags: nilToEmpty(libTags),
|
||||
FrameworkTags: nilToEmpty(fwTags),
|
||||
DomainTags: nilToEmpty(domTags),
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetSkill(ctx context.Context, id int64) (ext.AgentSkill, error) {
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where id = $1`, id)
|
||||
s, err := scanSkill(row)
|
||||
if err != nil {
|
||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill: %w", err)
|
||||
}
|
||||
s := ext.AgentSkill{
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetSkillByName(ctx context.Context, name string) (ext.AgentSkill, error) {
|
||||
row := db.pool.QueryRow(ctx, `select `+skillSelectCols+` from agent_skills where name = $1`, name)
|
||||
s, err := scanSkill(row)
|
||||
if err != nil {
|
||||
return ext.AgentSkill{}, fmt.Errorf("get agent skill by name: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetGuardrailByName(ctx context.Context, name string) (ext.AgentGuardrail, error) {
|
||||
row := db.pool.QueryRow(ctx, `
|
||||
select id, name, description, content, severity, tags::text[], created_at, updated_at
|
||||
from agent_guardrails where name = $1
|
||||
`, name)
|
||||
|
||||
var model generatedmodels.ModelPublicAgentGuardrails
|
||||
var tags []string
|
||||
if err := row.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &model.Severity, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
return ext.AgentGuardrail{}, fmt.Errorf("get agent guardrail by name: %w", err)
|
||||
}
|
||||
g := ext.AgentGuardrail{
|
||||
ID: model.ID.Int64(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Severity: model.Severity.String(),
|
||||
Tags: tags,
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}
|
||||
if s.Tags == nil {
|
||||
s.Tags = []string{}
|
||||
if g.Tags == nil {
|
||||
g.Tags = []string{}
|
||||
}
|
||||
return s, nil
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Agent Guardrails
|
||||
@@ -253,7 +305,7 @@ func (db *DB) RemoveProjectSkill(ctx context.Context, projectID, skillID int64)
|
||||
|
||||
func (db *DB) ListProjectSkills(ctx context.Context, projectID int64) ([]ext.AgentSkill, error) {
|
||||
rows, err := db.pool.Query(ctx, `
|
||||
select s.id, s.name, s.description, s.content, s.tags::text[], s.created_at, s.updated_at
|
||||
select s.`+skillSelectCols+`
|
||||
from agent_skills s
|
||||
join project_skills ps on ps.skill_id = s.id
|
||||
where ps.project_id = $1
|
||||
@@ -266,23 +318,10 @@ func (db *DB) ListProjectSkills(ctx context.Context, projectID int64) ([]ext.Age
|
||||
|
||||
var skills []ext.AgentSkill
|
||||
for rows.Next() {
|
||||
var model generatedmodels.ModelPublicAgentSkills
|
||||
var tags []string
|
||||
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
|
||||
s, err := scanSkill(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan project skill: %w", err)
|
||||
}
|
||||
s := ext.AgentSkill{
|
||||
ID: model.ID.Int64(),
|
||||
Name: model.Name.String(),
|
||||
Description: model.Description.String(),
|
||||
Content: model.Content.String(),
|
||||
Tags: tags,
|
||||
CreatedAt: model.CreatedAt.Time(),
|
||||
UpdatedAt: model.UpdatedAt.Time(),
|
||||
}
|
||||
if s.Tags == nil {
|
||||
s.Tags = []string{}
|
||||
}
|
||||
skills = append(skills, s)
|
||||
}
|
||||
return skills, rows.Err()
|
||||
|
||||
@@ -23,10 +23,14 @@ func NewSkillsTool(db *store.DB, sessions *session.ActiveProjects) *SkillsTool {
|
||||
// add_skill
|
||||
|
||||
type AddSkillInput struct {
|
||||
Name string `json:"name" jsonschema:"unique skill name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"short description of what the skill does"`
|
||||
Content string `json:"content" jsonschema:"the full skill instruction or prompt content"`
|
||||
Tags []string `json:"tags,omitempty" jsonschema:"optional tags for grouping or filtering"`
|
||||
Name string `json:"name" jsonschema:"unique skill name"`
|
||||
Description string `json:"description,omitempty" jsonschema:"short description of what the skill does"`
|
||||
Content string `json:"content" jsonschema:"the full skill instruction or prompt content"`
|
||||
Tags []string `json:"tags,omitempty" jsonschema:"general tags for grouping or filtering"`
|
||||
LanguageTags []string `json:"language_tags,omitempty" jsonschema:"programming languages this skill applies to (e.g. go, python, typescript)"`
|
||||
LibraryTags []string `json:"library_tags,omitempty" jsonschema:"libraries or packages this skill applies to (e.g. bunrouter, pgx, axios)"`
|
||||
FrameworkTags []string `json:"framework_tags,omitempty" jsonschema:"frameworks this skill applies to (e.g. gin, nextjs, django)"`
|
||||
DomainTags []string `json:"domain_tags,omitempty" jsonschema:"topic or problem domains this skill applies to (e.g. auth, testing, migrations)"`
|
||||
}
|
||||
|
||||
type AddSkillOutput struct {
|
||||
@@ -44,10 +48,14 @@ func (t *SkillsTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in Ad
|
||||
in.Tags = []string{}
|
||||
}
|
||||
skill, err := t.store.AddSkill(ctx, ext.AgentSkill{
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
Content: strings.TrimSpace(in.Content),
|
||||
Tags: in.Tags,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Description: strings.TrimSpace(in.Description),
|
||||
Content: strings.TrimSpace(in.Content),
|
||||
Tags: in.Tags,
|
||||
LanguageTags: in.LanguageTags,
|
||||
LibraryTags: in.LibraryTags,
|
||||
FrameworkTags: in.FrameworkTags,
|
||||
DomainTags: in.DomainTags,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, AddSkillOutput{}, err
|
||||
@@ -72,10 +80,41 @@ func (t *SkillsTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||
return nil, RemoveSkillOutput{Removed: true}, nil
|
||||
}
|
||||
|
||||
// get_skill
|
||||
|
||||
type GetSkillInput struct {
|
||||
ID int64 `json:"id,omitempty" jsonschema:"skill id (preferred; use instead of name when available)"`
|
||||
Name string `json:"name,omitempty" jsonschema:"skill name (alternative to id)"`
|
||||
}
|
||||
|
||||
type GetSkillOutput struct {
|
||||
Skill ext.AgentSkill `json:"skill"`
|
||||
}
|
||||
|
||||
func (t *SkillsTool) GetSkill(ctx context.Context, _ *mcp.CallToolRequest, in GetSkillInput) (*mcp.CallToolResult, GetSkillOutput, error) {
|
||||
var (
|
||||
skill ext.AgentSkill
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case in.ID != 0:
|
||||
skill, err = t.store.GetSkill(ctx, in.ID)
|
||||
case strings.TrimSpace(in.Name) != "":
|
||||
skill, err = t.store.GetSkillByName(ctx, strings.TrimSpace(in.Name))
|
||||
default:
|
||||
return nil, GetSkillOutput{}, errRequiredField("id or name")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, GetSkillOutput{}, err
|
||||
}
|
||||
return nil, GetSkillOutput{Skill: skill}, nil
|
||||
}
|
||||
|
||||
// list_skills
|
||||
|
||||
type ListSkillsInput struct {
|
||||
Tag string `json:"tag,omitempty" jsonschema:"filter by tag"`
|
||||
Tag string `json:"tag,omitempty" jsonschema:"filter by tag (searches all tag fields: tags, language_tags, library_tags, framework_tags, domain_tags)"`
|
||||
IncludeContent bool `json:"include_content,omitempty" jsonschema:"include full skill content in results (default false — use get_skill to load content for a specific skill)"`
|
||||
}
|
||||
|
||||
type ListSkillsOutput struct {
|
||||
@@ -90,6 +129,11 @@ func (t *SkillsTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in
|
||||
if skills == nil {
|
||||
skills = []ext.AgentSkill{}
|
||||
}
|
||||
if !in.IncludeContent {
|
||||
for i := range skills {
|
||||
skills[i].Content = ""
|
||||
}
|
||||
}
|
||||
return nil, ListSkillsOutput{Skills: skills}, nil
|
||||
}
|
||||
|
||||
@@ -182,6 +226,36 @@ func (t *SkillsTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest,
|
||||
return nil, ListGuardrailsOutput{Guardrails: guardrails}, nil
|
||||
}
|
||||
|
||||
// get_guardrail
|
||||
|
||||
type GetGuardrailInput struct {
|
||||
ID int64 `json:"id,omitempty" jsonschema:"guardrail id (preferred; use instead of name when available)"`
|
||||
Name string `json:"name,omitempty" jsonschema:"guardrail name (alternative to id)"`
|
||||
}
|
||||
|
||||
type GetGuardrailOutput struct {
|
||||
Guardrail ext.AgentGuardrail `json:"guardrail"`
|
||||
}
|
||||
|
||||
func (t *SkillsTool) GetGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in GetGuardrailInput) (*mcp.CallToolResult, GetGuardrailOutput, error) {
|
||||
var (
|
||||
g ext.AgentGuardrail
|
||||
err error
|
||||
)
|
||||
switch {
|
||||
case in.ID != 0:
|
||||
g, err = t.store.GetGuardrail(ctx, in.ID)
|
||||
case strings.TrimSpace(in.Name) != "":
|
||||
g, err = t.store.GetGuardrailByName(ctx, strings.TrimSpace(in.Name))
|
||||
default:
|
||||
return nil, GetGuardrailOutput{}, errRequiredField("id or name")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, GetGuardrailOutput{}, err
|
||||
}
|
||||
return nil, GetGuardrailOutput{Guardrail: g}, nil
|
||||
}
|
||||
|
||||
// add_project_skill
|
||||
|
||||
type AddProjectSkillInput struct {
|
||||
|
||||
@@ -217,14 +217,18 @@ type ContactHistory struct {
|
||||
// Agent Skills & Guardrails
|
||||
|
||||
type AgentSkill struct {
|
||||
ID int64 `json:"id"`
|
||||
GUID uuid.UUID `json:"guid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int64 `json:"id"`
|
||||
GUID uuid.UUID `json:"guid"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
LanguageTags []string `json:"language_tags"`
|
||||
LibraryTags []string `json:"library_tags"`
|
||||
FrameworkTags []string `json:"framework_tags"`
|
||||
DomainTags []string `json:"domain_tags"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AgentGuardrail struct {
|
||||
|
||||
Reference in New Issue
Block a user