From 0c3bccfceb620556d0896d6dfa8fcb98bd93f07f Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 01:42:53 +0000 Subject: [PATCH] feat(i18n): add internationalization package for CLI - Service with embedded locale files (en, de) - Auto-detect system language from LANG/LC_* env vars - Template support for interpolation and pluralization - Extensible: GUI can load additional translations via LoadFS() - Global default service with T() shorthand - Thread-safe with sync.RWMutex Designed to be extended by core-gui which can import this package and add GUI-specific translations on top. Co-Authored-By: Claude Opus 4.5 --- go.mod | 1 + go.sum | 2 + pkg/i18n/i18n.go | 379 +++++++++++++++++++++++++++++++++++++++ pkg/i18n/i18n_test.go | 137 ++++++++++++++ pkg/i18n/locales/de.json | 94 ++++++++++ pkg/i18n/locales/en.json | 185 +++++++++++++++++++ 6 files changed, 798 insertions(+) create mode 100644 pkg/i18n/i18n.go create mode 100644 pkg/i18n/i18n_test.go create mode 100644 pkg/i18n/locales/de.json create mode 100644 pkg/i18n/locales/en.json diff --git a/go.mod b/go.mod index 4bd611d3..b71ad265 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/nicksnyder/go-i18n/v2 v2.6.1 // indirect github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect diff --git a/go.sum b/go.sum index 9037288b..a131c897 100644 --- a/go.sum +++ b/go.sum @@ -151,6 +151,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ= +github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA= github.com/oasdiff/oasdiff v1.11.8 h1:3LalSR0yYVM5sAYNInlIG4TVckLCJBkgjcnst2GKWVg= github.com/oasdiff/oasdiff v1.11.8/go.mod h1:YtP/1VnQo8FCdSWGJ11a98HFgLnFvUffH//FTDuEpls= github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= diff --git a/pkg/i18n/i18n.go b/pkg/i18n/i18n.go new file mode 100644 index 00000000..3de920f4 --- /dev/null +++ b/pkg/i18n/i18n.go @@ -0,0 +1,379 @@ +// Package i18n provides internationalization for the CLI. +// +// It is designed to be extended by the GUI version, which can import this +// package and add additional translations for GUI-specific strings. +// +// # Getting Started +// +// svc, err := i18n.New() +// if err != nil { +// log.Fatal(err) +// } +// fmt.Println(svc.T("cli.success")) +// +// # Extending for GUI +// +// The GUI can extend this package by creating its own Service that embeds +// this one and loads additional locale files: +// +// guiService, err := i18n.NewWithFS(guiLocaleFS, "locales") +// +// # Locale Files +// +// Locale files are JSON with message IDs as keys. Supports both simple strings +// and go-i18n format with pluralization: +// +// { +// "cli.success": "Operation completed successfully", +// "cli.items_found": { +// "one": "{{.Count}} item found", +// "other": "{{.Count}} items found" +// } +// } +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/nicksnyder/go-i18n/v2/i18n" + "golang.org/x/text/language" +) + +//go:embed locales/*.json +var localeFS embed.FS + +// Default is the global i18n service instance. +// Initialized lazily on first use or via Init(). +var ( + defaultService *Service + defaultOnce sync.Once + defaultErr error +) + +// Service provides internationalization and localization. +type Service struct { + bundle *i18n.Bundle + localizer *i18n.Localizer + currentLang string + availableLangs []language.Tag + mu sync.RWMutex +} + +// New creates a new i18n service with embedded locales. +// The service is initialized with the system language or English as fallback. +func New() (*Service, error) { + return NewWithFS(localeFS, "locales") +} + +// NewWithFS creates a new i18n service loading locales from the given filesystem. +// This allows the GUI to provide its own locale files. +func NewWithFS(fsys fs.FS, dir string) (*Service, error) { + bundle := i18n.NewBundle(language.English) + bundle.RegisterUnmarshalFunc("json", json.Unmarshal) + + availableLangs, err := loadLocalesFromFS(bundle, fsys, dir) + if err != nil { + return nil, err + } + + s := &Service{ + bundle: bundle, + availableLangs: availableLangs, + currentLang: "en", + } + + // Try to detect system language + if detected, err := detectLanguage(availableLangs); err == nil && detected != "" { + _ = s.SetLanguage(detected) + } else { + _ = s.SetLanguage("en") + } + + return s, nil +} + +// NewWithBundle creates a service from an existing bundle. +// Useful for extending the CLI i18n with GUI-specific translations. +func NewWithBundle(bundle *i18n.Bundle, langs []language.Tag) *Service { + s := &Service{ + bundle: bundle, + availableLangs: langs, + currentLang: "en", + } + _ = s.SetLanguage("en") + return s +} + +// Init initializes the default global service. +// Safe to call multiple times; only the first call has effect. +func Init() error { + defaultOnce.Do(func() { + defaultService, defaultErr = New() + }) + return defaultErr +} + +// Default returns the global i18n service, initializing if needed. +func Default() *Service { + if defaultService == nil { + _ = Init() + } + return defaultService +} + +// SetDefault sets the global i18n service. +// Useful for GUI to replace with an extended service. +func SetDefault(s *Service) { + defaultService = s +} + +// T translates a message using the default service. +// Shorthand for Default().T(messageID, args...). +func T(messageID string, args ...interface{}) string { + return Default().T(messageID, args...) +} + +// --- Language Management --- + +func loadLocalesFromFS(bundle *i18n.Bundle, fsys fs.FS, dir string) ([]language.Tag, error) { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return nil, fmt.Errorf("failed to read locales directory: %w", err) + } + + var langs []language.Tag + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + filePath := filepath.Join(dir, entry.Name()) + if _, err := bundle.LoadMessageFileFS(fsys, filePath); err != nil { + return nil, fmt.Errorf("failed to load locale %s: %w", entry.Name(), err) + } + + lang := strings.TrimSuffix(entry.Name(), ".json") + tag := language.Make(lang) + langs = append(langs, tag) + } + + if len(langs) == 0 { + return nil, fmt.Errorf("no locale files found in %s", dir) + } + + return langs, nil +} + +func detectLanguage(supported []language.Tag) (string, error) { + langEnv := os.Getenv("LANG") + if langEnv == "" { + // Try LC_ALL, LC_MESSAGES as fallbacks + langEnv = os.Getenv("LC_ALL") + if langEnv == "" { + langEnv = os.Getenv("LC_MESSAGES") + } + } + if langEnv == "" { + return "", nil + } + + // Parse LANG format: en_GB.UTF-8 -> en-GB + baseLang := strings.Split(langEnv, ".")[0] + baseLang = strings.ReplaceAll(baseLang, "_", "-") + + parsedLang, err := language.Parse(baseLang) + if err != nil { + return "", fmt.Errorf("failed to parse language tag '%s': %w", baseLang, err) + } + + if len(supported) == 0 { + return "", nil + } + + matcher := language.NewMatcher(supported) + _, index, confidence := matcher.Match(parsedLang) + + if confidence >= language.Low { + return supported[index].String(), nil + } + return "", nil +} + +// --- Public Service Methods --- + +// SetLanguage sets the language for translations. +// The language tag should be a valid BCP 47 tag (e.g., "en", "en-GB", "de"). +func (s *Service) SetLanguage(lang string) error { + s.mu.Lock() + defer s.mu.Unlock() + + requestedLang, err := language.Parse(lang) + if err != nil { + return fmt.Errorf("invalid language tag %q: %w", lang, err) + } + + if len(s.availableLangs) == 0 { + return fmt.Errorf("no languages available") + } + + matcher := language.NewMatcher(s.availableLangs) + bestMatch, _, confidence := matcher.Match(requestedLang) + + if confidence == language.No { + return fmt.Errorf("unsupported language: %s", lang) + } + + s.localizer = i18n.NewLocalizer(s.bundle, bestMatch.String()) + s.currentLang = bestMatch.String() + return nil +} + +// Language returns the current language code. +func (s *Service) Language() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.currentLang +} + +// AvailableLanguages returns the list of available language codes. +func (s *Service) AvailableLanguages() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + langs := make([]string, len(s.availableLangs)) + for i, tag := range s.availableLangs { + langs[i] = tag.String() + } + return langs +} + +// T translates a message by its ID. +// Optional template data can be passed for interpolation. +// +// Examples: +// +// svc.T("cli.success") +// svc.T("cli.items_found", map[string]int{"Count": 5}) +// svc.T("cli.greeting", map[string]string{"Name": "Alice"}) +func (s *Service) T(messageID string, args ...interface{}) string { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.localizer == nil { + return messageID + } + + config := &i18n.LocalizeConfig{MessageID: messageID} + if len(args) > 0 { + config.TemplateData = args[0] + } + + translation, err := s.localizer.Localize(config) + if err != nil { + // Return the message ID if translation not found + return messageID + } + return translation +} + +// Translate is an alias for T. +func (s *Service) Translate(messageID string, args ...interface{}) string { + return s.T(messageID, args...) +} + +// MustT translates a message, panicking if not found. +// Use sparingly, mainly for critical messages that must exist. +func (s *Service) MustT(messageID string, args ...interface{}) string { + s.mu.RLock() + defer s.mu.RUnlock() + + if s.localizer == nil { + panic(fmt.Sprintf("i18n: localizer not initialized for message %q", messageID)) + } + + config := &i18n.LocalizeConfig{MessageID: messageID} + if len(args) > 0 { + config.TemplateData = args[0] + } + + translation, err := s.localizer.Localize(config) + if err != nil { + panic(fmt.Sprintf("i18n: translation not found for %q: %v", messageID, err)) + } + return translation +} + +// Bundle returns the underlying i18n.Bundle. +// Useful for extending with additional translations. +func (s *Service) Bundle() *i18n.Bundle { + return s.bundle +} + +// AddMessages adds additional messages to the bundle. +// This allows runtime extension of translations. +func (s *Service) AddMessages(lang string, messages map[string]string) error { + s.mu.Lock() + defer s.mu.Unlock() + + tag := language.Make(lang) + var i18nMessages []*i18n.Message + for id, text := range messages { + i18nMessages = append(i18nMessages, &i18n.Message{ + ID: id, + Other: text, + }) + } + + if err := s.bundle.AddMessages(tag, i18nMessages...); err != nil { + return fmt.Errorf("failed to add messages for %s: %w", lang, err) + } + + // Check if this is a new language + found := false + for _, existing := range s.availableLangs { + if existing == tag { + found = true + break + } + } + if !found { + s.availableLangs = append(s.availableLangs, tag) + } + + return nil +} + +// LoadFS loads additional locale files from a filesystem. +// Useful for GUI to add its translations on top of CLI translations. +func (s *Service) LoadFS(fsys fs.FS, dir string) error { + s.mu.Lock() + defer s.mu.Unlock() + + newLangs, err := loadLocalesFromFS(s.bundle, fsys, dir) + if err != nil { + return err + } + + // Merge new languages + for _, newTag := range newLangs { + found := false + for _, existing := range s.availableLangs { + if existing == newTag { + found = true + break + } + } + if !found { + s.availableLangs = append(s.availableLangs, newTag) + } + } + + return nil +} diff --git a/pkg/i18n/i18n_test.go b/pkg/i18n/i18n_test.go new file mode 100644 index 00000000..edbac09c --- /dev/null +++ b/pkg/i18n/i18n_test.go @@ -0,0 +1,137 @@ +package i18n + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNew(t *testing.T) { + svc, err := New() + require.NoError(t, err) + require.NotNil(t, svc) + + // Should have English available + langs := svc.AvailableLanguages() + assert.Contains(t, langs, "en") +} + +func TestTranslate(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + // Basic translation + result := svc.T("cli.success") + assert.Equal(t, "Success", result) + + // Missing key returns the key + result = svc.T("nonexistent.key") + assert.Equal(t, "nonexistent.key", result) +} + +func TestTranslateWithArgs(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + // Translation with template data + result := svc.T("error.not_found", map[string]string{"Item": "config.yaml"}) + assert.Equal(t, "Not found: config.yaml", result) + + result = svc.T("cli.time.minutes_ago", map[string]int{"Count": 5}) + assert.Equal(t, "5 minutes ago", result) +} + +func TestSetLanguage(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + // Default is English + assert.Equal(t, "en", svc.Language()) + + // Setting invalid language should error + err = svc.SetLanguage("xx-invalid") + assert.Error(t, err) + + // Language should still be English + assert.Equal(t, "en", svc.Language()) +} + +func TestDefaultService(t *testing.T) { + // Reset default for test + defaultService = nil + defaultOnce = sync.Once{} + defaultErr = nil + + err := Init() + require.NoError(t, err) + + svc := Default() + require.NotNil(t, svc) + + // Global T function should work + result := T("cli.success") + assert.Equal(t, "Success", result) +} + +func TestAddMessages(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + // Add custom messages + err = svc.AddMessages("en", map[string]string{ + "custom.greeting": "Hello, {{.Name}}!", + }) + require.NoError(t, err) + + result := svc.T("custom.greeting", map[string]string{"Name": "World"}) + assert.Equal(t, "Hello, World!", result) +} + +func TestAvailableLanguages(t *testing.T) { + svc, err := New() + require.NoError(t, err) + + langs := svc.AvailableLanguages() + assert.NotEmpty(t, langs) + assert.Contains(t, langs, "en") +} + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + name string + langEnv string + expected string + }{ + { + name: "English exact", + langEnv: "en", + expected: "en", + }, + { + name: "English with region and encoding", + langEnv: "en_GB.UTF-8", + expected: "en", + }, + { + name: "Empty LANG", + langEnv: "", + expected: "", + }, + } + + svc, err := New() + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("LANG", tt.langEnv) + t.Setenv("LC_ALL", "") + t.Setenv("LC_MESSAGES", "") + + result, _ := detectLanguage(svc.availableLangs) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/pkg/i18n/locales/de.json b/pkg/i18n/locales/de.json new file mode 100644 index 00000000..ec2fcbfc --- /dev/null +++ b/pkg/i18n/locales/de.json @@ -0,0 +1,94 @@ +{ + "cli.success": "Erfolg", + "cli.error": "Fehler", + "cli.warning": "Warnung", + "cli.info": "Info", + "cli.done": "Fertig", + "cli.failed": "Fehlgeschlagen", + "cli.pass": "BESTANDEN", + "cli.fail": "FEHLGESCHLAGEN", + "cli.ok": "OK", + "cli.skip": "Übersprungen", + "cli.pending": "Ausstehend", + "cli.running": "Läuft", + "cli.completed": "Abgeschlossen", + "cli.cancelled": "Abgebrochen", + "cli.aborted": "Abgebrochen", + + "cli.confirm.yes": "Ja", + "cli.confirm.no": "Nein", + "cli.confirm.proceed": "Fortfahren?", + "cli.confirm.continue": "Weiter?", + "cli.confirm.abort": "Vorgang abgebrochen", + + "cli.progress.checking": "Prüfe", + "cli.progress.fetching": "Lade", + "cli.progress.loading": "Lade", + "cli.progress.processing": "Verarbeite", + "cli.progress.installing": "Installiere", + "cli.progress.building": "Baue", + "cli.progress.deploying": "Deploye", + "cli.progress.testing": "Teste", + + "cli.time.just_now": "gerade eben", + "cli.time.seconds_ago": "vor {{.Count}} Sekunden", + "cli.time.minute_ago": "vor 1 Minute", + "cli.time.minutes_ago": "vor {{.Count}} Minuten", + "cli.time.hour_ago": "vor 1 Stunde", + "cli.time.hours_ago": "vor {{.Count}} Stunden", + "cli.time.day_ago": "vor 1 Tag", + "cli.time.days_ago": "vor {{.Count}} Tagen", + "cli.time.week_ago": "vor 1 Woche", + "cli.time.weeks_ago": "vor {{.Count}} Wochen", + + "cli.count.items": { + "one": "{{.Count}} Element", + "other": "{{.Count}} Elemente" + }, + "cli.count.files": { + "one": "{{.Count}} Datei", + "other": "{{.Count}} Dateien" + }, + "cli.count.repos": { + "one": "{{.Count}} Repository", + "other": "{{.Count}} Repositories" + }, + "cli.count.commits": { + "one": "{{.Count}} Commit", + "other": "{{.Count}} Commits" + }, + + "cmd.dev.short": "Multi-Repository-Entwicklung", + "cmd.dev.status.dirty": "geändert", + "cmd.dev.status.clean": "sauber", + "cmd.dev.status.ahead": "voraus", + "cmd.dev.status.behind": "zurück", + "cmd.dev.status.synced": "synchronisiert", + + "cmd.dev.push.confirm": "Alle pushen?", + "cmd.dev.commit.committing": "Committe geänderte Repos mit Claude...", + + "cmd.doctor.short": "Entwicklungsumgebung prüfen", + "cmd.doctor.checking": "Prüfe Entwicklungsumgebung...", + "cmd.doctor.required": "Erforderlich", + "cmd.doctor.optional": "Optional", + "cmd.doctor.ready": "Umgebung bereit", + + "error.not_found": "Nicht gefunden: {{.Item}}", + "error.invalid": "Ungültig: {{.Item}}", + "error.permission": "Zugriff verweigert: {{.Item}}", + "error.timeout": "Zeitüberschreitung", + "error.gh_not_found": "'gh' CLI nicht gefunden. Installieren von https://cli.github.com/", + + "label.status": "Status", + "label.branch": "Branch", + "label.commit": "Commit", + "label.coverage": "Abdeckung", + "label.total": "Gesamt", + + "lang.en": "Englisch", + "lang.de": "Deutsch", + "lang.es": "Spanisch", + "lang.fr": "Französisch", + "lang.zh": "Chinesisch" +} diff --git a/pkg/i18n/locales/en.json b/pkg/i18n/locales/en.json new file mode 100644 index 00000000..a2b729ab --- /dev/null +++ b/pkg/i18n/locales/en.json @@ -0,0 +1,185 @@ +{ + "cli.success": "Success", + "cli.error": "Error", + "cli.warning": "Warning", + "cli.info": "Info", + "cli.done": "Done", + "cli.failed": "Failed", + "cli.pass": "PASS", + "cli.fail": "FAIL", + "cli.ok": "OK", + "cli.skip": "Skipped", + "cli.pending": "Pending", + "cli.running": "Running", + "cli.completed": "Completed", + "cli.cancelled": "Cancelled", + "cli.aborted": "Aborted", + + "cli.confirm.yes": "Yes", + "cli.confirm.no": "No", + "cli.confirm.proceed": "Proceed?", + "cli.confirm.continue": "Continue?", + "cli.confirm.abort": "Operation aborted", + + "cli.progress.checking": "Checking", + "cli.progress.fetching": "Fetching", + "cli.progress.loading": "Loading", + "cli.progress.processing": "Processing", + "cli.progress.installing": "Installing", + "cli.progress.building": "Building", + "cli.progress.deploying": "Deploying", + "cli.progress.testing": "Testing", + + "cli.time.just_now": "just now", + "cli.time.seconds_ago": "{{.Count}} seconds ago", + "cli.time.minute_ago": "1 minute ago", + "cli.time.minutes_ago": "{{.Count}} minutes ago", + "cli.time.hour_ago": "1 hour ago", + "cli.time.hours_ago": "{{.Count}} hours ago", + "cli.time.day_ago": "1 day ago", + "cli.time.days_ago": "{{.Count}} days ago", + "cli.time.week_ago": "1 week ago", + "cli.time.weeks_ago": "{{.Count}} weeks ago", + + "cli.count.items": { + "one": "{{.Count}} item", + "other": "{{.Count}} items" + }, + "cli.count.files": { + "one": "{{.Count}} file", + "other": "{{.Count}} files" + }, + "cli.count.repos": { + "one": "{{.Count}} repo", + "other": "{{.Count}} repos" + }, + "cli.count.commits": { + "one": "{{.Count}} commit", + "other": "{{.Count}} commits" + }, + + "cmd.dev.short": "Multi-repo development workflow", + "cmd.dev.work.short": "Combined status, commit, and push workflow", + "cmd.dev.health.short": "Quick repo health summary", + "cmd.dev.commit.short": "Claude-assisted commit messages", + "cmd.dev.push.short": "Push repos with unpushed commits", + "cmd.dev.pull.short": "Pull repos behind remote", + "cmd.dev.issues.short": "List open issues across repos", + "cmd.dev.reviews.short": "List PRs awaiting review", + "cmd.dev.ci.short": "Check GitHub Actions status", + "cmd.dev.impact.short": "Analyse dependency impact", + + "cmd.dev.status.dirty": "dirty", + "cmd.dev.status.clean": "clean", + "cmd.dev.status.ahead": "ahead", + "cmd.dev.status.behind": "behind", + "cmd.dev.status.synced": "synced", + "cmd.dev.status.diverged": "diverged", + "cmd.dev.status.conflict": "conflict", + + "cmd.dev.push.confirm": "Push all?", + "cmd.dev.push.success": "Pushed successfully", + "cmd.dev.push.failed": "Push failed", + "cmd.dev.push.nothing": "Nothing to push", + + "cmd.dev.commit.committing": "Committing dirty repos with Claude...", + "cmd.dev.commit.success": "Committed successfully", + "cmd.dev.commit.failed": "Commit failed", + + "cmd.php.short": "Laravel/PHP development tools", + "cmd.php.dev.short": "Start development servers", + "cmd.php.test.short": "Run PHPUnit tests", + "cmd.php.fmt.short": "Format code with Pint", + "cmd.php.analyse.short": "Run static analysis", + "cmd.php.build.short": "Build production assets", + "cmd.php.deploy.short": "Deploy to Coolify", + "cmd.php.qa.short": "Run all quality checks", + + "cmd.php.test.running": "Running tests", + "cmd.php.test.passed": "All tests passed", + "cmd.php.test.failed": "Some tests failed", + + "cmd.go.short": "Go development tools", + "cmd.go.test.short": "Run tests with coverage", + "cmd.go.fmt.short": "Format Go code", + "cmd.go.build.short": "Build Go binaries", + "cmd.go.cov.short": "Run tests with coverage report", + + "cmd.go.test.running": "Running tests", + "cmd.go.test.passed": "All tests passed", + "cmd.go.test.failed": "Some tests failed", + "cmd.go.test.coverage": "Coverage", + + "cmd.doctor.short": "Check development environment", + "cmd.doctor.checking": "Checking development environment...", + "cmd.doctor.required": "Required", + "cmd.doctor.optional": "Optional", + "cmd.doctor.github": "GitHub Access", + "cmd.doctor.workspace": "Workspace", + "cmd.doctor.ready": "Environment ready", + "cmd.doctor.issues": "{{.Count}} issues found", + + "cmd.setup.short": "Set up development workspace", + "cmd.setup.cloning": "Cloning repositories...", + "cmd.setup.complete": "Setup complete", + + "cmd.build.short": "Build and release tools", + "cmd.build.building": "Building...", + "cmd.build.complete": "Build complete", + + "cmd.ai.short": "AI task management", + "cmd.ai.tasks.short": "List tasks", + "cmd.ai.task.short": "View task details", + + "cmd.vm.short": "LinuxKit VM management", + "cmd.vm.run.short": "Run a VM from image or template", + "cmd.vm.ps.short": "List running VMs", + "cmd.vm.stop.short": "Stop a running VM", + "cmd.vm.logs.short": "View VM logs", + + "cmd.docs.short": "Documentation tools", + "cmd.docs.list.short": "List documentation files", + "cmd.docs.sync.short": "Sync docs to central location", + + "cmd.pkg.short": "Package management", + "cmd.pkg.install.short": "Install packages", + "cmd.pkg.search.short": "Search packages", + + "cmd.ci.short": "CI/CD tools", + "cmd.ci.release.short": "Create a release", + + "cmd.sdk.short": "SDK validation tools", + "cmd.sdk.diff.short": "Check for breaking API changes", + "cmd.sdk.validate.short": "Validate OpenAPI spec", + + "cmd.test.short": "Test runner", + "cmd.test.running": "Running tests...", + + "error.not_found": "Not found: {{.Item}}", + "error.invalid": "Invalid: {{.Item}}", + "error.permission": "Permission denied: {{.Item}}", + "error.timeout": "Operation timed out", + "error.network": "Network error: {{.Message}}", + "error.gh_not_found": "'gh' CLI not found. Install from https://cli.github.com/", + "error.registry_not_found": "No repos.yaml found", + "error.repo_not_found": "Repository '{{.Name}}' not found", + + "label.status": "Status", + "label.branch": "Branch", + "label.commit": "Commit", + "label.message": "Message", + "label.author": "Author", + "label.date": "Date", + "label.duration": "Duration", + "label.coverage": "Coverage", + "label.package": "Package", + "label.registry": "Registry", + "label.scanning": "Scanning", + "label.total": "Total", + + "lang.en": "English", + "lang.de": "German", + "lang.es": "Spanish", + "lang.fr": "French", + "lang.zh": "Chinese" +}