Detailed changes
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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 <task-id> -s <status>`")
- _, _ = fmt.Fprintln(cmd.OutOrStdout(), " Batch: `np t u -i <id1> -s <status1> -i <id2> -s <status2>`")
- _, _ = 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
@@ -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
@@ -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"))
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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
@@ -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=
@@ -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),
@@ -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)
}
@@ -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])
+ }
+}
@@ -0,0 +1,79 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}
@@ -0,0 +1,167 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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")
+ }
+}
@@ -0,0 +1,114 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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 <task-id> -s <status>`
+ Batch: `np t u -i <id1> -s <status1> -i <id2> -s <status2>`
+ 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 <id1> -s <status1> -i <id2> -s <status2>` 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 <id1> -s <status1> -s <id2> -s <status2>` to update multiple at once."
+updated_review = "Task updated. Use `np p` to review the full plan."
@@ -0,0 +1,114 @@
+# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+#
+# 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 <task-id> -s <status>`
+ Batch: `np t u -i <id1> -s <status1> -i <id2> -s <status2>`
+ 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 <id1> -s <status1> -i <id2> -s <status2>` 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 <id1> -s <status1> -i <id2> -s <status2>` to update multiple at once."
+updated_review = "Task updated. Use `np p` to review the full plan."
@@ -0,0 +1,164 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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
+}