Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

## 0.2.0 - 2026-09-04

### Added

- Discover configured and uncovered catalogs across nested apps with `detect --json`, including runtime evidence, syntax suggestions, and unresolved source choices.
- Inspect resolved bundles, locale targets, syntax provenance, and offline credential presence with `config check --json`.
- Review and apply saved configuration proposals with `config plan` and `config apply`. Plans preserve provider settings, locale overrides, glossary paths, and bundle identities; application checks drift and returns a receipt tied to the plan.
- Discover command arguments, schemas, side effects, and next steps with `commands --json`.
- Preview translation work with `translate --dry-run --json`, separating planned work, blocked jobs, generated translations, and provider calls.

### Changed

- **JSON compatibility:** `validate --json` now returns a versioned envelope. Read reports from `data.reports` instead of the previous top-level array. Check `schema_version` before consuming command-specific data.
- Discovery and configuration diagnostics, translation results, and validation reports support bounded JSON output and scope filters. Structured errors include recovery argument arrays.
- Framework detection is advisory: i18next ICU integrations and mixed frameworks require explicit decisions. Temporary marketing catalogs are flagged for confirmation rather than replaced.

### Fixed

- Explain when automatic syntax detection interprets literal code braces as ICU, and point to explicit runtime profiles. Explicit ICU parsing and placeholder/protected-code validation remain strict.
- Base partial-failure reporting on persisted catalog or manifest updates, not staged translations from a failed batch sequence. Job results distinguish catalog writes from manifest updates.

See [the configuration workflow and JSON contract](docs/cli-onboarding.md) for setup, migration, and retry behavior.
45 changes: 40 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,44 @@ Human and JSON reports use stable finding codes:

### `detect`

Auto-detect the i18n framework and suggest a configuration.
Inspect existing configuration and discover catalogs, including nested apps.

```bash
internationalizer detect
internationalizer detect --json
internationalizer config check --json
```

Supports: react-i18next, next-intl, vue-i18n, vanilla JSON, markdown docs.
Discovery reports runtime evidence, uncovered catalogs, and unresolved choices.
i18next dependencies suggest `message_syntax: i18next`; ICU integration evidence
requires a runtime decision. JSON is a storage format, not a message grammar.

### `config plan` and `config apply`

Create a proposal, review its diff, then explicitly apply the saved plan:

```bash
internationalizer config plan --json \
--add-bundle web=exf-app/web/src/i18n/locales/en.json \
--syntax web=i18next --syntax default=i18next \
--confirm-source tmp/english-keys.json --out config-plan.json
internationalizer config apply --plan config-plan.json --no-input --json
internationalizer translate --dry-run --json
```

This example assumes an existing `source_path: tmp/english-keys.json` marketing
config. Select paths and syntax for your own runtime; discovery does not decide
which artifacts ship. Plan/apply preserves existing provider settings, locale
overrides, glossary paths, and bundle IDs. It rejects stale plans and recognizes
an already-applied configuration. `--no-input` disables prompts; it does not
authorize additional actions.

See [the onboarding and JSON contract](docs/cli-onboarding.md) for initial setup,
filters, error codes, and retry behavior. `internationalizer commands --json`
describes installed workflow entry points and their effects.

JSON compatibility: `validate --json` now emits a `schema_version: 1` envelope.
Consumers of its previous array output must read `data.reports` instead.

### `glossary`

Expand Down Expand Up @@ -490,9 +521,13 @@ documents receive markers on their next successful update.

`internationalizer detect` identifies your i18n setup by checking:

- `package.json` dependencies for react-i18next, next-intl, or vue-i18n
- Directory structures matching common locale patterns
- File extensions and naming conventions
- Existing configured bundles and source-locale filenames/directories
- Nested `package.json` dependencies and localization-module ICU references
- Runtime evidence separately from file format, with uncertainty made explicit

Scanning is bounded and excludes dependency, build, hidden, and data directories.
Dynamic plugin registration and unconventional catalog paths need explicit
configuration; static detection cannot prove that ICU integration is absent.

## Architecture

Expand Down
120 changes: 120 additions & 0 deletions cmd/internationalizer/commands.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package main

import (
"fmt"
"strings"

"github.com/spf13/cobra"
"github.com/spf13/pflag"
)

type commandContract struct {
Argv []string `json:"argv"`
Description string `json:"description"`
Arguments []flagContract `json:"arguments"`
SideEffects []string `json:"side_effects"`
Network string `json:"network"`
InputSchema map[string]any `json:"input_schema,omitempty"`
OutputSchema map[string]any `json:"output_schema,omitempty"`
OutputFormat string `json:"output_format"`
Next [][]string `json:"next,omitempty"`
}
type flagContract struct {
Name string `json:"name"`
Type string `json:"type"`
Default string `json:"default"`
Description string `json:"description"`
}

