Šķîþ ţö ḿàîñ çöñţéñţ

Çŕéàţîñĝ Ţööļš ŵîţĥ Þàŕàḿéţéŕ Šçĥéḿàš

Ţĥîš ĝüîđé çöṽéŕš ĥöŵ ţö çŕéàţé à ţööļ ŵîţĥ à þàŕàḿéţéŕ šçĥéḿà šö ţĥàţ ţĥé ÜÎ àñđ ÇĻÎ çàñ àüţö-ĝéñéŕàţé çöñƒîĝüŕàţîöñ ƒöŕḿš àñđ ṽàļîđàţé üšéŕ îñþüţ.

Ţööļ ƃàšîçš

Éṽéŕý ţööļ îš ƃüîļţ öñ tool.BaseTool. Ƒöŕ Ƃļöçķš (ţĥé ţŕàñšļàţàƃļé üñîţ) à ţööļ šéţš éẋàçţļý öñé çàþàƃîļîţý-ţýþéđ ĥàñđļéŕ, àñđ ţĥé ṽîéŵ îţ ŕéçéîṽéš ƃöüñđš ŵĥàţ îţ ḿàý ŵŕîţé (É-03): Annotate(BlockView) ŕéàđš šöüŕçé àñđ ţàŕĝéţ àñđ ŵŕîţéš öñļý öṽéŕļàýš, àññöţàţîöñš, àñđ þŕöþéŕţîéš; Produce(VariantView) ŵŕîţéš ţĥé ţàŕĝéţ; Transform(BlockView) ŕéţüŕñš àñ éđîţ þļàñ ţĥàţ ţĥé ƒŕàḿéŵöŕķ àþþļîéŕ üšéš ţö ŕéŵŕîţé ţĥé šöüŕçé. Ţĥé ŵŕöñĝ ŵŕîţéš àŕé ñöţ öñ ţĥé ṽîéŵ: àñ àññöţàţöŕ ĥàš ñö ţàŕĝéţ šéţţéŕ ţö çàļļ, àñđ à ţŕàñšƒöŕḿéŕ ĥöļđš ñö šöüŕçé šéţţéŕ àţ àļļ. Öţĥéŕ Þàŕţ ţýþéš (Đàţà, Ḿéđîà, Ļàýéŕ, Ĝŕöüþ) üšé ţĥé üñţýþéđ Handle*Fn ƒîéļđš. Þàŕţš ýöü đöñ'ţ ĥàñđļé þàšš ţĥŕöüĝĥ üñçĥàñĝéđ; à ĥàñđļéŕ ŕéţüŕñš àñ error (àñđ ḿàý çàļļ v.Drop() ţö ŕéḿöṽé ţĥé ƃļöçķ ƒŕöḿ ţĥé šţŕéàḿ).

package mytool

import (
"strings"

"github.com/neokapi/neokapi/core/model"
"github.com/neokapi/neokapi/core/tool"
)

func NewMyTool(cfg *MyToolConfig) *tool.BaseTool {
t := &tool.BaseTool{
ToolName: "my-tool",
ToolDescription: "Does something useful",
Cfg: cfg,
}
// A tool declares its capability by which block handler it sets: the
// parameter type bounds what it may write (E-03):
// Annotate(BlockView): read-only: overlays / annotations / properties
// Produce(VariantView): writes the target; source stays read-only
// Transform(BlockView): edit producer: returns an EditPlan the
// framework applier applies to the source
// This tool writes a target, so it sets Produce.
t.Produce = func(v tool.VariantView) error {
if !v.Translatable() {
return nil // pass through
}
conf := t.Cfg.(*MyToolConfig)
text := v.SourceText()
if conf.Uppercase {
text = strings.ToUpper(text)
}
v.SetTargetText(model.LocaleID(conf.TargetLocale), text)
return nil
}
return t
}

Đéçļàŕîñĝ à þàŕàḿéţéŕ šçĥéḿà ŵîţĥ šţŕüçţ ţàĝš

Đéƒîñé à çöñƒîĝ šţŕüçţ ŵîţĥ éẋþöŕţéđ ƒîéļđš. Ţĥé schema šţŕüçţ ţàĝ çöñţŕöļš ĥöŵ éàçĥ ƒîéļđ àþþéàŕš îñ ţĥé ĝéñéŕàţéđ šçĥéḿà:

type MyToolConfig struct {
TargetLocale string `json:"targetLocale" schema:"description=Target locale for output"`
Uppercase bool `json:"uppercase" schema:"description=Convert text to uppercase,default=false"`
MaxLength int `json:"maxLength" schema:"description=Maximum output length (0 = unlimited),default=0"`
Mode string `json:"mode" schema:"description=Processing mode,enum=fast|thorough|balanced,default=balanced"`
}

Šüþþöŕţéđ šţŕüçţ ţàĝ ķéýš

ĶéýÉẋàḿþļéÞüŕþöšé
descriptiondescription=Target localeĤüḿàñ-ŕéàđàƃļé ƒîéļđ đéšçŕîþţîöñ
defaultdefault=trueĐéƒàüļţ ṽàļüé
enumenum=fast|thoroughÀļļöŵéđ ṽàļüéš (þîþé-šéþàŕàţéđ)
minmin=0Ḿîñîḿüḿ ñüḿéŕîç ṽàļüé
maxmax=100Ḿàẋîḿüḿ ñüḿéŕîç ṽàļüé
widgetwidget=regexBuilderÜÎ ŵîđĝéţ ĥîñţ
placeholderplaceholder=enÎñþüţ þļàçéĥöļđéŕ ţéẋţ
groupgroup=validationÞàŕàḿéţéŕ ĝŕöüþ ÎĐ

Ĝö ţýþé ţö ĴŠÖÑ Šçĥéḿà ţýþé ḿàþþîñĝ

Ĝö ţýþéĴŠÖÑ Šçĥéḿà ţýþé
boolboolean
stringstring
int, int64, uint, éţç.integer
float32, float64number
[]Tarray
map, structobject

Îñţéŕƒàçé, ƒüñçţîöñ, àñđ çĥàññéļ ƒîéļđš àŕé àüţöḿàţîçàļļý šķîþþéđ.

Ĥöŵ šçĥéḿà.ƑŕöḿŠţŕüçţ() ŵöŕķš

Ţĥé schema.FromStruct() ƒüñçţîöñ üšéš Ĝö ŕéƒļéçţîöñ ţö îñšþéçţ à çöñƒîĝ šţŕüçţ àñđ þŕöđüçé à ComponentSchema:

import "github.com/neokapi/neokapi/core/schema"

s := schema.FromStruct(&MyToolConfig{}, schema.ToolMeta{
ID: "my-tool",
Category: "transform",
DisplayName: "My Tool",
})

Ţĥé ƒüñçţîöñ:

  1. Îţéŕàţéš öṽéŕ éẋþöŕţéđ šţŕüçţ ƒîéļđš
  2. Ḿàþš Ĝö ţýþéš ţö ĴŠÖÑ Šçĥéḿà ţýþéš
  3. Þàŕšéš schema šţŕüçţ ţàĝš ƒöŕ ḿéţàđàţà (đéšçŕîþţîöñ, đéƒàüļţ, éñüḿ, ŵîđĝéţ, éţç.)
  4. Éẋţŕàçţš group ţàĝš ţö ƃüîļđ ui:groups ƒöŕ ţĥé ÜÎ
  5. Üšéš json šţŕüçţ ţàĝš ƒöŕ ƒîéļđ ñàḿéš (ƒàļļš ƃàçķ ţö çàḿéļÇàšé çöñṽéŕšîöñ)
  6. Ĝéñéŕàţéš à ComponentSchema ŵîţĥ toolMeta ḿéţàđàţà

Þàŕàḿéţéŕ ĝŕöüþš

Ƒîéļđš ŵîţĥ à group ţàĝ àŕé öŕĝàñîžéđ îñţö çöļļàþšîƃļé šéçţîöñš îñ ţĥé ÜÎ:

type CheckConfig struct {
CheckLeadingWS bool `schema:"description=Check leading whitespace,default=true,group=whitespace"`
CheckTrailingWS bool `schema:"description=Check trailing whitespace,default=true,group=whitespace"`
CheckEmptyTarget bool `schema:"description=Check empty translations,default=true,group=content"`
}

