Multi-lingual Support Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a unified i18n infrastructure covering both CLI output strings and content pack files, with German (de) as the pilot language.
Architecture: A new internal/i18n package embeds JSON catalogs and provides Resolve/T/Lookup/Tf functions. config.Config gains a Language field. LoadPack/LoadPacks gain a lang parameter for locale-suffixed file selection. rootCmd.PersistentPreRunE resolves the active language once and calls localizeCommands to patch cobra metadata.
Tech Stack: Go stdlib only — encoding/json, text/template, embed, os, strings; cobra for command walk; github.com/stretchr/testify (already used in tests).
Windows note:
go testalways fails locally due to Windows Defender. Usego build ./... && go vet ./...for local verification. Tests are verified in CI (ubuntu-latest). Test steps include both commands — run whichever applies to your environment.
File Map
Create:
internal/i18n/i18n.go— Resolve, T, Lookup, Tf, ActiveLang, catalog loaderinternal/i18n/i18n_test.go— unit tests for all i18n functionsinternal/i18n/catalogs/en.json— authoritative English catalog (all keys)internal/i18n/catalogs/de.json— German pilot translationscontent/packs/cap/context.de.md— German CAP contextcontent/packs/cap/tips.de.md— German CAP tips
Modify:
internal/config/config.go— addLanguage stringfieldinternal/config/config_test.go— round-trip and omitempty testscmd/config.go— addlanguagecase toconfig set; addlanguageline toconfig showinternal/content/pack.go— addpackMetaLocalestruct; addlang stringparam toLoadPackinternal/content/pack_test.go— add locale file and metadata locale testsinternal/content/loader.go— addlang stringparam toLoadPacks; pass toLoadPackinternal/content/loader_test.go— update anyLoadPack/LoadPackscallscmd/root.go— add i18n init toPersistentPreRunE; addlocalizeCommands+buildLocalizeKeycmd/inject.go— updateLoadPackscall; replace output strings withi18n.T/Tfcmd/sync.go— updateLoadPackscall; replace output stringscmd/tip.go— updateLoadPackscall; replace no-tips messagecmd/doctor.go— updateLoadPackscalls (×3); replace output stringscmd/mcp.go— updateLoadPackscalls (×5); no output string changescmd/resources.go— updateLoadPackscalls (×3); no output string changescontent/packs/cap/pack.yaml— addlocales.deblock
Task 1: internal/i18n package
Files:
Create:
internal/i18n/i18n.goCreate:
internal/i18n/i18n_test.goCreate:
internal/i18n/catalogs/en.json(minimal — expanded in Task 6)Create:
internal/i18n/catalogs/de.json(minimal — expanded in Task 7)[ ] Step 1.1: Create minimal catalogs (required before package compiles)
Create internal/i18n/catalogs/en.json:
{
"root.short": "AI-first SAP developer toolkit",
"root.long": "sap-devs injects up-to-date SAP developer knowledge into your AI tools.",
"inject.short": "Push SAP context to your AI tools",
"inject.done": "SAP developer context injected ({{.Scope}} scope)."
}Create internal/i18n/catalogs/de.json:
{
"root.short": "KI-gestütztes SAP Entwickler-Toolkit",
"inject.short": "SAP-Kontext in deine KI-Tools einbinden"
}- [ ] Step 1.2: Write the failing tests
Create internal/i18n/i18n_test.go:
package i18n
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolve(t *testing.T) {
tests := []struct {
name string
cfgLang string
lang string
lcAll string
want string
}{
{"config wins over env", "de", "fr", "", "de"},
{"config with unknown lang falls back to en", "klingon", "", "", "en"},
{"LANG used when config empty", "", "de_AT.UTF-8", "", "de"},
{"LC_ALL used when LANG empty", "", "", "de", "de"},
{"LANG wins over LC_ALL", "", "de", "fr", "de"},
{"LANG empty string treated as unset", "", "", "de", "de"},
{"unknown LANG falls back to en", "", "fr_FR.UTF-8", "", "en"},
{"no input returns en", "", "", "", "en"},
{"config de_AT strips to de", "de_AT.UTF-8", "", "", "de"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LANG", tt.lang)
t.Setenv("LC_ALL", tt.lcAll)
assert.Equal(t, tt.want, Resolve(tt.cfgLang))
})
}
}
func TestT(t *testing.T) {
// Key exists in de catalog
assert.Equal(t, "KI-gestütztes SAP Entwickler-Toolkit", T("de", "root.short"))
// Key absent in de, falls back to en
assert.Equal(t, "SAP developer context injected ({{.Scope}} scope).", T("de", "inject.done"))
// Key absent from both catalogs — returns raw key
assert.Equal(t, "missing.key", T("de", "missing.key"))
}
func TestLookup(t *testing.T) {
// Found in de catalog
v, ok := Lookup("de", "root.short")
assert.True(t, ok)
assert.NotEmpty(t, v)
// Not in de, found in en fallback
v, ok = Lookup("de", "inject.done")
assert.True(t, ok)
assert.Contains(t, v, "{{.Scope}}")
// Not in either
_, ok = Lookup("de", "missing.key")
assert.False(t, ok)
}
func TestTf(t *testing.T) {
// Template substitution using en fallback
got := Tf("de", "inject.done", map[string]any{"Scope": "global"})
assert.Equal(t, "SAP developer context injected (global scope).", got)
// Missing data key with missingkey=error → raw template string
got = Tf("de", "inject.done", map[string]any{})
assert.Equal(t, "SAP developer context injected ({{.Scope}} scope).", got)
// nil data with template action → raw template string
got = Tf("de", "inject.done", nil)
assert.Equal(t, "SAP developer context injected ({{.Scope}} scope).", got)
}
func TestActiveLang(t *testing.T) {
// ActiveLang is a package-level var that starts empty
orig := ActiveLang
t.Cleanup(func() { ActiveLang = orig })
ActiveLang = "de"
assert.Equal(t, "de", ActiveLang)
}
// Ensure catalogs panic on bad JSON at init — tested via panic recovery in a subprocess.
// Skip: malformed catalog panics at init time (programmer error caught by CI build).
func init() {
// Override LANG/LC_ALL in case test environment has them set
os.Unsetenv("LANG")
os.Unsetenv("LC_ALL")
}- [ ] Step 1.3: Run to verify tests fail
go test ./internal/i18n/...
# Expected: FAIL — package does not exist yetLocal alternative: go build ./internal/i18n/... 2>&1 — expected: package not found.
- [ ] Step 1.4: Implement
internal/i18n/i18n.go
package i18n
import (
"bytes"
"embed"
"encoding/json"
"os"
"strings"
"text/template"
)
//go:embed catalogs/*.json
var catalogFS embed.FS
// ActiveLang is the resolved active language tag.
// Set once in rootCmd.PersistentPreRunE before any command body runs.
var ActiveLang string
// catalogs holds all loaded catalogs: map[lang]map[key]value.
var catalogs map[string]map[string]string
func init() {
catalogs = make(map[string]map[string]string)
entries, err := catalogFS.ReadDir("catalogs")
if err != nil {
panic("i18n: failed to read catalogs directory: " + err.Error())
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
lang := strings.TrimSuffix(e.Name(), ".json")
data, err := catalogFS.ReadFile("catalogs/" + e.Name())
if err != nil {
panic("i18n: failed to read catalog " + e.Name() + ": " + err.Error())
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
panic("i18n: malformed catalog " + e.Name() + ": " + err.Error())
}
catalogs[lang] = m
}
}
// Resolve returns the active language tag.
// Priority: cfgLang (if non-empty, used exclusively) → LANG env var →
// LC_ALL env var → "en". Empty env var values are treated as unset.
// All inputs are stripped of region/encoding suffixes: "de_AT.UTF-8" → "de".
// Unknown tags (no catalog) resolve to "en".
func Resolve(cfgLang string) string {
if cfgLang != "" {
return resolveTag(cfgLang)
}
for _, env := range []string{"LANG", "LC_ALL"} {
if v := os.Getenv(env); v != "" {
return resolveTag(v)
}
}
return "en"
}
func resolveTag(s string) string {
tag := stripTag(s)
if _, ok := catalogs[tag]; ok {
return tag
}
return "en"
}
// stripTag removes region and encoding suffixes: "de_AT.UTF-8" → "de".
func stripTag(s string) string {
s = strings.ToLower(s)
if i := strings.IndexAny(s, "_."); i > 0 {
s = s[:i]
}
return s
}
// Lookup returns the translation for key in lang (falling back to en).
// Returns the string and true if found in either catalog; "", false if absent from both.
func Lookup(lang, key string) (string, bool) {
if lang != "" && lang != "en" {
if cat, ok := catalogs[lang]; ok {
if v, ok := cat[key]; ok {
return v, true
}
}
}
if cat, ok := catalogs["en"]; ok {
if v, ok := cat[key]; ok {
return v, true
}
}
return "", false
}
// T returns the translation for key, falling back to the raw key string if not found.
func T(lang, key string) string {
if v, ok := Lookup(lang, key); ok {
return v
}
return key
}
// Tf resolves key via T and executes it as a text/template with data.
// Uses missingkey=error: missing keys in data trigger a failure.
// On any parse or execute failure, returns the raw (un-executed) catalog string.
// If data is nil, template actions will fail (missingkey=error), returning the raw string.
func Tf(lang, key string, data map[string]any) string {
raw := T(lang, key)
tmpl, err := template.New("").Option("missingkey=error").Parse(raw)
if err != nil {
return raw
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return raw
}
return buf.String()
}- [ ] Step 1.5: Run tests and verify they pass
go test ./internal/i18n/...
# Expected: PASSLocal: go build ./internal/i18n/... && go vet ./internal/i18n/...
- [ ] Step 1.6: Commit
git add internal/i18n/
git commit -m "feat: add internal/i18n package with Resolve, T, Lookup, Tf"Task 2: Add Language field to config.Config
Files:
Modify:
internal/config/config.goModify:
internal/config/config_test.goModify:
cmd/config.go[ ] Step 2.1: Write the failing tests
Add to internal/config/config_test.go:
func TestConfigLanguageRoundTrip(t *testing.T) {
dir := t.TempDir()
cfg := Default()
cfg.Language = "de"
require.NoError(t, cfg.Save(dir))
loaded, err := Load(dir)
require.NoError(t, err)
assert.Equal(t, "de", loaded.Language)
}
func TestConfigLanguageOmitempty(t *testing.T) {
dir := t.TempDir()
cfg := Default() // Language is ""
require.NoError(t, cfg.Save(dir))
data, err := os.ReadFile(filepath.Join(dir, "config.yaml"))
require.NoError(t, err)
assert.NotContains(t, string(data), "language",
"empty Language should not appear in YAML output")
}Ensure the test file imports "os" and "path/filepath" if not already present.
- [ ] Step 2.2: Run to verify tests fail
go test ./internal/config/...
# Expected: FAIL — Language field does not exist yetLocal: go build ./internal/config/... && go vet ./internal/config/...
- [ ] Step 2.3: Add
Languagefield toconfig.Config
In internal/config/config.go, add the Language field after CompanyRepo:
type Config struct {
CompanyRepo string `yaml:"company_repo,omitempty"`
Language string `yaml:"language,omitempty"` // e.g. "de"; empty = auto-detect from locale
Sync SyncConfig `yaml:"sync"`
}No change to Default() — Language defaults to "" (auto-detect).
- [ ] Step 2.4: Add
languagecase toconfig setand line toconfig show
In cmd/config.go, in the configSetCmd.RunE switch statement, add:
case "language":
cfg.Language = args[1]In configShowCmd.RunE, after the company_repo line, add:
fmt.Printf("language: %s\n", cfg.Language)- [ ] Step 2.5: Run tests and verify they pass
go test ./internal/config/...
# Expected: PASSLocal: go build ./... && go vet ./...
- [ ] Step 2.6: Commit
git add internal/config/config.go internal/config/config_test.go cmd/config.go
git commit -m "feat: add Language field to config.Config with config set/show support"Task 3: Add lang param to LoadPack
Files:
Modify:
internal/content/pack.goModify:
internal/content/pack_test.go[ ] Step 3.1: Write the failing tests
Add to internal/content/pack_test.go:
func TestLoadPack_LocaleContextFile(t *testing.T) {
dir := t.TempDir()
yaml := "id: test\nname: Test Pack\ndescription: Test\ntags: []\nprofiles: []\nweight: 0\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "pack.yaml"), []byte(yaml), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "context.md"), []byte("English context"), 0644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "context.de.md"), []byte("German context"), 0644))
// German: locale file used
p, err := content.LoadPack(dir, "de")
require.NoError(t, err)
assert.Equal(t, "German context", p.ContextMD)
// French: no locale file, falls back to base
p, err = content.LoadPack(dir, "fr")
require.NoError(t, err)
assert.Equal(t, "English context", p.ContextMD)
// Empty lang: base file used
p, err = content.LoadPack(dir, "")
require.NoError(t, err)
assert.Equal(t, "English context", p.ContextMD)
// lang="en": base file used (no context.en.md attempted)
p, err = content.LoadPack(dir, "en")
require.NoError(t, err)
assert.Equal(t, "English context", p.ContextMD)
}
func TestLoadPack_LocaleMetadata(t *testing.T) {
dir := t.TempDir()
yaml := `id: test
name: Test Pack
description: A test pack
tags: []
profiles: []
weight: 0
locales:
de:
name: Testpaket
description: Ein Testpaket
`
require.NoError(t, os.WriteFile(filepath.Join(dir, "pack.yaml"), []byte(yaml), 0644))
// German locale
p, err := content.LoadPack(dir, "de")
require.NoError(t, err)
assert.Equal(t, "Testpaket", p.Name)
assert.Equal(t, "Ein Testpaket", p.Description)
// English (no locale block)
p, err = content.LoadPack(dir, "en")
require.NoError(t, err)
assert.Equal(t, "Test Pack", p.Name)
assert.Equal(t, "A test pack", p.Description)
}
func TestLoadPack_MalformedLocales(t *testing.T) {
dir := t.TempDir()
// locales value is a string instead of a map — invalid YAML for the field
yaml := "id: test\nname: Test\ndescription: Test\ntags: []\nprofiles: []\nweight: 0\nlocales: not-a-map\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "pack.yaml"), []byte(yaml), 0644))
_, err := content.LoadPack(dir, "de")
assert.Error(t, err, "malformed locales block should return an error")
}Update the existing TestLoadPack_ParsesAllFiles and TestLoadPack_MissingOptionalFilesOK calls to pass an empty lang argument — they will fail to compile before the signature change.
- [ ] Step 3.2: Verify tests fail
go build ./internal/content/...
# Expected: FAIL — LoadPack signature mismatch- [ ] Step 3.3: Update
pack.go
In internal/content/pack.go:
- Add
packMetaLocalestruct andLocalesfield topackMeta:
type packMetaLocale struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
type packMeta struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
Profiles []string `yaml:"profiles"`
Weight int `yaml:"weight"`
Locales map[string]packMetaLocale `yaml:"locales,omitempty"`
}Change
LoadPack(packDir string)toLoadPack(packDir string, lang string).After populating
pack.Nameandpack.Descriptionfrommeta, add locale override:
if lang != "" && lang != "en" {
if loc, ok := meta.Locales[lang]; ok {
if loc.Name != "" {
pack.Name = loc.Name
}
if loc.Description != "" {
pack.Description = loc.Description
}
}
}- Replace the
context.mdread block:
// was: if data, err := os.ReadFile(filepath.Join(packDir, "context.md")); err == nil {
contextFile := filepath.Join(packDir, "context.md")
if lang != "" && lang != "en" {
if loc := filepath.Join(packDir, "context."+lang+".md"); fileExists(loc) {
contextFile = loc
}
}
if data, err := os.ReadFile(contextFile); err == nil {
pack.ContextMD = string(data)
}- Replace the
tips.mdread block similarly:
tipsFile := filepath.Join(packDir, "tips.md")
if lang != "" && lang != "en" {
if loc := filepath.Join(packDir, "tips."+lang+".md"); fileExists(loc) {
tipsFile = loc
}
}
if data, err := os.ReadFile(tipsFile); err == nil {
pack.Tips = parseTips(string(data))
}- Add the
fileExistshelper at the bottom ofpack.go:
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}- [ ] Step 3.4: Fix existing tests and the internal
LoadPackcall inloader.go
In internal/content/pack_test.go, update the two existing test calls:
// TestLoadPack_ParsesAllFiles
pack, err := content.LoadPack(dir, "")
// TestLoadPack_MissingOptionalFilesOK
pack, err := content.LoadPack(dir, "")Also update the LoadPack call inside internal/content/loader.go (line ~35) to compile now — pass "" as a placeholder lang; the correct lang parameter is threaded in Task 4:
pack, err := LoadPack(filepath.Join(packsDir, e.Name()), "")- [ ] Step 3.5: Verify the full module compiles and tests pass
go build ./...
go vet ./...
go test ./internal/content/...
# Expected: all PASSLocal (Windows): go build ./... && go vet ./...
- [ ] Step 3.6: Commit
git add internal/content/pack.go internal/content/pack_test.go internal/content/loader.go
git commit -m "feat: add lang param to LoadPack with locale file and metadata support"Task 4: Add lang param to LoadPacks and update all 13 call sites
Files:
- Modify:
internal/content/loader.go - Modify:
internal/content/loader_test.go - Modify:
cmd/inject.go,cmd/tip.go,cmd/doctor.go(×3),cmd/mcp.go(×5),cmd/resources.go(×3)
Before making changes, verify the full list of call sites:
bashgrep -rn "\.LoadPacks(" . --include="*.go"Expected results match the 13 calls listed in the file map above.
- [ ] Step 4.1: Update
loader.go
Change LoadPacks(profile *Profile) to LoadPacks(profile *Profile, lang string) in internal/content/loader.go:
func (cl *ContentLoader) LoadPacks(profile *Profile, lang string) ([]*Pack, error) {
// ... existing packMap logic unchanged ...
// Change the LoadPack call:
pack, err := LoadPack(filepath.Join(packsDir, e.Name()), lang)
// ... rest unchanged ...
}- [ ] Step 4.2: Update
loader_test.go
Find all LoadPacks( calls in internal/content/loader_test.go and add "" as the lang argument (LoadPack calls are only in pack_test.go, already addressed in Task 3):
grep -n "LoadPacks(" internal/content/loader_test.goUpdate each call: LoadPacks(profile) → LoadPacks(profile, "").
- [ ] Step 4.3: Update all
cmd/call sites
For each file, change every loader.LoadPacks(...) call to pass i18n.ActiveLang as the second argument:
cmd/inject.go (1 call):
packs, err := loader.LoadPacks(activeProfile, i18n.ActiveLang)Add import: "github.com/SAP-samples/sap-devs-cli/internal/i18n"
cmd/tip.go (1 call):
packs, err := loader.LoadPacks(activeProfile, i18n.ActiveLang)Add import: "github.com/SAP-samples/sap-devs-cli/internal/i18n"
cmd/doctor.go (3 calls — all three loader.LoadPacks(...) calls):
packs, err = loader.LoadPacks(nil, i18n.ActiveLang) // line ~33
packs, err = loader.LoadPacks(active, i18n.ActiveLang) // line ~56
packs, err = loader.LoadPacks(p, i18n.ActiveLang) // line ~68Add import: "github.com/SAP-samples/sap-devs-cli/internal/i18n"
cmd/mcp.go (5 calls) — add i18n.ActiveLang as second arg to all 5 loader.LoadPacks(...) calls. Add import.
cmd/resources.go (3 calls) — add i18n.ActiveLang as second arg to all 3 calls. Add import.
- [ ] Step 4.4: Verify everything compiles
go build ./...
# Expected: SUCCESS — no compile errors
go vet ./...
# Expected: SUCCESS- [ ] Step 4.5: Run tests
go test ./internal/content/...
# Expected: PASS- [ ] Step 4.6: Commit
git add internal/content/loader.go internal/content/loader_test.go \
cmd/inject.go cmd/tip.go cmd/doctor.go cmd/mcp.go cmd/resources.go
git commit -m "feat: add lang param to LoadPacks and update all call sites"Task 5: Language init and localizeCommands in cmd/root.go
Files:
Modify:
cmd/root.go[ ] Step 5.1: Add i18n init and
localizeCommandstocmd/root.go
Add the import
"github.com/SAP-samples/sap-devs-cli/internal/i18n"to the import block.In
PersistentPreRunE, add language resolution at the top of the function. The i18n init runs for all commands (includingupdate) so all commands see the resolved language. Replace the entire function opening up to the update check guard:
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
updateHintCh = nil // reset before every invocation
// Resolve active language before any command body runs.
// Runs for all commands so that Short/Long are always localized.
if paths, err := xdg.New(); err == nil {
if cfg, err := config.Load(paths.ConfigDir); err == nil {
i18n.ActiveLang = i18n.Resolve(cfg.Language)
}
}
if i18n.ActiveLang == "" {
i18n.ActiveLang = "en"
}
localizeCommands(rootCmd, i18n.ActiveLang)
// Skip background update check for "update" command and dev builds.
if cmd.Name() == "update" || Version == "dev" {
return nil
}
// ... rest of the existing update check code unchanged ...- Add
localizeCommandsandbuildLocalizeKeyat the bottom ofcmd/root.go:
// localizeCommands walks root and all its descendants and updates Short and Long
// from the i18n catalog. Uses i18n.Lookup so cobra-registered strings are never
// overwritten with bare key names when a key is absent from both catalogs.
// Key path segments are derived from cmd.Name() (cobra's first word of Use).
// The root command uses the hardcoded prefix "root".
func localizeCommands(root *cobra.Command, lang string) {
var walk func(cmd *cobra.Command)
walk = func(cmd *cobra.Command) {
prefix := buildLocalizeKey(cmd)
if s, ok := i18n.Lookup(lang, prefix+".short"); ok {
cmd.Short = s
}
if s, ok := i18n.Lookup(lang, prefix+".long"); ok {
cmd.Long = s
}
for _, sub := range cmd.Commands() {
walk(sub)
}
}
walk(root)
}
// buildLocalizeKey returns the dot-separated i18n key prefix for cmd.
// The root command (!cmd.HasParent()) returns "root".
// Other commands build the path by walking up the parent chain
// (excluding the root command itself).
func buildLocalizeKey(cmd *cobra.Command) string {
if !cmd.HasParent() {
return "root"
}
var parts []string
for c := cmd; c.HasParent(); c = c.Parent() {
parts = append([]string{c.Name()}, parts...)
}
return strings.Join(parts, ".")
}Add "strings" to the import block if not already present.
- [ ] Step 5.2: Verify it compiles
go build ./...
go vet ./...
# Expected: SUCCESS- [ ] Step 5.3: Smoke test — default language (English) unchanged
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: English output, no errors- [ ] Step 5.4: Commit
git add cmd/root.go
git commit -m "feat: resolve active language and localize cobra commands in PersistentPreRunE"Task 6: Populate en.json and wire pilot command output strings
Files:
Modify:
internal/i18n/catalogs/en.json(expand to full key set)Modify:
cmd/inject.go,cmd/sync.go,cmd/tip.go,cmd/doctor.go[ ] Step 6.1: Replace
en.jsonwith the complete key set
Overwrite internal/i18n/catalogs/en.json:
{
"root.short": "AI-first SAP developer toolkit",
"root.long": "sap-devs injects up-to-date SAP developer knowledge into your AI tools.",
"inject.short": "Push SAP context to your AI tools",
"inject.long": "Inject up-to-date SAP developer context into all detected AI tools.\n\nInjects at global (user) scope by default into tools such as Claude Code,\nCursor, and GitHub Copilot. Use --project to opt into project scope and inject\ninto project-level files (CLAUDE.md, .cursorrules, etc.) in the current directory.",
"inject.dry_run": "[dry-run] no files will be modified",
"inject.done": "SAP developer context injected ({{.Scope}} scope).",
"inject.hint": "Run 'sap-devs inject --dry-run' to preview changes before writing.",
"sync.short": "Pull latest SAP developer content",
"sync.long": "Syncs content from the official repo (and company repo if configured). Respects per-category TTLs unless --force is set.",
"sync.disabled": "Sync is disabled in config.",
"sync.up_to_date": "All content is up to date.",
"sync.syncing": "Syncing SAP developer content...",
"sync.updated": "Updated: {{.Categories}}",
"sync.warn_https": "Warning: company_repo must be an HTTPS URL (got: {{.URL}}) — skipping sync.",
"sync.syncing_company": "Syncing company repo...",
"sync.warn_company_failed": "Warning: company repo sync failed: {{.Err}}",
"tip.short": "Print a SAP developer tip (add to your shell profile)",
"tip.no_tips": "No tips available. Run 'sap-devs sync' to download content.",
"doctor.short": "Check local tool versions against pack requirements",
"doctor.no_tools": "No tools defined for the current selection.",
"doctor.col_tool": "TOOL",
"doctor.col_required": "REQUIRED",
"doctor.col_found": "FOUND",
"doctor.col_status": "STATUS",
"doctor.install_header": "\nInstall commands:",
"doctor.status_ok": "ok",
"doctor.status_ok_unverified": "ok (unverified)",
"doctor.status_fail": "FAIL",
"doctor.status_missing": "MISSING",
"profile.short": "Manage your developer profile",
"profile.list.short": "List available developer profiles",
"profile.set.short": "Set your active developer profile",
"profile.show.short": "Show your current profile and pack weights",
"config.short": "Manage sap-devs configuration",
"config.show.short": "Display current configuration",
"config.set.short": "Set a configuration value",
"config.company.short": "Configure the company content repo URL",
"mcp.short": "Manage SAP MCP servers",
"mcp.list.short": "List available SAP MCP servers",
"mcp.status.short": "Show which SAP MCP servers are registered in your AI tool configs",
"mcp.install.short": "Install and wire an SAP MCP server into your AI tools",
"resources.short": "Browse curated SAP resources",
"resources.list.short": "List curated resources for your active profile",
"resources.search.short": "Search across all SAP resources",
"resources.open.short": "Open a resource URL in the default browser",
"version.short": "Print the sap-devs version",
"update.short": "Update sap-devs to the latest release",
"update.long": "Check for a newer release on GitHub and install it if found.",
"init.short": "First-time setup wizard"
}- [ ] Step 6.2: Wire
cmd/inject.gooutput strings
Replace the three fmt output calls in inject.go:
// Replace: fmt.Println("[dry-run] no files will be modified")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "inject.dry_run"))
// Replace: fmt.Printf("SAP developer context injected (%s scope).\n", scope)
fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(i18n.ActiveLang, "inject.done", map[string]any{"Scope": scope}))
// Replace: fmt.Println("Run 'sap-devs inject --dry-run' to preview changes before writing.")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "inject.hint"))The i18n import was added in Task 4.
- [ ] Step 6.3: Wire
cmd/sync.gooutput strings
Add import "github.com/SAP-samples/sap-devs-cli/internal/i18n".
Replace all fmt.Println/fmt.Printf output calls:
// fmt.Println("Sync is disabled in config.")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "sync.disabled"))
// fmt.Println("All content is up to date.")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "sync.up_to_date"))
// fmt.Println("Syncing SAP developer content...")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "sync.syncing"))
// fmt.Printf("Updated: %v\n", categories)
fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(i18n.ActiveLang, "sync.updated", map[string]any{"Categories": categories}))
// fmt.Printf("Warning: company_repo must be an HTTPS URL (got: %s) — skipping sync.\n", cfg.CompanyRepo)
fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(i18n.ActiveLang, "sync.warn_https", map[string]any{"URL": cfg.CompanyRepo}))
// fmt.Println("Syncing company repo...")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "sync.syncing_company"))
// fmt.Printf("Warning: company repo sync failed: %v\n", err)
fmt.Fprintln(cmd.OutOrStdout(), i18n.Tf(i18n.ActiveLang, "sync.warn_company_failed", map[string]any{"Err": err}))Note: sync.go uses bare fmt.Println (not cmd.OutOrStdout()). The RunE function receives cmd *cobra.Command as first arg — use cmd.OutOrStdout() consistently. For the warning inside the goroutine-free callback, cmd is in scope.
- [ ] Step 6.4: Wire
cmd/tip.gooutput string
The i18n import was added in Task 4. Replace:
// fmt.Println("No tips available. Run 'sap-devs sync' to download content.")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "tip.no_tips"))- [ ] Step 6.5: Wire
cmd/doctor.gooutput strings
The i18n import was added in Task 4.
Replace in doctorCmd.RunE:
// fmt.Println("No tools defined for the current selection.")
fmt.Fprintln(cmd.OutOrStdout(), i18n.T(i18n.ActiveLang, "doctor.no_tools"))Replace in printDoctorTable (note: this function doesn't receive cmd; use fmt.Printf directly since table formatting is structural):
func printDoctorTable(results []content.ToolResult, lang string) {
fmt.Printf("%-20s %-12s %-12s %s\n",
i18n.T(lang, "doctor.col_tool"),
i18n.T(lang, "doctor.col_required"),
i18n.T(lang, "doctor.col_found"),
i18n.T(lang, "doctor.col_status"))
fmt.Println(strings.Repeat("-", 62))
for _, r := range results {
found := r.Found
if found == "" {
found = "-"
}
fmt.Printf("%-20s %-12s %-12s %s\n", r.Tool.ID, r.Tool.Required, found, statusLabel(r.Status, lang))
}
}Update the call site: printDoctorTable(results) → printDoctorTable(results, i18n.ActiveLang).
Update statusLabel to accept lang:
func statusLabel(s content.CheckStatus, lang string) string {
switch s {
case content.StatusOK:
return i18n.T(lang, "doctor.status_ok")
case content.StatusUnknown:
return i18n.T(lang, "doctor.status_ok_unverified")
case content.StatusFail:
return i18n.T(lang, "doctor.status_fail")
case content.StatusMissing:
return i18n.T(lang, "doctor.status_missing")
}
return string(s)
}Replace in printInstallCommands:
// fmt.Println("\nInstall commands:")
fmt.Println(i18n.T(i18n.ActiveLang, "doctor.install_header"))Update printInstallCommands and statusLabel call sites to pass i18n.ActiveLang where needed.
- [ ] Step 6.6: Verify everything compiles and runs in English
go build ./...
go vet ./...
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: English output unchanged
SAP_DEVS_DEV=1 go run . doctor
# Expected: English table headers, English status labels- [ ] Step 6.7: Commit
git add internal/i18n/catalogs/en.json cmd/inject.go cmd/sync.go cmd/tip.go cmd/doctor.go
git commit -m "feat: wire pilot command output through i18n, populate en.json"Task 7: German translations in de.json
Files:
Modify:
internal/i18n/catalogs/de.json[ ] Step 7.1: Replace
de.jsonwith German translations
Overwrite internal/i18n/catalogs/de.json:
{
"root.short": "KI-gestütztes SAP Entwickler-Toolkit",
"root.long": "sap-devs integriert aktuelles SAP-Entwicklerwissen in deine KI-Tools.",
"inject.short": "SAP-Kontext in deine KI-Tools einbinden",
"inject.long": "Aktuellen SAP-Entwicklerkontext in alle erkannten KI-Tools einbinden.\n\nStandardmäßig globaler (Benutzer-)Bereich für Tools wie Claude Code, Cursor und GitHub Copilot. Mit --project auf Projektbereich umschalten und in Projektdateien (CLAUDE.md, .cursorrules, etc.) im aktuellen Verzeichnis einbinden.",
"inject.dry_run": "[Testlauf] Es werden keine Dateien geändert",
"inject.done": "SAP-Entwicklerkontext eingefügt ({{.Scope}}-Bereich).",
"inject.hint": "Führe 'sap-devs inject --dry-run' aus, um Änderungen vorab anzuzeigen.",
"sync.short": "Aktuellen SAP-Entwicklerinhalt herunterladen",
"sync.long": "Synchronisiert Inhalte aus dem offiziellen Repo (und dem Unternehmens-Repo, falls konfiguriert). Berücksichtigt kategoriespezifische TTLs, außer --force ist gesetzt.",
"sync.disabled": "Synchronisierung ist in der Konfiguration deaktiviert.",
"sync.up_to_date": "Alle Inhalte sind aktuell.",
"sync.syncing": "SAP-Entwicklerinhalt wird synchronisiert...",
"sync.updated": "Aktualisiert: {{.Categories}}",
"sync.warn_https": "Warnung: company_repo muss eine HTTPS-URL sein (erhalten: {{.URL}}) — Synchronisierung übersprungen.",
"sync.syncing_company": "Unternehmens-Repo wird synchronisiert...",
"sync.warn_company_failed": "Warnung: Synchronisierung des Unternehmens-Repos fehlgeschlagen: {{.Err}}",
"tip.short": "Einen SAP-Entwicklertipp anzeigen (in dein Shell-Profil einfügen)",
"tip.no_tips": "Keine Tipps verfügbar. Führe 'sap-devs sync' aus, um Inhalte herunterzuladen.",
"doctor.short": "Lokale Tool-Versionen gegen Pack-Anforderungen prüfen",
"doctor.no_tools": "Keine Tools für die aktuelle Auswahl definiert.",
"doctor.col_tool": "TOOL",
"doctor.col_required": "ERFORDERLICH",
"doctor.col_found": "GEFUNDEN",
"doctor.col_status": "STATUS",
"doctor.install_header": "\nInstallationsbefehle:",
"doctor.status_ok": "ok",
"doctor.status_ok_unverified": "ok (nicht verifiziert)",
"doctor.status_fail": "FEHLER",
"doctor.status_missing": "FEHLT",
"profile.short": "Entwicklerprofil verwalten",
"profile.list.short": "Verfügbare Entwicklerprofile auflisten",
"profile.set.short": "Aktives Entwicklerprofil festlegen",
"profile.show.short": "Aktuelles Profil und Pack-Gewichtungen anzeigen",
"config.short": "sap-devs-Konfiguration verwalten",
"config.show.short": "Aktuelle Konfiguration anzeigen",
"config.set.short": "Konfigurationswert setzen",
"config.company.short": "URL des Unternehmens-Inhalts-Repos konfigurieren",
"mcp.short": "SAP MCP-Server verwalten",
"mcp.list.short": "Verfügbare SAP MCP-Server auflisten",
"mcp.status.short": "Registrierte SAP MCP-Server in KI-Tool-Konfigurationen anzeigen",
"mcp.install.short": "Einen SAP MCP-Server installieren und in KI-Tools einbinden",
"resources.short": "Kuratierte SAP-Ressourcen durchsuchen",
"resources.list.short": "Kuratierte Ressourcen für aktives Profil auflisten",
"resources.search.short": "Über alle SAP-Ressourcen suchen",
"resources.open.short": "Ressourcen-URL im Standardbrowser öffnen",
"version.short": "sap-devs-Version ausgeben",
"update.short": "sap-devs auf die neueste Version aktualisieren",
"update.long": "Sucht auf GitHub nach einer neueren Version und installiert diese, falls gefunden.",
"init.short": "Ersteinrichtungsassistent"
}- [ ] Step 7.2: Smoke test with German locale
Note:
--helpandhelpbypassPersistentPreRunE, so cobraShort/Longstrings are always displayed in English for help invocations. This is a known limitation documented in the spec. Smoke test using commands that complete normally instead.
SAP_DEVS_DEV=1 LANG=de go run . inject --dry-run
# Expected: "[Testlauf] Es werden keine Dateien geändert"
SAP_DEVS_DEV=1 LANG=de go run . sync
# Expected: German sync output (e.g. "Alle Inhalte sind aktuell." if content already synced)
SAP_DEVS_DEV=1 LANG=de go run . doctor
# Expected: German table headers (TOOL, ERFORDERLICH, GEFUNDEN, STATUS)- [ ] Step 7.3: Verify English still works
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: "[dry-run] no files will be modified"
SAP_DEVS_DEV=1 go run . doctor
# Expected: English table headers (TOOL, REQUIRED, FOUND, STATUS)- [ ] Step 7.4: Commit
git add internal/i18n/catalogs/de.json
git commit -m "feat: add German (de) pilot translations"Task 8: German CAP pack content
Files:
Create:
content/packs/cap/context.de.mdCreate:
content/packs/cap/tips.de.mdModify:
content/packs/cap/pack.yaml[ ] Step 8.1: Add
locales.deblock tocap/pack.yaml
Read the current content/packs/cap/pack.yaml first, then add locales at the end:
locales:
de:
name: SAP Cloud Application Programming Model
description: Node.js- und Java-Framework für Cloud-native Business-Anwendungen auf BTP(The name stays in English as it is an SAP product name; only the description is translated.)
- [ ] Step 8.2: Create
content/packs/cap/context.de.md
## SAP CAP (Cloud Application Programming Model)
CAP ist SAPs primäres Framework für Cloud-native Business-Anwendungen auf SAP BTP.
Es verwendet CDS (Core Data Services) für Daten- und Servicedefinitionen sowie Node.js oder Java für die Service-Logik.
### Wichtige Tools
- `@sap/cds-dk` — CAP Development Kit (CLI: `cds`)
- `cds watch` — lokaler Entwicklungsserver mit Live-Reload
- `cds deploy` — Deployment in Datenbank / Cloud
### CDS-Datenmodellierung
```cds
entity Books : managed {
key ID : Integer;
title : localized String(111);
author : Association to Authors;
}
```
### Service-Definition
```cds
service CatalogService @(path:'/browse') {
@readonly entity Books as SELECT from my.Books;
}
```
### Best Practices
- Entities in `db/schema.cds` definieren, Services in `srv/*.cds`
- `cds.ql` für typsichere CQL-Abfragen verwenden
- Eingebaute Authentifizierung via `@requires`-Annotationen nutzen- [ ] Step 8.3: Create
content/packs/cap/tips.de.md
## cds watch für lokale Entwicklung nutzen
Tags: cap,nodejs
`cds watch` statt `node server.js` ausführen — es lädt bei jeder Dateiänderung neu und protokolliert alle Anfragen.
## managed-Entities für Audit-Felder definieren
Tags: cap,cds
`: managed` zu Entities hinzufügen, um `createdAt`, `createdBy`, `modifiedAt`, `modifiedBy` automatisch zu erhalten.
## @readonly in der Service-Schicht verwenden
Tags: cap,odata,security
`@readonly` in der Service-Schicht statt auf DB-Ebene einschränken — hält das Schema flexibel.
## CAP-Versionskompatibilität prüfen
Tags: cap,versions
`cds version` ausführen, um alle CAP-Stack-Versionen anzuzeigen. Nicht übereinstimmende `@sap/cds`- und `@sap/cds-dk`-Versionen verursachen schwer zu findende Fehler.- [ ] Step 8.4: Verify German CAP content loads
SAP_DEVS_DEV=1 LANG=de go run . tip
# Expected: German tip text
SAP_DEVS_DEV=1 go run . tip
# Expected: English tip text (unchanged)- [ ] Step 8.5: Verify English CAP content is unaffected
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: no errors, English context- [ ] Step 8.6: Commit
git add content/packs/cap/context.de.md content/packs/cap/tips.de.md content/packs/cap/pack.yaml
git commit -m "feat: add German (de) CAP pack content and pack.yaml locale metadata"Final Verification
- [ ] Full build and vet
go build ./...
go vet ./...- [ ] End-to-end German smoke test
SAP_DEVS_DEV=1 LANG=de go run . --help
# Expected: English output (--help bypasses PersistentPreRunE — known limitation; Short/Long are not localized for help invocations)
SAP_DEVS_DEV=1 LANG=de go run . inject --dry-run
# Expected: "[Testlauf] Es werden keine Dateien geändert"
SAP_DEVS_DEV=1 LANG=de go run . sync --force
# Expected: German sync output (e.g. "SAP-Entwicklerinhalt wird synchronisiert...")
SAP_DEVS_DEV=1 LANG=de go run . tip
# Expected: German tip output (if cap pack German tips are present)
SAP_DEVS_DEV=1 LANG=de go run . doctor
# Expected: German table headers (TOOL, ERFORDERLICH, GEFUNDEN, STATUS)- [ ] End-to-end English smoke test (regression check)
SAP_DEVS_DEV=1 go run . --help
# Expected: English descriptions unchanged
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: "[dry-run] no files will be modified"
SAP_DEVS_DEV=1 go run . tip
SAP_DEVS_DEV=1 go run . doctor
# Expected: English table headers (TOOL, REQUIRED, FOUND, STATUS)- [ ] Config language override test
SAP_DEVS_DEV=1 go run . config set language de
SAP_DEVS_DEV=1 go run . inject --dry-run
# Expected: German output regardless of LANG env var
SAP_DEVS_DEV=1 go run . config set language ""