func newCommandsCmd(root *cobra.Command) *cobra.Command {
var asJSON bool
var selected string
var limit int
cmd := &cobra.Command{Use: "commands", Short: "Describe installed workflow entry points, arguments, and effects", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
if limit < 0 {
return codedError("invalid_arguments", fmt.Errorf("limit must be nonnegative"))
}
contracts := []commandContract{}
var visit func(*cobra.Command)
visit = func(c *cobra.Command) {
if c.Hidden {
return
}
if c.RunE != nil || c.Run != nil {
path := c.CommandPath()
item := commandContract{Argv: strings.Fields(path), Description: c.Short, Arguments: []flagContract{}, SideEffects: []string{}, Network: "none", OutputFormat: "human-readable"}
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
item.Arguments = append(item.Arguments, flagContract{Name: "--" + f.Name, Type: f.Value.Type(), Default: f.DefValue, Description: f.Usage})
}
})
switch strings.TrimPrefix(path, root.Name()+" ") {
case "detect":
item.Next = [][]string{{root.Name(), "config", "check", "--json"}, {root.Name(), "config", "plan", "--help"}}
case "config check":
item.Next = [][]string{{root.Name(), "config", "plan", "--help"}, {root.Name(), "translate", "--dry-run", "--json"}}
case "config plan":
item.SideEffects = []string{"writes a new plan file only when --out is supplied"}
item.Next = [][]string{{root.Name(), "config", "apply", "--help"}}
case "config apply":
item.SideEffects = []string{"writes only the selected plan's configuration file after integrity and drift checks"}
item.Next = [][]string{{root.Name(), "config", "check", "--json"}, {root.Name(), "translate", "--dry-run", "--json"}}
case "translate":
item.Network = "provider calls except --dry-run and --adopt-existing"
item.SideEffects = []string{"may write catalogs, translation memory, and state; --dry-run writes nothing"}
case "validate":
item.Next = [][]string{{root.Name(), "review", "--help"}}
case "commands":
default:
item.SideEffects = []string{"mode-dependent; inspect command help before execution"}
item.Network = "mode-dependent; inspect command help"
}
if c.Flags().Lookup("json") != nil {
switch strings.TrimPrefix(path, root.Name()+" ") {
case "detect", "commands", "config check", "config plan", "config apply", "translate", "validate":
item.OutputFormat = "internationalizer.cli.v1 (--json)"
item.InputSchema = workflowInputSchema(c)
item.OutputSchema = workflowOutputSchema(path)
default:
item.OutputFormat = "command-specific JSON (--json)"
}
}
contracts = append(contracts, item)
}
for _, child := range c.Commands() {
visit(child)
}
}
visit(root)
total := len(contracts)
if selected != "" {
filtered := contracts[:0]
for _, item := range contracts {
if strings.Join(item.Argv[1:], " ") == selected {
filtered = append(filtered, item)
}
}
contracts = filtered
if len(contracts) == 0 {
return codedError("invalid_arguments", fmt.Errorf("unknown command selection %q", selected))
}
}
matched := len(contracts)
if limit > 0 && len(contracts) > limit {
contracts = contracts[:limit]
}
if asJSON {
return emitJSON(cmd, "ok", map[string]any{"cli_version": version, "commands": contracts, "total": total, "matched": matched, "truncated": len(contracts) < matched, "exit_codes": map[string]string{"0": "command completed; inspect status and diagnostics for unresolved decisions", "1": "command failed or validation blocked"}, "states": []string{"configuration_checked", "planned", "applied", "generated", "structurally_valid", "human_approved"}, "authorization": "config apply is an explicit mutation request; --no-input disables prompts only"}, nil)
}
for _, c := range contracts {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s — %s\n", strings.Join(c.Argv, " "), c.Description); err != nil {
return err
}
}
return nil
}}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit versioned machine-readable command contracts")
cmd.Flags().StringVar(&selected, "command", "", "Return one command contract, for example 'config plan'")
cmd.Flags().IntVar(&limit, "limit", 50, "Maximum command contracts (0 for all)")
return cmd
}
182 changes: 182 additions & 0 deletions cmd/internationalizer/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package main

import (
"encoding/json"
"fmt"
"github.com/Tom-R-Main/Internationalizer/internal/config"
"github.com/Tom-R-Main/Internationalizer/internal/message"
"github.com/Tom-R-Main/Internationalizer/internal/onboarding"
"github.com/spf13/cobra"
"os"
"sort"
"strings"
)

func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "Inspect, plan, and explicitly apply project configuration"}
f := &inspectionFlags{}
check := &cobra.Command{Use: "check", Short: "Check resolved configuration offline without changing files", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { return runInspection(cmd, f, true) }}
f.bind(check)
cmd.AddCommand(check, newConfigPlanCmd(), newConfigApplyCmd())
return cmd
}

func assignments(values []string) (map[string]string, error) {
out := map[string]string{}
for _, value := range values {
key, v, ok := strings.Cut(value, "=")
if !ok || key == "" || v == "" {
return nil, fmt.Errorf("expected ID=value, got %q", value)
}
if _, exists := out[key]; exists {
return nil, fmt.Errorf("duplicate decision for %q", key)
}
out[key] = v
}
return out, nil
}

