diff --git a/cmd/a.go b/cmd/a.go index f821188114bd80395d7c56da528d123312d2a480..578c9275a8c7948e1b4e6558bf170e8d32a54daf 100644 --- a/cmd/a.go +++ b/cmd/a.go @@ -46,12 +46,12 @@ func runArchiveSession(cmd *cobra.Command, args []string) error { if foundArchived { // Session already archived, idempotent operation out := cmd.OutOrStdout() - _, _ = fmt.Fprintf(out, "Session %s archived.\n", archivedDoc.SID) + _, _ = fmt.Fprintf(out, env.Localizer.T("session.archive.success"), archivedDoc.SID) return nil } // No session at all (neither active nor archived) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No active session. Start one with `np s`.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("session.archive.none_active")) return nil } @@ -61,6 +61,6 @@ func runArchiveSession(cmd *cobra.Command, args []string) error { } out := cmd.OutOrStdout() - _, _ = fmt.Fprintf(out, "Session %s archived.\n", archived.SID) + _, _ = fmt.Fprintf(out, env.Localizer.T("session.archive.success"), archived.SID) return nil } diff --git a/cmd/g/s.go b/cmd/g/s.go index 3fe44d904dd07935d93c608d7abd0d81fadd214f..644e37d710ebcbde253eaad22f3dbaeb17c5678c 100644 --- a/cmd/g/s.go +++ b/cmd/g/s.go @@ -58,11 +58,11 @@ func runSetGoal(cmd *cobra.Command, _ []string) error { description = strings.TrimSpace(description) if title == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Goal title is required.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.set.title_required")) return nil } if description == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Goal description is required.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.set.description_required")) return nil } @@ -71,7 +71,7 @@ func runSetGoal(cmd *cobra.Command, _ []string) error { return err } if exists { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Goal already set. Use 'np g u' to update it (requires -r/--reason flag).") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.set.already_set")) return nil } @@ -103,7 +103,7 @@ func runSetGoal(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() _, _ = fmt.Fprintln(out, "") - _, _ = fmt.Fprintln(out, "Study everything above carefully, the reference content, the source code, the documentation, etc. Once you've a solid understanding of how to approach resolving the request, fill out your task list. Prefer adding/updating in batch.") + _, _ = fmt.Fprintln(out, env.Localizer.T("goal.set.guidance")) return nil } diff --git a/cmd/g/u.go b/cmd/g/u.go index b9cca27479d49169aea1adbe4b07e6b500fb43d7..2ab64e23f0a9fd63750ecce3f4937daf332fe63e 100644 --- a/cmd/g/u.go +++ b/cmd/g/u.go @@ -49,7 +49,7 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error { current, err := env.GoalStore.Get(cmd.Context(), sessionDoc.SID) if err != nil { if errors.Is(err, goal.ErrNotFound) { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No goal set yet. Use 'np g s' first.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.update.not_set")) return nil } return err @@ -70,7 +70,7 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error { reason = strings.TrimSpace(reason) if reason == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason is required for goal updates.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.update.reason_required")) return nil } @@ -78,7 +78,7 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error { if cmd.Flags().Changed("title") { newTitle = strings.TrimSpace(titleInput) if newTitle == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Goal title cannot be empty.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.update.title_empty")) return nil } } @@ -89,12 +89,12 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error { } if !cmd.Flags().Changed("title") && !cmd.Flags().Changed("description") { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title or --description to update the goal.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.update.no_changes_provided")) return nil } if newTitle == current.Title && newDescription == current.Description { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Goal already matches the provided values; no changes made.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("goal.update.no_changes_made")) return nil } @@ -127,7 +127,7 @@ func runUpdateGoal(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() _, _ = fmt.Fprintln(out, "") - _, _ = fmt.Fprintln(out, "Goal updated. Ensure pending tasks still align with the goal and adjust them and/or add new tasks if necessary.") + _, _ = fmt.Fprintln(out, env.Localizer.T("goal.update.guidance")) return nil } diff --git a/cmd/r.go b/cmd/r.go index da512f0a79fcf86a28a5e984f85fc18dcbc71897..f1affaec7e0344af3bebae4bcd1d512fc851be61 100644 --- a/cmd/r.go +++ b/cmd/r.go @@ -40,22 +40,10 @@ func runResume(cmd *cobra.Command, args []string) error { } // Print instructions for resuming work - _, _ = fmt.Fprintln(cmd.OutOrStdout(), strings.Repeat("-", 80)) - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Resuming session. To continue:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "1. Thoroughly consider the goal and its description.") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "2. Read the referenced files and symbols, especially in the pending tasks, to understand what work remains.") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "3. Add more tasks if needed. For multi-line descriptions, use literal newlines:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " # Single") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " np t a -t \"task title\" -d \"details\"") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " # Batch (preferred for multiple additions)") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " np t a -t \"first\" -d \"step 1 details\" -t \"second\" -d \"step 2 with") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " more details\" -t \"third\" -d \"step three\"`") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "4. Update task status as you work:") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Single: `np t u -i -s `") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Batch: `np t u -i -s -i -s `") - _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Statuses: pending, in_progress, completed, failed, cancelled") + out := cmd.OutOrStdout() + _, _ = fmt.Fprintln(out, strings.Repeat("-", 80)) + _, _ = fmt.Fprintln(out, env.Localizer.T("session.resume.header")) + _, _ = fmt.Fprintln(out, env.Localizer.T("session.resume.guidance")) // Provide context about pending work pendingCount := 0 @@ -70,10 +58,10 @@ func runResume(cmd *cobra.Command, args []string) error { } if inProgressCount > 0 { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n%d task(s) are in progress.\n", inProgressCount) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("session.resume.in_progress_count"), inProgressCount) } if pendingCount > 0 { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%d task(s) are pending.\n", pendingCount) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("session.resume.pending_count"), pendingCount) } return nil diff --git a/cmd/root.go b/cmd/root.go index ecc9d821ceedf64efe715271d27a3347e7fa9923..343731733b0801dad9ebe5dc3ef37bff05b2d898 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,19 +6,26 @@ package cmd import ( "errors" + "os" "git.secluded.site/np/cmd/g" "git.secluded.site/np/cmd/t" "git.secluded.site/np/internal/cli" + "git.secluded.site/np/internal/config" "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/i18n" "github.com/spf13/cobra" ) -var rootCmd = &cobra.Command{ - Use: "np", - Short: "nasin pali - task planning for LLM agents", - Long: `A CLI tool for guiding LLMs through structured task planning and execution`, -} +var ( + languageFlag string + + rootCmd = &cobra.Command{ + Use: "np", + Short: "nasin pali - task planning for LLM agents", + Long: `A CLI tool for guiding LLMs through structured task planning and execution`, + } +) func RootCmd() *cobra.Command { return rootCmd @@ -26,6 +33,8 @@ func RootCmd() *cobra.Command { func init() { rootCmd.SilenceUsage = true + rootCmd.PersistentFlags().StringVar(&languageFlag, "lang", "", "Override language (en, tok)") + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { if err := openEnvironment(); err != nil { return err @@ -52,7 +61,19 @@ func openEnvironment() error { if environment != nil { return nil } - env, err := cli.OpenEnvironment(db.Options{}, nil) + + cfg, err := config.Load() + if err != nil { + return err + } + + lang := resolveLanguage(cfg) + localizer, err := i18n.Load(lang) + if err != nil { + return err + } + + env, err := cli.OpenEnvironment(db.Options{}, nil, cfg, localizer) if err != nil { return err } @@ -60,6 +81,16 @@ func openEnvironment() error { return nil } +func resolveLanguage(cfg config.Config) string { + if languageFlag != "" { + return languageFlag + } + if envLang := os.Getenv("NP_LANG"); envLang != "" { + return envLang + } + return cfg.UI.Language +} + func closeEnvironment() error { if environment == nil { return nil diff --git a/cmd/s.go b/cmd/s.go index edb8ce9e20865add5f5d786582d3621ad4d727cf..ff7390c56d9bd05087bcef6550b0b711225f4496 100644 --- a/cmd/s.go +++ b/cmd/s.go @@ -9,6 +9,7 @@ import ( "fmt" "os" + "git.secluded.site/np/internal/cli" "git.secluded.site/np/internal/session" "github.com/spf13/cobra" ) @@ -36,36 +37,25 @@ func runStartSession(cmd *cobra.Command, args []string) error { if err != nil { var already session.AlreadyActiveError if errors.As(err, &already) { - return printExistingSession(cmd, already.Session) + return printExistingSession(cmd, env, already.Session) } return err } - printSessionStarted(cmd, doc) + printSessionStarted(cmd, env, doc) return nil } -func printExistingSession(cmd *cobra.Command, existing session.Document) error { +func printExistingSession(cmd *cobra.Command, env *cli.Environment, existing session.Document) error { out := cmd.OutOrStdout() - _, _ = fmt.Fprintf(out, "Session %s is already active for %s.\n", existing.SID, existing.DirPath) - _, _ = fmt.Fprintln(out, "Ask your operator whether they want to resume (`np r`) or archive (`np a`) it.") + _, _ = fmt.Fprintf(out, env.Localizer.T("session.start.already_active"), existing.SID, existing.DirPath) + _, _ = fmt.Fprintln(out, env.Localizer.T("session.start.already_active_guidance")) return nil } -func printSessionStarted(cmd *cobra.Command, doc session.Document) { +func printSessionStarted(cmd *cobra.Command, env *cli.Environment, doc session.Document) { out := cmd.OutOrStdout() - _, _ = fmt.Fprintf(out, "Session %s is now active for %s.\n\n", doc.SID, doc.DirPath) - _, _ = fmt.Fprintln(out, "If you haven't already, read any provided issue/ticket/file/webpage/commit/etc. references and thoroughly mull them over. If you already see the contents above, don't re-read them. If there were no referenced files or you don't have a clear picture of the issue, you may selectively read additional relevant files until you do have a clear picture.") - _, _ = fmt.Fprintln(out, "Set the goal with `np g s -t \"goal title\" -d \"goal description\"`.") - _, _ = fmt.Fprintln(out, "Capture the most concise form of the overarching goal using no more than 20 words in the title. Elaborate _usefully_ in the description; don't just repeat the title in more flowery language. In case we're interrupted and need to pick up from this plan later, include:") - _, _ = fmt.Fprintln(out, "- Summaries of only the relevant portions of the referenced content. Include their URLs/IDs/hashes. Copy the user's language around the references: if they say 'look at bug REF', use 'Bug: REF' near the summary. If 'issue NUM', then 'Issue: NUM.'") - _, _ = fmt.Fprintln(out, "- Paths to the relevant files, and if there are particularly relevant symbols from those files, include them too. DO NOT summarise the files or symbols. Only list them if they're relevant") - _, _ = fmt.Fprintln(out, "- An 'Immediate thoughts:' line at the bottom. You should have enough files to have some idea of the issue and its resolution; briefly capture your immediate thoughts in this line, couching with appropriate uncertainty.") - _, _ = fmt.Fprintln(out, "Add single tasks with `np t a -t \"task\" -d \"details\"`, but prefer batching. For multi-line descriptions, use literal newlines:") - _, _ = fmt.Fprintln(out, " np t a -t \"first task\" -d \"step 1 details\" -t \"second task\" -d \"step 2 with") - _, _ = fmt.Fprintln(out, " \nmore details\" -t \"third task\" -d \"step three\"") - _, _ = fmt.Fprintln(out, "Keep task statuses up to date as you work:") - _, _ = fmt.Fprintln(out, " Single update: `np t u -i task-id -s in_progress|completed|failed|cancelled`") - _, _ = fmt.Fprintln(out, " But prefer batching: `np t u -i abc123 -s completed -i def456 -s in_progress`") - _, _ = fmt.Fprintln(out, "Use `np p` if you need to review the full plan.") + _, _ = fmt.Fprintf(out, env.Localizer.T("session.start.now_active"), doc.SID, doc.DirPath) + + _, _ = fmt.Fprintln(out, env.Localizer.T("session.start.guidance")) } diff --git a/cmd/shared/helpers.go b/cmd/shared/helpers.go index 2a30f5cc2b8383d91dd9acdbea49a1998b59081f..0f10086bfdaa43b64b089c4bc066a76ce94aadbd 100644 --- a/cmd/shared/helpers.go +++ b/cmd/shared/helpers.go @@ -18,7 +18,7 @@ import ( func Environment(cmd *cobra.Command) (*cli.Environment, error) { env, ok := cli.FromContext(cmd.Context()) if !ok || env == nil { - return nil, fmt.Errorf("environment not initialised") + return nil, fmt.Errorf("cli: environment not initialised") } return env, nil } @@ -35,7 +35,7 @@ func ActiveSession(cmd *cobra.Command, env *cli.Environment) (session.Document, return session.Document{}, false, err } if !found { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No active session. Start one with `np s`.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("session.none_active")) return session.Document{}, false, nil } return doc, true, nil @@ -53,6 +53,6 @@ func PrintPlan(cmd *cobra.Command, env *cli.Environment, sid string) (cli.PlanSt return cli.PlanState{}, err } - _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderPlan(state)) + _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderPlan(state, env.Localizer)) return state, nil } diff --git a/cmd/t/a.go b/cmd/t/a.go index 690f7e996d02b2ba0511aef23c23d8c9b0922112..f74050d543e4ebb4464ab6b044cdef276caa500b 100644 --- a/cmd/t/a.go +++ b/cmd/t/a.go @@ -56,11 +56,11 @@ func runAddTasks(cmd *cobra.Command, _ []string) error { } if len(titles) != len(descriptions) { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Number of titles and descriptions must match.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.add.count_mismatch")) return nil } if len(titles) == 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task with -t title -d description.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.add.none_provided")) return nil } @@ -76,7 +76,7 @@ func runAddTasks(cmd *cobra.Command, _ []string) error { description := strings.TrimSpace(descriptions[i]) if title == "" { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task title at position %d cannot be empty.\n", i+1) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.add.title_empty_at"), i+1) return nil } @@ -142,7 +142,7 @@ func runAddTasks(cmd *cobra.Command, _ []string) error { }) if err != nil { if errors.Is(err, task.ErrEmptyTitle) { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.add.title_empty")) return nil } return err @@ -164,10 +164,8 @@ func runAddTasks(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() _, _ = fmt.Fprintln(out, "") - _, _ = fmt.Fprintf(out, "Added %d task(s).\n", addedCount) - _, _ = fmt.Fprintln(out, "Update task statuses as you work:") - _, _ = fmt.Fprintln(out, " Single: `np t u -i task-id -s in_progress|completed|failed|cancelled`") - _, _ = fmt.Fprintln(out, " Batch: `np t u -i abc123 -s completed -i def456 -s in_progress`") + _, _ = fmt.Fprintf(out, env.Localizer.T("task.add.success"), addedCount) + _, _ = fmt.Fprintln(out, env.Localizer.T("task.add.guidance")) return nil } diff --git a/cmd/t/t.go b/cmd/t/t.go index 8d3997e40671234b397774f589d52e76ce8cd092..368c7d40ac1eb65bc9ed7fc148c9237f940b2013 100644 --- a/cmd/t/t.go +++ b/cmd/t/t.go @@ -47,7 +47,7 @@ func runListTasks(cmd *cobra.Command, _ []string) error { if statusFlag != "" && statusFlag != "all" { statusFilter, err = task.ParseStatus(statusFlag) if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled, all.\n", statusFlag) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.list.invalid_status"), statusFlag) return nil } } @@ -57,6 +57,6 @@ func runListTasks(cmd *cobra.Command, _ []string) error { return err } - _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks)) + _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks, env.Localizer)) return nil } diff --git a/cmd/t/u.go b/cmd/t/u.go index 67b914f3b6b5e1a4937cd368d32b3c08bb2bde92..04dcb89e0e0a55700d7639a59fdf33b20af89284 100644 --- a/cmd/t/u.go +++ b/cmd/t/u.go @@ -70,7 +70,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error { taskIDs = cleanIDs if len(taskIDs) == 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task ID with -i/--id.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_ids")) return nil } @@ -86,7 +86,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error { descChanged := cmd.Flags().Changed("description") if (titleChanged || descChanged) && !isSingleTask { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Title and description updates are only supported for single task updates.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.title_desc_single_only")) return nil } @@ -97,7 +97,7 @@ func runUpdateTask(cmd *cobra.Command, _ []string) error { // Handle single task update (possibly with title/description) if !isSingleTask { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.status_count_mismatch")) return nil } @@ -116,7 +116,7 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, statusStr := strings.TrimSpace(statuses[i]) newStatus, err := task.ParseStatus(statusStr) if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr, taskID) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.invalid_status"), statusStr, taskID) return nil } pairs = append(pairs, updatePair{id: taskID, status: newStatus}) @@ -133,7 +133,7 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, } if reasonRequired && reason == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required when changing status to cancelled or failed.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.reason_required_status")) return nil } @@ -149,7 +149,7 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, before, err := taskTxn.Get(sid, pair.id) if err != nil { if errors.Is(err, task.ErrNotFound) { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", pair.id) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.not_found"), pair.id) continue } return err @@ -189,7 +189,7 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, } if updatedCount == 0 { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No tasks were updated (all tasks either not found or already at target status).") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_updates")) return nil } @@ -197,11 +197,11 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, if err != nil { return err } - _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks)) + _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks, env.Localizer)) out := cmd.OutOrStdout() _, _ = fmt.Fprintln(out, "") - _, _ = fmt.Fprintf(out, "Updated %d task(s).\n", updatedCount) + _, _ = fmt.Fprintf(out, env.Localizer.T("task.update.success"), updatedCount) // Check if all work is complete allComplete := true @@ -218,7 +218,9 @@ func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, } if allComplete { - _, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`).") + _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.none_pending_archive")) + } else { + _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.continue_working")) } return nil @@ -228,7 +230,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t current, err := env.TaskStore.Get(cmd.Context(), sid, taskID) if err != nil { if errors.Is(err, task.ErrNotFound) { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.not_found"), taskID) return nil } return err @@ -238,7 +240,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t if titleChanged { newTitle = strings.TrimSpace(titleInput) if newTitle == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.title_empty")) return nil } } @@ -254,7 +256,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t statusStr := strings.TrimSpace(statuses[0]) newStatus, err = task.ParseStatus(statusStr) if err != nil { - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusStr) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.invalid_status_single"), statusStr) return nil } } else { @@ -262,7 +264,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t } if !titleChanged && !descChanged && !statusChanged { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_changes_provided")) return nil } @@ -271,7 +273,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t (statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed)) if reasonRequired && reason == "" { - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.") + _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.reason_required")) return nil } @@ -330,7 +332,7 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t if err != nil { return err } - _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks)) + _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks, env.Localizer)) out := cmd.OutOrStdout() _, _ = fmt.Fprintln(out, "") @@ -345,10 +347,12 @@ func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, t } if len(pending) == 0 && len(inProgress) == 0 { - _, _ = fmt.Fprintln(out, "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`).") + _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.none_pending_archive")) + } else { + _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.completed_continue")) } } else { - _, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.") + _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.updated_review")) } return nil diff --git a/go.mod b/go.mod index cdb3f941d7998d56fd47e6c0298612777684cba6..9606f45d9c5ea5a22ee9ad4a703ba228c196b9a2 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ module git.secluded.site/np go 1.25.3 require ( + github.com/BurntSushi/toml v1.5.0 github.com/charmbracelet/fang v0.4.3 github.com/dgraph-io/badger/v4 v4.8.0 github.com/spf13/cobra v1.10.1 diff --git a/go.sum b/go.sum index 86eff6ab060a3d0a057b649bc4d7599f94f4c691..3ff4a4a749d8447b5402bc6b1fd2ad1c01e1a143 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/internal/cli/environment.go b/internal/cli/environment.go index c66bcdb0a63df523498bbe61eb114771abbdc308..bf06362e1899d7a54c24ca83846f3268ada81a2f 100644 --- a/internal/cli/environment.go +++ b/internal/cli/environment.go @@ -9,9 +9,11 @@ import ( "errors" "fmt" + "git.secluded.site/np/internal/config" "git.secluded.site/np/internal/db" "git.secluded.site/np/internal/event" "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/i18n" "git.secluded.site/np/internal/session" "git.secluded.site/np/internal/task" "git.secluded.site/np/internal/timeutil" @@ -21,6 +23,8 @@ import ( type Environment struct { DB *db.Database Clock timeutil.Clock + Config config.Config + Localizer *i18n.Localizer SessionStore *session.Store GoalStore *goal.Store TaskStore *task.Store @@ -28,7 +32,7 @@ type Environment struct { } // OpenEnvironment initialises storage and domain stores for CLI commands. -func OpenEnvironment(opts db.Options, clock timeutil.Clock) (*Environment, error) { +func OpenEnvironment(opts db.Options, clock timeutil.Clock, cfg config.Config, localizer *i18n.Localizer) (*Environment, error) { database, err := db.Open(opts) if err != nil { return nil, fmt.Errorf("cli: open database: %w", err) @@ -41,6 +45,8 @@ func OpenEnvironment(opts db.Options, clock timeutil.Clock) (*Environment, error env := &Environment{ DB: database, Clock: clock, + Config: cfg, + Localizer: localizer, SessionStore: session.NewStore(database, clock), GoalStore: goal.NewStore(database, clock), TaskStore: task.NewStore(database, clock), diff --git a/internal/cli/plan.go b/internal/cli/plan.go index a008243043690a7453bad91b4abd335581896c68..07858a57bfb3597c677a845941438fce42342522 100644 --- a/internal/cli/plan.go +++ b/internal/cli/plan.go @@ -11,6 +11,7 @@ import ( "strings" "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/i18n" "git.secluded.site/np/internal/task" ) @@ -66,7 +67,7 @@ func BuildPlanState(ctx context.Context, env *Environment, sid string) (PlanStat } // RenderPlan produces the textual plan layout consumed by LLM agents. -func RenderPlan(state PlanState) string { +func RenderPlan(state PlanState, localizer *i18n.Localizer) string { var b strings.Builder if state.Goal != nil { @@ -81,30 +82,33 @@ func RenderPlan(state PlanState) string { b.WriteString("\n") } } else { - b.WriteString("No goal set yet\n\n") + b.WriteString(localizer.T("plan.no_goal")) + b.WriteString("\n\n") } - b.WriteString(renderTaskList(state.Tasks)) + b.WriteString(renderTaskList(state.Tasks, localizer)) return b.String() } // RenderTasksOnly renders just the tasks without the goal header. -func RenderTasksOnly(tasks []task.Task) string { - return renderTaskList(tasks) +func RenderTasksOnly(tasks []task.Task, localizer *i18n.Localizer) string { + return renderTaskList(tasks, localizer) } -func renderTaskList(tasks []task.Task) string { +func renderTaskList(tasks []task.Task, localizer *i18n.Localizer) string { var b strings.Builder - legend := buildLegend(tasks) + legend := buildLegend(tasks, localizer) if legend != "" { - b.WriteString("Legend: ") + b.WriteString(localizer.T("plan.legend_label")) + b.WriteString(" ") b.WriteString(legend) b.WriteString("\n") } if len(tasks) == 0 { - b.WriteString("No tasks yet.\n") + b.WriteString(localizer.T("plan.no_tasks")) + b.WriteString("\n") return b.String() } @@ -140,7 +144,7 @@ func renderTaskList(tasks []task.Task) string { return b.String() } -func buildLegend(tasks []task.Task) string { +func buildLegend(tasks []task.Task, localizer *i18n.Localizer) string { present := map[task.Status]bool{} for _, t := range tasks { present[t.Status] = true @@ -148,22 +152,22 @@ func buildLegend(tasks []task.Task) string { var parts []string for _, status := range legendBaseOrder { - parts = append(parts, legendEntry(status)) + parts = append(parts, legendEntry(status, localizer)) } for _, status := range legendOptionalOrder { if present[status] { - parts = append(parts, legendEntry(status)) + parts = append(parts, legendEntry(status, localizer)) } } return strings.Join(parts, " ") } -func legendEntry(status task.Status) string { +func legendEntry(status task.Status, localizer *i18n.Localizer) string { icon := statusIcons[status] if icon == "" { icon = "?" } - return icon + " " + statusLabel(status) + return icon + " " + statusLabel(status, localizer) } func writeIndentedBlock(b *strings.Builder, text string, prefix string) { @@ -175,6 +179,7 @@ func writeIndentedBlock(b *strings.Builder, text string, prefix string) { } } -func statusLabel(status task.Status) string { - return strings.ReplaceAll(status.String(), "_", " ") +func statusLabel(status task.Status, localizer *i18n.Localizer) string { + key := "status." + status.String() + return localizer.T(key) } diff --git a/internal/cli/plan_test.go b/internal/cli/plan_test.go index 1e02da836cf894b4cb20cf13cef892a0ce349e9e..b0bf6748cca5825ae235f46cdb3d23b512c13f6a 100644 --- a/internal/cli/plan_test.go +++ b/internal/cli/plan_test.go @@ -11,10 +11,16 @@ import ( "git.secluded.site/np/internal/cli" "git.secluded.site/np/internal/goal" + "git.secluded.site/np/internal/i18n" "git.secluded.site/np/internal/task" ) func TestRenderPlanWithGoalAndTasks(t *testing.T) { + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + title := "Build reliable planning workflow" description := "Capture context from ticket and operator input.\nPrioritise determinism." goalDoc := goal.Document{ @@ -65,7 +71,7 @@ func TestRenderPlanWithGoalAndTasks(t *testing.T) { result := cli.RenderPlan(cli.PlanState{ Goal: &goalDoc, Tasks: tasks, - }) + }, localizer) expectedLegend := "Legend: ☐ pending ⟳ in progress ☑ completed ⊗ cancelled" if !strings.Contains(result, expectedLegend) { @@ -90,7 +96,12 @@ func TestRenderPlanWithGoalAndTasks(t *testing.T) { } func TestRenderPlanWithoutGoalOrTasks(t *testing.T) { - result := cli.RenderPlan(cli.PlanState{}) + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + + result := cli.RenderPlan(cli.PlanState{}, localizer) if !strings.Contains(result, "No goal set yet") { t.Fatalf("expected placeholder goal") @@ -99,3 +110,169 @@ func TestRenderPlanWithoutGoalOrTasks(t *testing.T) { t.Fatalf("expected placeholder task message") } } + +func TestRenderPlanGoalWithoutTasks(t *testing.T) { + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + + goalDoc := goal.Document{ + Title: "Standalone goal", + Description: "Goals can exist without tasks.", + UpdatedAt: time.Now(), + } + + result := cli.RenderPlan(cli.PlanState{ + Goal: &goalDoc, + }, localizer) + + if !strings.Contains(result, "Standalone goal") { + t.Fatalf("expected goal title in output") + } + if !strings.Contains(result, "No tasks yet.") { + t.Fatalf("expected placeholder task message") + } +} + +func TestRenderPlanTasksWithoutGoal(t *testing.T) { + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + + tasks := []task.Task{ + { + ID: "task1", + Title: "First task", + Status: task.StatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreatedSeq: 1, + }, + } + + result := cli.RenderPlan(cli.PlanState{ + Tasks: tasks, + }, localizer) + + if !strings.Contains(result, "No goal set yet") { + t.Fatalf("expected placeholder goal") + } + if !strings.Contains(result, "First task [task1]") { + t.Fatalf("expected task in output") + } +} + +func TestRenderPlanFailedTasksInLegend(t *testing.T) { + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + + tasks := []task.Task{ + { + ID: "task1", + Title: "Failed operation", + Status: task.StatusFailed, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreatedSeq: 1, + }, + { + ID: "task2", + Title: "Pending task", + Status: task.StatusPending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + CreatedSeq: 2, + }, + } + + result := cli.RenderPlan(cli.PlanState{ + Tasks: tasks, + }, localizer) + + expectedLegend := "Legend: ☐ pending ⟳ in progress ☑ completed ☒ failed" + if !strings.Contains(result, expectedLegend) { + t.Fatalf("expected legend with failed status:\n%s", result) + } + if strings.Contains(result, "⊗ cancelled") { + t.Fatalf("cancelled legend entry should not appear without cancelled tasks") + } +} + +func TestRenderPlanTaskSorting(t *testing.T) { + localizer, err := i18n.Load("en") + if err != nil { + t.Fatalf("failed to load localizer: %v", err) + } + + now := time.Now() + earlier := now.Add(-1 * time.Hour) + + tasks := []task.Task{ + { + ID: "zulu", + Title: "Third by ID", + Status: task.StatusPending, + CreatedAt: now, + UpdatedAt: now, + CreatedSeq: 2, + }, + { + ID: "alpha", + Title: "First by ID", + Status: task.StatusPending, + CreatedAt: now, + UpdatedAt: now, + CreatedSeq: 2, + }, + { + ID: "beta", + Title: "Second by ID", + Status: task.StatusPending, + CreatedAt: now, + UpdatedAt: now, + CreatedSeq: 2, + }, + { + ID: "task1", + Title: "Earlier seq", + Status: task.StatusCompleted, + CreatedAt: earlier, + UpdatedAt: now, + CreatedSeq: 1, + }, + } + + result := cli.RenderPlan(cli.PlanState{ + Tasks: tasks, + }, localizer) + + lines := strings.Split(result, "\n") + var taskLines []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "☐") || strings.HasPrefix(trimmed, "☑") { + taskLines = append(taskLines, trimmed) + } + } + + if len(taskLines) != 4 { + t.Fatalf("expected 4 task lines, got %d", len(taskLines)) + } + + if !strings.Contains(taskLines[0], "[task1]") { + t.Fatalf("first task should be task1 (seq=1), got: %s", taskLines[0]) + } + if !strings.Contains(taskLines[1], "[alpha]") { + t.Fatalf("second task should be alpha (seq=2, sorted by ID), got: %s", taskLines[1]) + } + if !strings.Contains(taskLines[2], "[beta]") { + t.Fatalf("third task should be beta (seq=2, sorted by ID), got: %s", taskLines[2]) + } + if !strings.Contains(taskLines[3], "[zulu]") { + t.Fatalf("fourth task should be zulu (seq=2, sorted by ID), got: %s", taskLines[3]) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..0c0c6e2d45dea46b8d5caa47680f6ee3172ecad5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/i18n" + "github.com/BurntSushi/toml" +) + +const ( + configFileName = "config.toml" +) + +// Config captures user configuration for the CLI. +type Config struct { + UI UIConfig `toml:"ui"` +} + +// UIConfig configures user-facing behaviour. +type UIConfig struct { + Language string `toml:"language"` +} + +// Path resolves the configuration file location. +func Path() (string, error) { + dbPath, err := db.DefaultPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(dbPath), configFileName), nil +} + +// Load attempts to read configuration from disk. Missing files fall back to +// defaults without error. +func Load() (Config, error) { + cfg := Config{ + UI: UIConfig{ + Language: i18n.DefaultLanguage, + }, + } + + path, err := Path() + if err != nil { + return cfg, err + } + + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cfg, nil + } + return cfg, fmt.Errorf("config: read %q: %w", path, err) + } + + if err := toml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("config: parse %q: %w", path, err) + } + + cfg.UI.Language = normaliseLanguage(cfg.UI.Language) + return cfg, nil +} + +func normaliseLanguage(lang string) string { + lang = strings.TrimSpace(strings.ToLower(lang)) + if lang == "" { + return i18n.DefaultLanguage + } + return lang +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5ef708e0e1eb52851089a3b6628fb66d112436c6 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package config + +import ( + "os" + "path/filepath" + "testing" + + "git.secluded.site/np/internal/i18n" +) + +func TestLoadDefaults(t *testing.T) { + // Create a temporary directory that won't have a config file + tmpDir := t.TempDir() + originalDBPath := os.Getenv("XDG_CONFIG_HOME") + t.Cleanup(func() { + if originalDBPath != "" { + _ = os.Setenv("XDG_CONFIG_HOME", originalDBPath) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + }) + if err := os.Setenv("XDG_CONFIG_HOME", tmpDir); err != nil { + t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.UI.Language != i18n.DefaultLanguage { + t.Errorf("expected default language %q, got %q", i18n.DefaultLanguage, cfg.UI.Language) + } +} + +func TestLoadValidTOML(t *testing.T) { + tmpDir := t.TempDir() + originalDBPath := os.Getenv("XDG_CONFIG_HOME") + t.Cleanup(func() { + if originalDBPath != "" { + _ = os.Setenv("XDG_CONFIG_HOME", originalDBPath) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + }) + if err := os.Setenv("XDG_CONFIG_HOME", tmpDir); err != nil { + t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err) + } + + cfgPath, err := Path() + if err != nil { + t.Fatalf("Path() failed: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + content := `[ui] +language = "tok" +` + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.UI.Language != "tok" { + t.Errorf("expected language %q, got %q", "tok", cfg.UI.Language) + } +} + +func TestLoadNormalisation(t *testing.T) { + tmpDir := t.TempDir() + originalDBPath := os.Getenv("XDG_CONFIG_HOME") + t.Cleanup(func() { + if originalDBPath != "" { + _ = os.Setenv("XDG_CONFIG_HOME", originalDBPath) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + }) + if err := os.Setenv("XDG_CONFIG_HOME", tmpDir); err != nil { + t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err) + } + + cfgPath, err := Path() + if err != nil { + t.Fatalf("Path() failed: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + tests := []struct { + name string + input string + expected string + }{ + {"uppercase", "EN", "en"}, + {"mixed case", "En-US", "en-us"}, + {"whitespace", " tok ", "tok"}, + {"empty", "", i18n.DefaultLanguage}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content := "[ui]\nlanguage = \"" + tt.input + "\"\n" + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() failed: %v", err) + } + + if cfg.UI.Language != tt.expected { + t.Errorf("expected language %q, got %q", tt.expected, cfg.UI.Language) + } + }) + } +} + +func TestLoadInvalidTOML(t *testing.T) { + tmpDir := t.TempDir() + originalDBPath := os.Getenv("XDG_CONFIG_HOME") + t.Cleanup(func() { + if originalDBPath != "" { + _ = os.Setenv("XDG_CONFIG_HOME", originalDBPath) + } else { + _ = os.Unsetenv("XDG_CONFIG_HOME") + } + }) + if err := os.Setenv("XDG_CONFIG_HOME", tmpDir); err != nil { + t.Fatalf("failed to set XDG_CONFIG_HOME: %v", err) + } + + cfgPath, err := Path() + if err != nil { + t.Fatalf("Path() failed: %v", err) + } + + if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + content := `[ui +language = "tok" +` + if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + + _, err = Load() + if err == nil { + t.Fatal("expected error for invalid TOML, got nil") + } +} diff --git a/internal/i18n/locales/en.toml b/internal/i18n/locales/en.toml new file mode 100644 index 0000000000000000000000000000000000000000..53c2e20b40ba6d703b98e96148a3396984b3c901 --- /dev/null +++ b/internal/i18n/locales/en.toml @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: CC0-1.0 + +# plan rendering +[plan] +no_goal = "No goal set yet" +legend_label = "Legend:" +no_tasks = "No tasks yet." + +# status labels +[status] +pending = "pending" +in_progress = "in progress" +completed = "completed" +failed = "failed" +cancelled = "cancelled" + +# shared helpers +[session] +none_active = "No active session. Start one with `np s`." + +# start command (cmd/s.go) +[session.start] +already_active = "Session %s is already active for %s.\n" +already_active_guidance = "Ask your operator whether they want to resume (`np r`) or archive (`np a`) it." +now_active = "Session %s is now active for %s.\n\n" +guidance = """If you haven't already, read any provided issue/ticket/file/webpage/commit/etc. references and thoroughly mull them over. If you already see the contents above, don't re-read them. If there were no referenced files or you don't have a clear picture of the issue, you may selectively read additional relevant files until you do have a clear picture. +Set the goal with `np g s -t "goal title" -d "goal description"`. +Capture the most concise form of the overarching goal using no more than 20 words in the title. Elaborate _usefully_ in the description; don't just repeat the title in more flowery language. In case we're interrupted and need to pick up from this plan later, include: +- Summaries of only the relevant portions of the referenced content. Include their URLs/IDs/hashes. Copy the user's language around the references: if they say 'look at bug REF', use 'Bug: REF' near the summary. If 'issue NUM', then 'Issue: NUM.' +- Paths to the relevant files, and if there are particularly relevant symbols from those files, include them too. DO NOT summarise the files or symbols. Only list them if they're relevant +- An 'Immediate thoughts:' line at the bottom. You should have enough files to have some idea of the issue and its resolution; briefly capture your immediate thoughts in this line, couching with appropriate uncertainty. +Add single tasks with `np t a -t "task" -d "details"`, but prefer batching. For multi-line descriptions, use literal newlines: + np t a -t "first task" -d "step 1 details" -t "second task" -d "step 2 with + more details" -t "third task" -d "step three" +Keep task statuses up to date as you work: + Single update: `np t u -i task-id -s in_progress|completed|failed|cancelled` + But prefer batching: `np t u -i abc123 -s completed -i def456 -s in_progress` +Use `np p` if you need to review the full plan.""" + +# resume command (cmd/r.go) +[session.resume] +header = "\nResuming session. To continue:" +guidance = """1. Thoroughly consider the goal and its description. +2. Read the referenced files and symbols, especially in the pending tasks, to understand what work remains. +3. Add more tasks if needed. For multi-line descriptions, use literal newlines: + # Single + np t a -t "task title" -d "details" + + # Batch (preferred for multiple additions) + np t a -t "first" -d "step 1 details" -t "second" -d "step 2 with + more details" -t "third" -d "step three"` +4. Update task status as you work: + Single: `np t u -i -s ` + Batch: `np t u -i -s -i -s ` + Statuses: pending, in_progress, completed, failed, cancelled""" +in_progress_count = "\n%d task(s) are in progress.\n" +pending_count = "%d task(s) are pending.\n" + +# archive command (cmd/a.go) +[session.archive] +success = "Session %s archived.\n" +none_active = "No active session. Start one with `np s`." + +# goal set command (cmd/g/s.go) +[goal.set] +title_required = "Goal title is required." +description_required = "Goal description is required." +already_set = "Goal already set. Use 'np g u' to update it (requires -r/--reason flag)." +guidance = "Study everything above carefully, the reference content, the source code, the documentation, etc. Once you've a solid understanding of how to approach resolving the request, fill out your task list. Prefer adding/updating in batch." + +# goal update command (cmd/g/u.go) +[goal.update] +not_set = "No goal set yet. Use 'np g s' first." +reason_required = "Reason is required for goal updates." +title_empty = "Goal title cannot be empty." +no_changes_provided = "Provide at least one of --title or --description to update the goal." +no_changes_made = "Goal already matches the provided values; no changes made." +guidance = "Goal updated. Ensure pending tasks still align with the goal and adjust them and/or add new tasks if necessary." + +# task add command (cmd/t/a.go) +[task.add] +count_mismatch = "Number of titles and descriptions must match." +none_provided = "Provide at least one task with -t title -d description." +title_empty_at = "Task title at position %d cannot be empty.\n" +title_empty = "Task title cannot be empty." +success = "Added %d task(s).\n" +guidance = """Update task statuses as you work: + Single: `np t u -i task-id -s in_progress|completed|failed|cancelled`. + Batch: `np t u -i abc123 -s completed -i def456 -s in_progress`.""" + +# task list command (cmd/t/t.go) +[task.list] +invalid_status = "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled, all.\n" + +# task update command (cmd/t/u.go) +[task.update] +no_ids = "Provide at least one task ID with -i/--id." +title_desc_single_only = "Title and description updates are only supported for single task updates." +status_count_mismatch = "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags." +invalid_status = "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n" +reason_required_status = "Reason (-r/--reason) is required when changing status to cancelled or failed." +not_found = "Task %q not found in current session.\n" +no_updates = "No tasks were updated (all tasks either not found or already at target status)." +success = "Updated %d task(s).\n" +none_pending_archive = "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`)." +continue_working = "Continue working through remaining tasks. Use `np t u -i -s -i -s ` to update multiple tasks at once." +title_empty = "Task title cannot be empty." +invalid_status_single = "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n" +no_changes_provided = "Provide at least one of --title, --description, or --status to update the task." +reason_required = "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed." +completed_continue = "Task marked completed. Continue working through remaining tasks. Use `np t u -i -s -s -s ` to update multiple at once." +updated_review = "Task updated. Use `np p` to review the full plan." diff --git a/internal/i18n/locales/tok.toml b/internal/i18n/locales/tok.toml new file mode 100644 index 0000000000000000000000000000000000000000..5c7fb4ef28ad53a102c7144e36e0b3a3a17ec00a --- /dev/null +++ b/internal/i18n/locales/tok.toml @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Amolith +# +# SPDX-License-Identifier: CC0-1.0 + +# plan rendering +[plan] +no_goal = "No goal set yet" +legend_label = "Legend:" +no_tasks = "No tasks yet." + +# status labels +[status] +pending = "pending" +in_progress = "in progress" +completed = "completed" +failed = "failed" +cancelled = "cancelled" + +# shared helpers +[session] +none_active = "No active session. Start one with `np s`." + +# start command (cmd/s.go) +[session.start] +already_active = "Session %s is already active for %s.\n" +already_active_guidance = "Ask your operator whether they want to resume (`np r`) or archive (`np a`) it." +now_active = "Session %s is now active for %s.\n\n" +guidance = """If you haven't already, read any provided issue/ticket/file/webpage/commit/etc. references and thoroughly mull them over. If you already see the contents above, don't re-read them. If there were no referenced files or you don't have a clear picture of the issue, you may selectively read additional relevant files until you do have a clear picture. +Set the goal with `np g s -t "goal title" -d "goal description"`. +Capture the most concise form of the overarching goal using no more than 20 words in the title. Elaborate _usefully_ in the description; don't just repeat the title in more flowery language. In case we're interrupted and need to pick up from this plan later, include: +- Summaries of only the relevant portions of the referenced content. Include their URLs/IDs/hashes. Copy the user's language around the references: if they say 'look at bug REF', use 'Bug: REF' near the summary. If 'issue NUM', then 'Issue: NUM.' +- Paths to the relevant files, and if there are particularly relevant symbols from those files, include them too. DO NOT summarise the files or symbols. Only list them if they're relevant +- An 'Immediate thoughts:' line at the bottom. You should have enough files to have some idea of the issue and its resolution; briefly capture your immediate thoughts in this line, couching with appropriate uncertainty. +Add single tasks with `np t a -t "task" -d "details"`, but prefer batching. For multi-line descriptions, use literal newlines: + np t a -t "first task" -d "step 1 details" -t "second task" -d "step 2 with + more details" -t "third task" -d "step three" +Keep task statuses up to date as you work: + Single update: `np t u -i task-id -s in_progress|completed|failed|cancelled` + But prefer batching: `np t u -i abc123 -s completed -i def456 -s in_progress` +Use `np p` if you need to review the full plan.""" + +# resume command (cmd/r.go) +[session.resume] +header = "\nResuming session. To continue:" +guidance = """1. Thoroughly consider the goal and its description. +2. Read the referenced files and symbols, especially in the pending tasks, to understand what work remains. +3. Add more tasks if needed. For multi-line descriptions, use literal newlines: + # Single + np t a -t "task title" -d "details" + + # Batch (preferred for multiple additions) + np t a -t "first" -d "step 1 details" -t "second" -d "step 2 with + more details" -t "third" -d "step three"` +4. Update task status as you work: + Single: `np t u -i -s ` + Batch: `np t u -i -s -i -s ` + Statuses: pending, in_progress, completed, failed, cancelled""" +in_progress_count = "\n%d task(s) are in progress.\n" +pending_count = "%d task(s) are pending.\n" + +# archive command (cmd/a.go) +[session.archive] +success = "Session %s archived.\n" +none_active = "No active session. Start one with `np s`." + +# goal set command (cmd/g/s.go) +[goal.set] +title_required = "Goal title is required." +description_required = "Goal description is required." +already_set = "Goal already set. Use 'np g u' to update it (requires -r/--reason flag)." +guidance = "Study everything above carefully, the reference content, the source code, the documentation, etc. Once you've a solid understanding of how to approach resolving the request, fill out your task list. Prefer adding/updating in batch." + +# goal update command (cmd/g/u.go) +[goal.update] +not_set = "No goal set yet. Use 'np g s' first." +reason_required = "Reason is required for goal updates." +title_empty = "Goal title cannot be empty." +no_changes_provided = "Provide at least one of --title or --description to update the goal." +no_changes_made = "Goal already matches the provided values; no changes made." +guidance = "Goal updated. Ensure pending tasks still align with the goal and adjust them and/or add new tasks if necessary." + +# task add command (cmd/t/a.go) +[task.add] +count_mismatch = "Number of titles and descriptions must match." +none_provided = "Provide at least one task with -t title -d description." +title_empty_at = "Task title at position %d cannot be empty.\n" +title_empty = "Task title cannot be empty." +success = "Added %d task(s).\n" +guidance = """Update task statuses as you work: + Single: `np t u -i task-id -s in_progress|completed|failed|cancelled`. + Batch: `np t u -i abc123 -s completed -i def456 -s in_progress`.""" + +# task list command (cmd/t/t.go) +[task.list] +invalid_status = "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled, all.\n" + +# task update command (cmd/t/u.go) +[task.update] +no_ids = "Provide at least one task ID with -i/--id." +title_desc_single_only = "Title and description updates are only supported for single task updates." +status_count_mismatch = "Multiple task IDs provided but status count doesn't match. For batch updates, provide equal -i and -s flags." +invalid_status = "Invalid status %q for task %s. Valid: pending, in_progress, completed, failed, cancelled.\n" +reason_required_status = "Reason (-r/--reason) is required when changing status to cancelled or failed." +not_found = "Task %q not found in current session.\n" +no_updates = "No tasks were updated (all tasks either not found or already at target status)." +success = "Updated %d task(s).\n" +none_pending_archive = "No pending tasks remaining. If you've completed the goal, escalate to the operator and ask whether to archive the session (`np a`)." +continue_working = "Continue working through remaining tasks. Use `np t u -i -s -i -s ` to update multiple tasks at once." +title_empty = "Task title cannot be empty." +invalid_status_single = "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n" +no_changes_provided = "Provide at least one of --title, --description, or --status to update the task." +reason_required = "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed." +completed_continue = "Task marked completed. Continue working through remaining tasks. Use `np t u -i -s -i -s ` to update multiple at once." +updated_review = "Task updated. Use `np p` to review the full plan." diff --git a/internal/i18n/localizer.go b/internal/i18n/localizer.go new file mode 100644 index 0000000000000000000000000000000000000000..3be54579c9cfb6d6422db41ae35b7f31c5d6c6d5 --- /dev/null +++ b/internal/i18n/localizer.go @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package i18n + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "strings" + + "github.com/BurntSushi/toml" +) + +const ( + // DefaultLanguage is the fallback locale when no other language is available. + DefaultLanguage = "en" +) + +var ( + //go:embed locales/*.toml + localeFiles embed.FS + + languageAliases = map[string]string{ + "en": "en", + "eng": "en", + "tok": "tok", + "tp": "tok", + } +) + +// Localizer provides translated strings with an English fallback. +type Localizer struct { + language string + entries map[string]string + fallback map[string]string +} + +// Language reports the canonical language code for the localizer. +func (l *Localizer) Language() string { + if l == nil || l.language == "" { + return DefaultLanguage + } + return l.language +} + +// T returns the translated string for key, formatting with args when provided. +// When the key does not exist in the active catalog, the English fallback is +// used. Missing keys return the key itself as a last resort. +func (l *Localizer) T(key string, args ...any) string { + if key == "" { + return "" + } + + template := "" + if l != nil { + if value, ok := l.entries[key]; ok && value != "" { + template = value + } else if value, ok := l.fallback[key]; ok && value != "" { + template = value + } + } + if template == "" { + template = key + } + if len(args) == 0 { + return template + } + return fmt.Sprintf(template, args...) +} + +// Load constructs a Localizer for language, falling back to English when the +// requested catalog is unavailable. +func Load(language string) (*Localizer, error) { + lang := canonicalLanguage(language) + + fallback, err := loadCatalog(DefaultLanguage) + if err != nil { + return nil, fmt.Errorf("i18n: load fallback %q: %w", DefaultLanguage, err) + } + + var entries map[string]string + if lang == DefaultLanguage { + entries = cloneCatalog(fallback) + } else { + entries, err = loadCatalog(lang) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("i18n: load %q: %w", lang, err) + } + entries = map[string]string{} + lang = DefaultLanguage + } + } + + return &Localizer{ + language: lang, + entries: entries, + fallback: fallback, + }, nil +} + +func canonicalLanguage(input string) string { + code := strings.TrimSpace(strings.ToLower(input)) + if code == "" { + return DefaultLanguage + } + if canonical, ok := languageAliases[code]; ok { + return canonical + } + return code +} + +func loadCatalog(language string) (map[string]string, error) { + filename := fmt.Sprintf("locales/%s.toml", language) + data, err := localeFiles.ReadFile(filename) + if err != nil { + return nil, err + } + return parseCatalog(data) +} + +func parseCatalog(data []byte) (map[string]string, error) { + var raw map[string]any + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse toml: %w", err) + } + return flattenToml(raw, ""), nil +} + +// flattenToml recursively flattens nested TOML structures into dot-notation keys. +func flattenToml(data map[string]any, prefix string) map[string]string { + result := make(map[string]string) + for key, value := range data { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case string: + result[fullKey] = v + case map[string]any: + nested := flattenToml(v, fullKey) + for k, val := range nested { + result[k] = val + } + default: + // Convert other types to strings + result[fullKey] = fmt.Sprint(v) + } + } + return result +} + +func cloneCatalog(src map[string]string) map[string]string { + clone := make(map[string]string, len(src)) + for k, v := range src { + clone[k] = v + } + return clone +}