Ţĥîš þŕöđüçéš ţŵö çöļļàþšîƃļé ĝŕöüþš ("Ŵĥîţéšþàçé" àñđ "Çöñţéñţ") îñ ţĥé ĝéñéŕàţéđ ƒöŕḿ.

Ŕéĝîšţéŕîñĝ ŵîţĥ ŔéĝîšţéŕŴîţĥŠçĥéḿà()

Üšé RegisterWithSchema() îñšţéàđ öƒ Register() ţö îñçļüđé ţĥé šçĥéḿà îñ ţĥé ŕéĝîšţŕý, àñđ þàîŕ îţ ŵîţĥ SetConfigFactory() šö à ÝÀḾĻ šţéþ'š config: ḿàþ ŕéàçĥéš ţĥé ţööļ. Ţĥé þļàîñ ƒàçţöŕý ƃüîļđš à ţööļ ŵîţĥ đéƒàüļţ çöñƒîĝ; ţĥé çöñƒîĝ ƒàçţöŕý ŕéçéîṽéš ţĥé šţéþ'š ḿàþ àñđ ţĥé ŕüñ'š ţàŕĝéţ ļàñĝüàĝé, đéçöđéš ţĥé ḿàþ îñţö ţĥé çöñƒîĝ šţŕüçţ, àñđ ƃüîļđš ţĥé ţööļ ƒŕöḿ ţĥàţ. Ŵîţĥöüţ îţ, à šţéþ'š config: îš đîšçàŕđéđ (ţĥé ŕéĝîšţŕý öñļý ķñöŵš ĥöŵ ţö çàļļ ţĥé þļàîñ ƒàçţöŕý), ŵĥîçĥ îš ţĥé ŕéĝîšţŕàţîöñ îñṽàŕîàñţ ţĥé ƒŕàḿéŵöŕķ'š öŵñ ţööļš àŕé ţéšţéđ àĝàîñšţ:

import (
"bytes"
"encoding/json"

"github.com/neokapi/neokapi/core/registry"
"github.com/neokapi/neokapi/core/schema"
"github.com/neokapi/neokapi/core/tool"
)

func RegisterAll(reg *registry.ToolRegistry) {
reg.RegisterWithSchema("my-tool", func() tool.Tool {
return NewMyTool(&MyToolConfig{})
}, toolSchema(&MyToolConfig{}, "my-tool", "My Tool", "transform"))

// Called for a YAML step (or `kapi exec my-tool --flag ...`): the map holds
// the step's config keys, targetLang the run's --target-lang.
reg.SetConfigFactory("my-tool", func(config map[string]any, targetLang string) (tool.Tool, error) {
cfg := &MyToolConfig{TargetLocale: targetLang}
if err := decodeConfig(config, cfg); err != nil {
return nil, err
}
return NewMyTool(cfg), nil
})
}

