1// Package config is 012's settings file: a Ghostty-style list of2// "key = value" lines in <user config dir>/012/config, with # comments and3// config-file includes. One registry (Options) says what every option is4// called, its type, default, environment variable, flag and meaning; the5// parser, `012 config`, docs/reference/config.md and the in-app Settings all come6// from it.7//8// Settings are process-wide. Settings that belong to a workbook (decimal9// arithmetic, its locale) stay in the workbook's file; the locale option is10// only the default. Secrets never go here: the JEV11// API key lives in the OS credential store (see internal/keyring).12package config1314import (15 "errors"16 "fmt"17 "net"18 "net/url"19 "strconv"20 "strings"21 "time"2223 "src.rest/cat/012/internal/locale"24)2526// Kind is an option's type.27type Kind int2829const (30 String Kind = iota31 Bool32 Enum // one of Option.Values33 Path // a file path; ~ is the home directory34 URL35 Command // a program and its arguments, run without a shell36 Int // a whole number, at least 137 Duration // e.g. 30m or 1h30m; 0 for never38 Address // host:port39)4041func (k Kind) String() string {42 return [...]string{"text", "true or false", "one of", "path", "URL", "command", "number", "duration", "host:port"}[k]43}4445// Option is one setting.46type Option struct {47 Name string48 Kind Kind49 Group string // heading in docs/reference/config.md and `012 config`50 Default string // as it would be written in the file51 Values []string // for Enum52 Env []string // environment variables that set it, first set wins53 // Fallback are environment variables read, first set wins, when54 // nothing else sets the option, through Adopt, which turns their55 // value into one of the option's or "" to pass over it quietly (LANG56 // may name a locale 012 lacks).57 Fallback []string58 Adopt func(string) string59 Flag string // command-line flag, e.g. "--log"60 Repeat bool // may be given more than once; each adds a value61 Live bool // Reload config applies it without restarting62 Desc string // what it does, one or more sentences63 // Check validates a value beyond its kind. Invalid values are64 // reported as warnings and the option keeps its previous value.65 Check func(string) error66 // Redact shows a value safely in `012 config`, e.g. without a URL's67 // password.68 Redact func(string) string69}7071// Group names, in the order docs list them.72const (73 GroupAppearance = "Appearance"74 GroupJEV = "JEV functions"75 GroupTelemetry = "Telemetry"76 GroupServe = "012 serve"77 GroupData = "Data"78 GroupShell = "Nushell notebooks"79 GroupFiles = "Config files"80)8182// Groups is the order groups are listed in.83var Groups = []string{GroupAppearance, GroupData, GroupShell, GroupJEV, GroupTelemetry, GroupServe, GroupFiles}8485// Options is every setting. Add an option here and read it with86// Config.String, Bool or List; parsing, `012 config`, docs/reference/config.md87// (go test ./internal/config -update-docs) and Settings follow.88var Options = []Option{89 {Name: "theme", Group: GroupAppearance, Default: "terminal", Env: []string{"O12_THEME"}, Flag: "--theme", Live: true,90 Desc: "Colors. `terminal` uses the terminal's own 16-color palette. `high-contrast` draws white on " +91 "black or black on white by the terminal's background, with WCAG AAA contrast. Any other name is a " +92 "terminal color scheme, built in (`012 config themes` lists them) or a file in the themes " +93 "directory, drawn in its own colors with solid menu and status bars. " +94 "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",95 Check: checkTheme},96 {Name: "chart-images", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_CHART_IMAGES"}, Live: true,97 Desc: "Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm) or, " +98 "outside tmux, sixel graphics (foot, xterm, mlterm, Windows Terminal). When false, charts are always text."},99 {Name: "notifications", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_NOTIFICATIONS"}, Live: true,100 Desc: "Send a desktop notification (OSC 9) when JEV answers or an import finishes while the " +101 "terminal window is in the background."},102 {Name: "keymap", Kind: Enum, Group: GroupAppearance, Default: "default", Values: []string{"default", "vim"}, Env: []string{"O12_KEYMAP"}, Live: true,103 Desc: "Keys in the grid. `default` works like Google Sheets; `vim` adds hjkl, counts, operators, " +104 "visual selection and a : command line (File > Settings > Vim keys)."},105106 {Name: "locale", Kind: Enum, Group: GroupData, Default: "en-US", Values: locale.Tags(), Env: []string{"O12_LOCALE"},107 Fallback: []string{"LC_ALL", "LC_NUMERIC", "LANG"}, Adopt: locale.FromPOSIX, Live: true,108 Desc: "How new sheets and files without a locale of their own are typed and shown: decimal and " +109 "thousands separators, date order, the currency symbol and the formula argument separator " +110 "(`;` where the decimal separator is a comma), as in Sheets' File > Settings > Locale, " +111 "which sets a file's own. Files store the same thing in every locale. " +112 "When unset, the POSIX locale (`LC_ALL`, `LC_NUMERIC`, then `LANG`, e.g. `de_DE.UTF-8`) picks it if it's one of these."},113114 {Name: "max-cells", Kind: Int, Group: GroupData, Default: "10000000", Env: []string{"O12_MAX_CELLS"}, Live: true,115 Desc: "The most cells an import keeps, and a paste or fill writes at once. Numbers and text " +116 "take 20 to 60 bytes a cell and formulas about 750, so the default of ten million cells of " +117 "data is 200 to 600 MB. Imports keep whole rows " +118 "up to the budget and say how many they left out; larger pastes and fills are refused. " +119 "A function that holds what it reads (SORT, TEXTJOIN) is given no more of a linked source. " +120 "The grid itself is 1,048,576 rows by 16,384 columns (A to XFD) whatever this is."},121122 {Name: "shell", Kind: Enum, Group: GroupShell, Default: "ask", Values: []string{"off", "ask", "on"}, Env: []string{"O12_SHELL"}, Live: true,123 Desc: "Whether notebooks run their code cells (docs/nushell/notebooks.md). `ask` runs what you write " +124 "and asks once before running the cells of a file made on another computer; `on` never asks; " +125 "`off` runs none. Opening a file never runs its cells."},126 {Name: "nu-timeout", Kind: Duration, Group: GroupShell, Default: "30s", Env: []string{"O12_NU_TIMEOUT"}, Live: true,127 Desc: "Stop a notebook cell that runs longer than this; 0 lets it run until Stop (i i) stops it."},128 {Name: "nu-config", Kind: Bool, Group: GroupShell, Default: "false", Env: []string{"O12_NU_CONFIG"}, Live: true,129 Desc: "Run notebook cells with your nushell config files (config.nu, env.nu) rather than " +130 "`nu --no-config-file`, for your own commands and aliases."},131 {Name: "nu-save-cell-kb", Kind: Int, Group: GroupShell, Default: "1024", Env: []string{"O12_NU_SAVE_CELL_KB"}, Live: true,132 Desc: "The largest output of one cell a saved file keeps, in kilobytes of NUON; a larger one is left out " +133 "and shows `not saved; run to see` when the file is opened."},134 {Name: "nu-save-notebook-kb", Kind: Int, Group: GroupShell, Default: "8192", Env: []string{"O12_NU_SAVE_NOTEBOOK_KB"}, Live: true,135 Desc: "How much of all its cells' outputs a saved file keeps, in kilobytes of NUON: the outputs that fit, " +136 "in the notebook's order."},137138 {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,139 Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +140 "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +141 "`pass show typesafe`. It runs the first time a sheet asks JEV something, without a shell, for up to 10 seconds, and its first line of output is the key; for pipes, write " +142 "`sh -c '...'` yourself. The key itself never goes in this file."},143 {Name: "jev-credential-store", Kind: Bool, Group: GroupJEV, Default: "true", Env: []string{"O12_JEV_CREDENTIAL_STORE"},144 Desc: "Look for the API key in the OS credential store (macOS Keychain, Windows Credential Manager, " +145 "the Secret Service on Linux), where `012 config set-key` and Settings put it."},146 {Name: "jev-base-url", Kind: URL, Group: GroupJEV, Env: []string{"TYPESAFE_BASE_URL"},147 Desc: "The TypeSafe service to ask; the SDK's default when empty. Must be https, except on localhost.",148 Check: CheckBaseURL, Redact: redactURL},149 {Name: "jev-model", Group: GroupJEV, Env: []string{"TYPESAFE_DEFAULT_MODEL"},150 Desc: "The JEV model to ask; the service's default when empty."},151152 {Name: "log-file", Kind: Path, Group: GroupTelemetry, Env: []string{"O12_LOG"}, Flag: "--log",153 Desc: "Append telemetry events to this JSON log file. See docs/contributing/observability.md."},154 {Name: "log-level", Kind: Enum, Group: GroupTelemetry, Default: "info", Values: []string{"debug", "info", "warn", "error"},155 Env: []string{"O12_LOG_LEVEL"},156 Desc: "The least severe telemetry event recorded; debug adds every frame and command."},157 {Name: "otlp-endpoint", Kind: URL, Group: GroupTelemetry, Env: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Flag: "--otlp",158 Desc: "Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. " +159 "The other OTEL_* variables still apply.",160 Redact: redactURL},161162 {Name: "serve-listen", Kind: Address, Group: GroupServe, Default: "127.0.0.1:2312",163 Desc: "The address 012 serve listens on. Anything but the loopback address lets other machines " +164 "reach it (with an authorized key). See docs/terminal/ssh.md."},165 {Name: "serve-authorized-keys", Kind: Path, Group: GroupServe, Default: "~/.ssh/authorized_keys",166 Desc: "The public keys allowed to log in to 012 serve, in OpenSSH's authorized_keys format."},167 {Name: "serve-host-key", Kind: Path, Group: GroupServe,168 Desc: "012 serve's private host key, generated when missing; " +169 "ssh_host_ed25519_key in the config directory when empty."},170 {Name: "serve-idle-timeout", Kind: Duration, Group: GroupServe, Default: "30m",171 Desc: "End a 012 serve session that has had no input for this long; 0 never does."},172 {Name: "serve-max-sessions", Kind: Int, Group: GroupServe, Default: "8",173 Desc: "How many 012 serve sessions may run at once; more are turned away."},174 {Name: "serve-share", Kind: Enum, Group: GroupServe, Default: "edit", Values: []string{"edit", "view", "off"},175 Desc: "Whether 012 serve sessions opening the same file share its workbook: edit, everyone edits; " +176 "view, one writes and the others follow until writing is handed over; off, each session has its own copy."},177 {Name: "serve-shell", Kind: Bool, Group: GroupServe, Default: "false",178 Desc: "Let 012 serve sessions run notebooks' code cells, as the user 012 serve runs as, " +179 "following the shell option. Off, served notebooks show their cells and saved outputs but run nothing."},180181 {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,182 Desc: "Read another config file after this one, relative to this file's directory. " +183 "A leading `?` makes it optional: no warning when it doesn't exist."},184}185186// byName indexes Options.187var byName = func() map[string]*Option {188 m := make(map[string]*Option, len(Options))189 for i := range Options {190 m[Options[i].Name] = &Options[i]191 }192 return m193}()194195// Lookup returns the option called name.196func Lookup(name string) (*Option, bool) {197 o, ok := byName[name]198 return o, ok199}200201// validate checks a value against the option's kind and Check.202func (o *Option) validate(v string) error {203 if v == "" {204 return nil // empty resets to the default205 }206 switch o.Kind {207 case Bool:208 if _, err := strconv.ParseBool(v); err != nil {209 return fmt.Errorf("%q isn't true or false", v)210 }211 case Enum:212 if !contains(o.Values, v) {213 return fmt.Errorf("%q isn't one of %s", v, strings.Join(o.Values, ", "))214 }215 case URL:216 withScheme := v217 if !strings.Contains(v, "://") {218 withScheme = "http://" + v // host:port, as OTEL_EXPORTER_OTLP_ENDPOINT allows219 }220 u, err := url.Parse(withScheme)221 if err != nil || u.Host == "" {222 return fmt.Errorf("%q isn't a URL", v)223 }224 case Command:225 if _, err := SplitCommand(v); err != nil {226 return err227 }228 case Int:229 if n, err := strconv.Atoi(v); err != nil || n < 1 {230 return fmt.Errorf("%q isn't a whole number of at least 1", v)231 }232 case Duration:233 if d, err := time.ParseDuration(v); err != nil || d < 0 {234 return fmt.Errorf("%q isn't a duration like 30m or 1h", v)235 }236 case Address:237 if _, _, err := net.SplitHostPort(v); err != nil {238 return fmt.Errorf("%q isn't host:port", v)239 }240 }241 if o.Check != nil {242 return o.Check(v)243 }244 return nil245}246247func contains(list []string, s string) bool {248 for _, v := range list {249 if v == s {250 return true251 }252 }253 return false254}255256// CheckBaseURL accepts https URLs, and http only for this machine, so a257// config can't send the API key anywhere in the clear.258func CheckBaseURL(v string) error {259 u, err := url.Parse(v)260 if err != nil || u.Host == "" {261 return fmt.Errorf("%q isn't a URL", v)262 }263 switch {264 case u.Scheme == "https":265 return nil266 case u.Scheme == "http" && IsLocalhost(u.Hostname()):267 return nil268 }269 return errors.New("the JEV base URL must be https (http only for localhost)")270}271272// IsLocalhost reports whether host names this machine.273func IsLocalhost(host string) bool {274 if host == "localhost" || strings.HasSuffix(host, ".localhost") {275 return true276 }277 ip := net.ParseIP(host)278 return ip != nil && ip.IsLoopback()279}280281// checkTheme checks the shape of a theme value: a name, or light: and282// dark: names. Whether the names exist is known only when it's loaded.283func checkTheme(v string) error {284 _, err := ParseThemeChoice(v)285 return err286}287288// ThemeChoice is the theme value: one theme, or one for each kind of289// terminal background.290type ThemeChoice struct{ Light, Dark string }291292// Pick returns the theme for a dark or light terminal.293func (c ThemeChoice) Pick(dark bool) string {294 if dark {295 return c.Dark296 }297 return c.Light298}299300// ParseThemeChoice parses "name" or "light:A,dark:B" (either order).301func ParseThemeChoice(v string) (ThemeChoice, error) {302 v = strings.TrimSpace(v)303 if v == "" {304 v = "terminal"305 }306 if !strings.Contains(v, "light:") && !strings.Contains(v, "dark:") {307 return ThemeChoice{Light: v, Dark: v}, nil308 }309 var c ThemeChoice310 for part := range strings.SplitSeq(v, ",") {311 k, name, ok := strings.Cut(strings.TrimSpace(part), ":")312 name = strings.TrimSpace(name)313 switch {314 case !ok || name == "":315 return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)316 case k == "light":317 c.Light = name318 case k == "dark":319 c.Dark = name320 default:321 return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)322 }323 }324 if c.Light == "" || c.Dark == "" {325 return c, fmt.Errorf("theme %q: give both light: and dark:", v)326 }327 return c, nil328}329330// String is the choice as written in the file.331func (c ThemeChoice) String() string {332 if c.Light == c.Dark {333 return c.Light334 }335 return "light:" + c.Light + ",dark:" + c.Dark336}337338// redactURL hides a URL's password and query, which can carry tokens.339func redactURL(v string) string {340 u, err := url.Parse(v)341 if err != nil {342 return "(hidden)"343 }344 if u.User != nil {345 u.User = url.User("xxxxx")346 }347 if u.RawQuery != "" {348 u.RawQuery = "xxxxx"349 }350 return u.String()351}352353// SplitCommand splits a command line into words, as a shell would354// without expanding anything: spaces separate words, and single or355// double quotes keep spaces inside one (a backslash escapes the next356// character outside single quotes). No variables, globs or pipes.357func SplitCommand(s string) ([]string, error) {358 var words []string359 var cur strings.Builder360 inWord := false361 var quote rune362 escaped := false363 for _, r := range s {364 switch {365 case escaped:366 cur.WriteRune(r)367 escaped = false368 case r == '\\' && quote != '\'':369 escaped, inWord = true, true370 case quote != 0 && r == quote:371 quote = 0372 case quote != 0:373 cur.WriteRune(r)374 case r == '\'' || r == '"':375 quote, inWord = r, true376 case r == ' ' || r == '\t':377 if inWord {378 words = append(words, cur.String())379 cur.Reset()380 inWord = false381 }382 default:383 cur.WriteRune(r)384 inWord = true385 }386 }387 if quote != 0 || escaped {388 return nil, fmt.Errorf("command %q: unclosed quote", s)389 }390 if inWord {391 words = append(words, cur.String())392 }393 if len(words) == 0 {394 return nil, errors.New("empty command")395 }396 return words, nil397}