func newConfigPlanCmd() *cobra.Command {
var path, out, sourceLocale string
var additions, syntaxes, targets, confirm, locales []string
var asJSON bool
cmd := &cobra.Command{Use: "plan", Short: "Propose a reviewable config change; never apply it", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
adds, err := assignments(additions)
if err != nil {
return err
}
modes, err := assignments(syntaxes)
if err != nil {
return err
}
targetMap, err := assignments(targets)
if err != nil {
return err
}
opts := onboarding.PlanOptions{Syntax: map[string]message.Syntax{}, ConfirmSources: confirm, SourceLocale: sourceLocale, TargetLocales: locales}
for id, mode := range modes {
opts.Syntax[id] = message.Syntax(mode)
}
for id := range targetMap {
if _, ok := adds[id]; !ok {
return fmt.Errorf("--target %s requires --add-bundle %s=source", id, id)
}
}
if len(adds) > 0 {
report, scanErr := onboarding.Scan(".", path)
if scanErr != nil {
return scanErr
}
ids := make([]string, 0, len(adds))
for id := range adds {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
source := adds[id]
b := config.Bundle{ID: id, Source: source, Target: targetMap[id], MessageSyntax: opts.Syntax[id]}
for _, c := range report.Candidates {
if c.ID == source || c.Source == source {
b.Source = c.Source
b.Format = c.Format
if b.Target == "" {
b.Target = c.Target
}
break
}
}
if b.Target == "" {
return fmt.Errorf("source %q is not a discovered catalog; supply --target %s=path/{locale}.json", source, id)
}
opts.AddBundles = append(opts.AddBundles, b)
}
}
plan, err := onboarding.BuildPlan(".", path, opts)
if err != nil {
return err
}
if out != "" {
data, marshalErr := json.MarshalIndent(plan, "", " ")
if marshalErr != nil {
return marshalErr
}
file, openErr := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if openErr != nil {
return fmt.Errorf("save plan (will not overwrite): %w", openErr)
}
_, writeErr := file.Write(append(data, '\n'))
closeErr := file.Close()
if writeErr != nil {
return writeErr
}
if closeErr != nil {
return closeErr
}
}
status := "planned"
if len(plan.RequiredDecisions) > 0 {
status = "needs_decision"
}
if asJSON {
return emitJSON(cmd, status, plan, nil)
}
if _, err := fmt.Fprintln(cmd.OutOrStdout(), plan.Diff); err != nil {
return err
}
for _, d := range plan.RequiredDecisions {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", d.Code, d.Message); err != nil {
return err
}
}
if out != "" {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "Saved plan to %s. Review it, then: config apply --plan %s --no-input\n", out, out); err != nil {
return err
}
}
return nil
}}
cmd.Flags().StringVar(&path, "config", "", "Configuration path")
cmd.Flags().StringVar(&out, "out", "", "Save plan to a new file (never overwrites)")
cmd.Flags().StringArrayVar(&additions, "add-bundle", nil, "Explicit bundle ID=discovered-source-path (repeatable)")
cmd.Flags().StringArrayVar(&syntaxes, "syntax", nil, "Explicit bundle ID=plain|i18next|icu|auto (repeatable)")
cmd.Flags().StringArrayVar(&targets, "target", nil, "Target override for added bundle ID=path/{locale}.json")
cmd.Flags().StringArrayVar(&confirm, "confirm-source", nil, "Confirm authoritative source path, including tmp/ (repeatable)")
cmd.Flags().StringVar(&sourceLocale, "source-locale", "", "Explicit source locale (existing setting preserved when omitted)")
cmd.Flags().StringArrayVar(&locales, "locale", nil, "Explicit target locale set (repeatable; existing set preserved when omitted)")
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit versioned JSON")
return cmd
}

func newConfigApplyCmd() *cobra.Command {
var path string
var asJSON, noInput bool
cmd := &cobra.Command{Use: "apply", Short: "Apply an explicitly selected saved plan after drift checks", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error {
if path == "" {
return fmt.Errorf("--plan is required; --no-input only disables prompts")
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var plan onboarding.ConfigPlan
if err = json.Unmarshal(data, &plan); err != nil {
return fmt.Errorf("invalid plan: %w", err)
}
receipt, err := onboarding.ApplyPlan(&plan)
if asJSON {
status := "error"
if err == nil {
status = receipt.Status
}
return emitJSON(cmd, status, receipt, err)
}
if err != nil {
return err
}
return json.NewEncoder(cmd.OutOrStdout()).Encode(receipt)
}}
cmd.Flags().StringVar(&path, "plan", "", "Saved config plan to apply (explicit mutation request)")
cmd.Flags().BoolVar(&noInput, "no-input", false, "Disable prompting; does not authorize any additional action")
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit versioned JSON receipt")
return cmd
}
Loading