// decodeConfig maps config keys onto the struct by its json tags and rejects
// keys the struct does not declare.
func decodeConfig(config map[string]any, into any) error {
raw, err := json.Marshal(config)
if err != nil {
return err
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
return dec.Decode(into)
}

// Helper to reduce boilerplate
func toolSchema(cfg any, id, displayName, category string) *schema.ComponentSchema {
return schema.FromStruct(cfg, schema.ToolMeta{
ID: id,
Category: category,
DisplayName: displayName,
})
}

Öñçé ŕéĝîšţéŕéđ ŵîţĥ à šçĥéḿà:

  • kapi tools šĥöŵš ţĥé ţööļ ŵîţĥ îţš đéšçŕîþţîöñ àñđ çàţéĝöŕý
  • Ţĥé ŵéƃ ÜÎ ŕéñđéŕš à đýñàḿîç çöñƒîĝüŕàţîöñ ƒöŕḿ (ṽîà FilterConfigEditor / SchemaConfigEditor)
  • Ţĥé ÇĻÎ çàñ ṽàļîđàţé ţööļ çöñƒîĝ ƃéƒöŕé éẋéçüţîöñ
  • reg.Schema("my-tool") ŕéţüŕñš ţĥé šçĥéḿà ƒöŕ þŕöĝŕàḿḿàţîç àççéšš

Ƒüļļ éẋàḿþļé: çŕéàţîñĝ à çüšţöḿ ţööļ

Ĥéŕé îš à çöḿþļéţé éẋàḿþļé öƒ à þŕéƒîẋ/šüƒƒîẋ ŵŕàþþîñĝ ţööļ ŵîţĥ à þàŕàḿéţéŕ šçĥéḿà:

package wraptext

import (
"bytes"
"encoding/json"
"fmt"

"github.com/neokapi/neokapi/core/model"
"github.com/neokapi/neokapi/core/registry"
"github.com/neokapi/neokapi/core/schema"
"github.com/neokapi/neokapi/core/tool"
)

// Config
type WrapTextConfig struct {
Prefix string `json:"prefix" schema:"description=Text prepended to each block,default=["`
Suffix string `json:"suffix" schema:"description=Text appended to each block,default=]"`
TargetLocale string `json:"targetLocale" schema:"description=Target locale,placeholder=en"`
SourceOnly bool `json:"sourceOnly" schema:"description=Wrap source text only,default=false"`
}

func (c *WrapTextConfig) ToolName() string { return "wrap-text" }
func (c *WrapTextConfig) Reset() { c.Prefix = "["; c.Suffix = "]" }

// Tool
func NewWrapTextTool(cfg *WrapTextConfig) *tool.BaseTool {
t := &tool.BaseTool{
ToolName: "wrap-text",
ToolDescription: "Wraps block text with prefix and suffix",
Cfg: cfg,
}
// It can rewrite the source (SourceOnly), so it sets Transform. A
// transformer is a read-only edit producer: it returns an EditPlan and the
// framework applier performs the rewrite, applying the edits and rebasing
// surviving run-anchored overlays. The structured Edits (here two pure
// insertions) are what lets the applier rebase rather than drop overlays.
t.Transform = func(v tool.BlockView) (tool.EditPlan, error) {
conf := t.Cfg.(*WrapTextConfig)
text := v.SourceText()
wrapped := fmt.Sprintf("%s%s%s", conf.Prefix, text, conf.Suffix)
var plan tool.EditPlan
if conf.SourceOnly {
n := len([]rune(text))
plan.NewRuns = []model.Run{{Text: &model.TextRun{Text: wrapped}}}
plan.Edits = []model.RunEdit{
{Start: 0, End: 0, NewLen: len([]rune(conf.Prefix))}, // insert prefix
{Start: n, End: n, NewLen: len([]rune(conf.Suffix))}, // append suffix
}
} else {
plan.SetTarget(model.LocaleID(conf.TargetLocale),
[]model.Run{{Text: &model.TextRun{Text: wrapped}}})
}
return plan, nil
}
return t
}

// Registration
func Register(reg *registry.ToolRegistry) {
s := schema.FromStruct(&WrapTextConfig{}, schema.ToolMeta{
ID: "wrap-text",
Category: "transform",
DisplayName: "Wrap Text",
})
reg.RegisterWithSchema("wrap-text", func() tool.Tool {
return NewWrapTextTool(&WrapTextConfig{Prefix: "[", Suffix: "]"})
}, s)
// Without this, the `config:` block of a YAML step never reaches the tool.
reg.SetConfigFactory("wrap-text", func(config map[string]any, targetLang string) (tool.Tool, error) {
cfg := &WrapTextConfig{TargetLocale: targetLang}
cfg.Reset()
raw, err := json.Marshal(config)
if err != nil {
return nil, err
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(cfg); err != nil {
return nil, fmt.Errorf("wrap-text: %w", err)
}
return NewWrapTextTool(cfg), nil
})
}

Üšé ţĥé ţööļ ƒŕöḿ ţĥé ÇĻÎ:

kapi exec wrap-text input.json --target-lang fr --prefix ">> " --suffix " <<"

Öŕ îñ à ÝÀḾĻ ƒļöŵ:

steps:
- tool: wrap-text
config:
prefix: ">> "
suffix: " <<"

Ţĥé ŕüñ'š --target-lang ŕéàçĥéš ţĥé ţööļ ţĥŕöüĝĥ ţĥé çöñƒîĝ ƒàçţöŕý'š targetLang àŕĝüḿéñţ, šö ţĥé šţéþ đéçļàŕéš ñö ļöçàļé öƒ îţš öŵñ.