Ghostty-style key = value file in the user config dir, with comments, config-file includes (optional with ?), cycle detection and warnings for unknown keys and bad values. One registry of options drives parsing, flags, the effective-config listing, the commented default file and the generated reference in docs/config.md.
- commit
- 93d4cf726abdb8d498c050937310c14b260740c9
- parent
- 726dffdcec01760d1fd3521e5c8f34a5451ca513
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 191 | ++++++++++++++++++ |
| A | internal/ | 303 | ++++++++++++++++++++++++++++ |
| A | internal/ | 319 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 53 | +++++ |
| A | internal/ | 186 | +++++++++++++++++ |
| A | internal/ | 310 | +++++++++++++++++++++++++++++ |
@@ -0,0 +1,191 @@
1+# Configuration2+3+012 reads one settings file, in the style of Ghostty's:4+5+| OS | File |6+|---|---|7+| Linux, BSD | `$XDG_CONFIG_HOME/012/config`, usually `~/.config/012/config` |8+| macOS | `~/Library/Application Support/012/config`, or `$XDG_CONFIG_HOME/012/config` when that's set |9+| Windows | `%AppData%\012\config`, or `%XDG_CONFIG_HOME%\012\config` when that's set |10+11+```12+# ~/.config/012/config13+theme = light:Builtin Solarized Light,dark:Dracula14+chart-images = true15+jev-api-key-command = op read op://Private/TypeSafe/credential16+config-file = ?local.conf17+```18+19+- One `key = value` per line. `#` starts a comment line; blank lines are20+ ignored. Quote a value (`" like this "`) to keep spaces at its ends.21+- An empty value (`theme =`) puts an option back to its default.22+- `config-file = path` reads another file after this one, relative to this23+ file's directory; its settings win over the ones around it. A leading24+ `?` (`config-file = ?local.conf`) makes it optional. Includes can nest;25+ a file that includes itself is skipped with a warning.26+- Unknown keys, bad values and missing includes never stop 012: it starts27+ with the defaults for those options and says what's wrong on the context28+ line (and in `012 config`).29+30+Settings are process-wide. Settings that belong to a workbook, such as31+decimal arithmetic, are saved in the workbook's `.012` file instead.32+33+**Precedence:** command-line flags win over environment variables, which34+win over the config file, which wins over the defaults. `012 config` shows35+where each value came from.36+37+**Secrets never go in this file.** The JEV API key lives in the OS38+credential store or comes from a command; see [JEV setup](jev.md#setup).39+40+## Commands41+42+| Command | Does |43+|---|---|44+| `012 config` | print the settings in effect, each with where it came from (file and line, variable, flag or default), secrets redacted, then any warnings |45+| `012 config path` | print the config file's path |46+| `012 config edit` | open the file in `$VISUAL` or `$EDITOR`, creating it with every option commented out if it doesn't exist |47+| `012 config default` | print a config file with every option at its default, commented out and described |48+| `012 config themes` | list the themes: built in and in the themes directory |49+| `012 config set-key` | store the JEV API key in the credential store, read from the terminal without echo or from stdin |50+| `012 config delete-key` | remove the JEV API key from the credential store |51+52+In the app, File > Settings has **Theme** (a picker with live preview),53+**Open config file** (in your editor, reloading when it closes),54+**Reload config** and **JEV API key**. Reloading applies the options marked55+"File > Settings > Reload config" below; the rest take effect on the next56+start.57+58+## Options59+60+<!-- Generated from internal/config/registry.go by `go test ./internal/config -update-docs`. Don't edit below. -->61+62+### Appearance63+64+#### `theme`65+66+Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a terminal color scheme, built in (`012 config themes` lists them) or a file in the themes directory, drawn in its own colors with solid menu and status bars. `light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.67+68+| | |69+|---|---|70+| Type | text |71+| Default | `terminal` |72+| Environment | `O12_THEME` |73+| Flag | `--theme` |74+| Applies | File > Settings > Reload config |75+76+#### `chart-images`77+78+Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). When false, charts are always text.79+80+| | |81+|---|---|82+| Type | true or false |83+| Default | `true` |84+| Environment | `O12_CHART_IMAGES` |85+| Applies | File > Settings > Reload config |86+87+#### `notifications`88+89+Send a desktop notification (OSC 9) when JEV answers or an import finishes while the terminal window is in the background.90+91+| | |92+|---|---|93+| Type | true or false |94+| Default | `true` |95+| Environment | `O12_NOTIFICATIONS` |96+| Applies | File > Settings > Reload config |97+98+### JEV functions99+100+#### `jev-api-key-command`101+102+A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or `pass show typesafe`. It runs without a shell, for up to 10 seconds; for pipes, write `sh -c '...'` yourself. The key itself never goes in this file.103+104+| | |105+|---|---|106+| Type | command |107+| Default | (empty) |108+| Applies | restart 012 |109+110+#### `jev-credential-store`111+112+Look for the API key in the OS credential store (macOS Keychain, Windows Credential Manager, the Secret Service on Linux), where `012 config set-key` and Settings put it.113+114+| | |115+|---|---|116+| Type | true or false |117+| Default | `true` |118+| Environment | `O12_JEV_CREDENTIAL_STORE` |119+| Applies | restart 012 |120+121+#### `jev-base-url`122+123+The TypeSafe service to ask; the SDK's default when empty. Must be https, except on localhost.124+125+| | |126+|---|---|127+| Type | URL |128+| Default | (empty) |129+| Environment | `TYPESAFE_BASE_URL` |130+| Applies | restart 012 |131+132+#### `jev-model`133+134+The JEV model to ask; the service's default when empty.135+136+| | |137+|---|---|138+| Type | text |139+| Default | (empty) |140+| Environment | `TYPESAFE_DEFAULT_MODEL` |141+| Applies | restart 012 |142+143+### Telemetry144+145+#### `log-file`146+147+Append telemetry events to this JSON log file. See docs/observability.md.148+149+| | |150+|---|---|151+| Type | path |152+| Default | (empty) |153+| Environment | `O12_LOG` |154+| Flag | `--log` |155+| Applies | restart 012 |156+157+#### `log-level`158+159+The least severe telemetry event recorded; debug adds every frame and command.160+161+| | |162+|---|---|163+| Type | one of `debug`, `info`, `warn`, `error` |164+| Default | `info` |165+| Environment | `O12_LOG_LEVEL` |166+| Applies | restart 012 |167+168+#### `otlp-endpoint`169+170+Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. The other OTEL_* variables still apply.171+172+| | |173+|---|---|174+| Type | URL |175+| Default | (empty) |176+| Environment | `OTEL_EXPORTER_OTLP_ENDPOINT` |177+| Flag | `--otlp` |178+| Applies | restart 012 |179+180+### Config files181+182+#### `config-file`183+184+Read another config file after this one, relative to this file's directory. A leading `?` makes it optional: no warning when it doesn't exist.185+186+| | |187+|---|---|188+| Type | path |189+| Default | (empty) |190+| Repeatable | yes |191+| Applies | restart 012 |@@ -0,0 +1,303 @@
1+package config2+3+import (4+ "bufio"5+ "errors"6+ "fmt"7+ "io"8+ "io/fs"9+ "os"10+ "path/filepath"11+ "strconv"12+ "strings"13+)14+15+// SourceKind says where a value came from.16+type SourceKind int17+18+const (19+ FromDefault SourceKind = iota20+ FromFile21+ FromEnv22+ FromFlag23+)24+25+// Source is where a value came from: the default, a line of a config26+// file, an environment variable or a flag.27+type Source struct {28+ Kind SourceKind29+ Name string // the file, variable or flag30+ Line int // for files31+}32+33+func (s Source) String() string {34+ switch s.Kind {35+ case FromFile:36+ return fmt.Sprintf("%s:%d", s.Name, s.Line)37+ case FromEnv:38+ return "$" + s.Name39+ case FromFlag:40+ return s.Name41+ }42+ return "default"43+}44+45+// Value is one setting's value and where it came from.46+type Value struct {47+ Raw string48+ Src Source49+}50+51+// Warning is a problem with the config that didn't stop 012: an unknown52+// key, a bad value, a missing include. The option keeps its default.53+type Warning struct {54+ Src Source55+ Msg string56+}57+58+func (w Warning) String() string {59+ if w.Src.Kind == FromDefault {60+ return w.Msg61+ }62+ return w.Src.String() + ": " + w.Msg63+}64+65+// Config is the effective configuration.66+type Config struct {67+ // Path is the main config file, which may not exist.68+ Path string69+ Warnings []Warning70+ vals map[string][]Value // file values, then env, then flags override71+}72+73+// Dir is 012's config directory: $XDG_CONFIG_HOME/012 when that's set74+// (on every OS, so tests and dotfile setups can point it anywhere),75+// otherwise os.UserConfigDir()/012 (~/.config/012 on Linux,76+// ~/Library/Application Support/012 on macOS, %AppData%\012 on Windows).77+func Dir() (string, error) {78+ if x := os.Getenv("XDG_CONFIG_HOME"); x != "" && filepath.IsAbs(x) {79+ return filepath.Join(x, "012"), nil80+ }81+ d, err := os.UserConfigDir()82+ if err != nil {83+ return "", err84+ }85+ return filepath.Join(d, "012"), nil86+}87+88+// DefaultPath is the main config file, <Dir>/config.89+func DefaultPath() (string, error) {90+ d, err := Dir()91+ if err != nil {92+ return "", err93+ }94+ return filepath.Join(d, "config"), nil95+}96+97+// ThemesDir is where theme files go, <Dir>/themes.98+func ThemesDir() string {99+ d, err := Dir()100+ if err != nil {101+ return ""102+ }103+ return filepath.Join(d, "themes")104+}105+106+// Load reads the config file at path (a missing file is fine), then107+// applies environment variables (from getenv) and flags, which win in108+// that order: flags > environment > config file > defaults.109+func Load(path string, getenv func(string) string, flags map[string]string) *Config {110+ c := &Config{Path: path, vals: map[string][]Value{}}111+ if path != "" {112+ c.readFile(path, false, map[string]bool{})113+ }114+ if getenv != nil {115+ for i := range Options {116+ o := &Options[i]117+ for _, env := range o.Env {118+ if v := strings.TrimSpace(getenv(env)); v != "" {119+ c.set(o, v, Source{Kind: FromEnv, Name: env})120+ break121+ }122+ }123+ }124+ }125+ for name, v := range flags {126+ if o, ok := byName[name]; ok {127+ c.set(o, v, Source{Kind: FromFlag, Name: o.Flag})128+ }129+ }130+ return c131+}132+133+// readFile reads one file and then the files it includes. seen holds the134+// files being read, to stop include cycles.135+func (c *Config) readFile(path string, optional bool, seen map[string]bool) {136+ abs, err := filepath.Abs(path)137+ if err == nil {138+ path = abs139+ }140+ if seen[path] {141+ c.warn(Source{}, "config-file "+path+" includes itself; skipped")142+ return143+ }144+ f, err := os.Open(path)145+ switch {146+ case errors.Is(err, fs.ErrNotExist):147+ if !optional && len(seen) > 0 {148+ c.warn(Source{}, "config-file "+path+" doesn't exist")149+ }150+ return151+ case err != nil:152+ c.warn(Source{}, err.Error())153+ return154+ }155+ defer f.Close()156+ seen[path] = true157+ defer delete(seen, path)158+ // Includes are read after the whole file, as in Ghostty, so they159+ // override what comes before and after them.160+ for _, inc := range c.parse(f, path) {161+ c.readFile(inc.path, inc.optional, seen)162+ }163+}164+165+type include struct {166+ path string167+ optional bool168+}169+170+// parse applies a file's lines and returns its includes.171+func (c *Config) parse(r io.Reader, path string) []include {172+ var incs []include173+ sc := bufio.NewScanner(r)174+ for n := 1; sc.Scan(); n++ {175+ src := Source{Kind: FromFile, Name: path, Line: n}176+ key, val, ok := splitLine(sc.Text())177+ if !ok {178+ if strings.TrimSpace(sc.Text()) != "" && !strings.HasPrefix(strings.TrimSpace(sc.Text()), "#") {179+ c.warn(src, fmt.Sprintf("expected key = value, got %q", strings.TrimSpace(sc.Text())))180+ }181+ continue182+ }183+ o, known := byName[key]184+ if !known {185+ c.warn(src, "unknown key "+strconv.Quote(key))186+ continue187+ }188+ if o.Name == "config-file" && val != "" {189+ inc := include{path: val}190+ if strings.HasPrefix(val, "?") {191+ inc = include{path: strings.TrimPrefix(val, "?"), optional: true}192+ }193+ inc.path = expandPath(inc.path, filepath.Dir(path))194+ incs = append(incs, inc)195+ }196+ c.set(o, val, src)197+ }198+ return incs199+}200+201+// splitLine splits "key = value", ignoring blank lines and # comments. A202+// value may be quoted to keep spaces at its ends or a #.203+func splitLine(line string) (key, val string, ok bool) {204+ line = strings.TrimSpace(line)205+ if line == "" || strings.HasPrefix(line, "#") {206+ return "", "", false207+ }208+ key, val, ok = strings.Cut(line, "=")209+ if !ok {210+ return "", "", false211+ }212+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)213+ if len(val) >= 2 && val[0] == '"' && val[len(val)-1] == '"' {214+ if u, err := strconv.Unquote(val); err == nil {215+ val = u216+ }217+ }218+ return key, val, key != ""219+}220+221+// set records a value after validating it. An empty value resets the222+// option (for repeatable ones, clears the list).223+func (c *Config) set(o *Option, v string, src Source) {224+ if err := o.validate(v); err != nil {225+ c.warn(src, o.Name+": "+err.Error())226+ return227+ }228+ switch {229+ case v == "":230+ delete(c.vals, o.Name)231+ case o.Repeat && src.Kind == FromFile:232+ c.vals[o.Name] = append(c.vals[o.Name], Value{Raw: v, Src: src})233+ default:234+ c.vals[o.Name] = []Value{{Raw: v, Src: src}}235+ }236+}237+238+func (c *Config) warn(src Source, msg string) {239+ c.Warnings = append(c.Warnings, Warning{Src: src, Msg: msg})240+}241+242+// Get returns an option's value and where it came from.243+func (c *Config) Get(name string) Value {244+ o := mustLookup(name)245+ if c != nil {246+ if vs := c.vals[name]; len(vs) > 0 {247+ return vs[len(vs)-1]248+ }249+ }250+ return Value{Raw: o.Default}251+}252+253+// String returns an option's value, with ~ expanded for paths.254+func (c *Config) String(name string) string {255+ v := c.Get(name)256+ if byName[name].Kind == Path && v.Raw != "" {257+ return expandPath(v.Raw, "")258+ }259+ return v.Raw260+}261+262+// Bool returns a true-or-false option.263+func (c *Config) Bool(name string) bool {264+ b, _ := strconv.ParseBool(c.Get(name).Raw)265+ return b266+}267+268+// List returns every value of a repeatable option.269+func (c *Config) List(name string) []Value {270+ mustLookup(name)271+ if c == nil {272+ return nil273+ }274+ return c.vals[name]275+}276+277+// Theme returns the theme choice.278+func (c *Config) Theme() ThemeChoice {279+ t, _ := ParseThemeChoice(c.Get("theme").Raw)280+ return t281+}282+283+func mustLookup(name string) *Option {284+ o, ok := byName[name]285+ if !ok {286+ panic("config: no option " + name)287+ }288+ return o289+}290+291+// expandPath expands a leading ~ and makes a relative path relative to292+// dir, when dir is given.293+func expandPath(p, dir string) string {294+ if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {295+ if home, err := os.UserHomeDir(); err == nil {296+ p = filepath.Join(home, p[1:])297+ }298+ }299+ if dir != "" && !filepath.IsAbs(p) {300+ p = filepath.Join(dir, p)301+ }302+ return p303+}@@ -0,0 +1,319 @@
1+package config2+3+import (4+ "flag"5+ "os"6+ "path/filepath"7+ "slices"8+ "strings"9+ "testing"10+)11+12+func write(t *testing.T, path, text string) string {13+ t.Helper()14+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {15+ t.Fatal(err)16+ }17+ if err := os.WriteFile(path, []byte(text), 0o600); err != nil {18+ t.Fatal(err)19+ }20+ return path21+}22+23+func noEnv(string) string { return "" }24+25+func warnings(c *Config) string {26+ var out []string27+ for _, w := range c.Warnings {28+ out = append(out, w.String())29+ }30+ return strings.Join(out, "\n")31+}32+33+func TestParseFile(t *testing.T) {34+ dir := t.TempDir()35+ path := write(t, filepath.Join(dir, "config"), `# a comment36+theme = Dracula37+ # indented comment38+39+chart-images=false40+jev-model = " spaced "41+bogus = 142+not a setting43+log-level = loud44+`)45+ c := Load(path, noEnv, nil)46+ if got := c.String("theme"); got != "Dracula" {47+ t.Errorf("theme %q", got)48+ }49+ if c.Bool("chart-images") {50+ t.Error("chart-images should be false")51+ }52+ if got := c.String("jev-model"); got != " spaced " {53+ t.Errorf("quoted value %q", got)54+ }55+ if v := c.Get("log-level"); v.Raw != "info" || v.Src.Kind != FromDefault {56+ t.Errorf("an invalid value keeps the default: %+v", v)57+ }58+ w := warnings(c)59+ for _, want := range []string{`config:7: unknown key "bogus"`, `config:8: expected key = value`, `config:9: log-level: "loud" isn't one of`} {60+ if !strings.Contains(w, want) {61+ t.Errorf("warnings lack %q:\n%s", want, w)62+ }63+ }64+ if v := c.Get("theme"); v.Src.Kind != FromFile || v.Src.Line != 2 {65+ t.Errorf("theme source %+v", v.Src)66+ }67+}68+69+func TestMissingFileIsFine(t *testing.T) {70+ c := Load(filepath.Join(t.TempDir(), "config"), noEnv, nil)71+ if len(c.Warnings) != 0 || c.String("theme") != "terminal" || !c.Bool("chart-images") {72+ t.Errorf("defaults: %v %q", c.Warnings, c.String("theme"))73+ }74+}75+76+func TestIncludes(t *testing.T) {77+ dir := t.TempDir()78+ write(t, filepath.Join(dir, "themes.conf"), "theme = Nord\n")79+ write(t, filepath.Join(dir, "sub", "more"), "jev-model = included\nconfig-file = ../config\n")80+ path := write(t, filepath.Join(dir, "config"), `config-file = themes.conf81+theme = before-include-wins-not82+config-file = ?optional-missing83+config-file = required-missing84+config-file = sub/more85+`)86+ c := Load(path, noEnv, nil)87+ if got := c.String("theme"); got != "Nord" {88+ t.Errorf("includes are read after the file, so they win: %q", got)89+ }90+ if got := c.String("jev-model"); got != "included" {91+ t.Errorf("nested include: %q", got)92+ }93+ w := warnings(c)94+ if strings.Contains(w, "optional-missing") {95+ t.Errorf("an optional include warned:\n%s", w)96+ }97+ if !strings.Contains(w, "required-missing doesn't exist") {98+ t.Errorf("no warning for a missing include:\n%s", w)99+ }100+ if !strings.Contains(w, "includes itself") {101+ t.Errorf("no warning for the cycle:\n%s", w)102+ }103+ if n := len(c.List("config-file")); n != 5 {104+ t.Errorf("config-file is repeatable: %d values", n)105+ }106+}107+108+func TestSelfInclude(t *testing.T) {109+ dir := t.TempDir()110+ path := write(t, filepath.Join(dir, "config"), "config-file = config\ntheme = x\n")111+ c := Load(path, noEnv, nil)112+ if c.String("theme") != "x" || !strings.Contains(warnings(c), "includes itself") {113+ t.Errorf("self include: %q %v", c.String("theme"), c.Warnings)114+ }115+}116+117+func TestPrecedence(t *testing.T) {118+ path := write(t, filepath.Join(t.TempDir(), "config"), "log-file = from-file.jsonl\njev-model = file-model\ntheme = Nord\n")119+ env := map[string]string{"O12_LOG": "from-env.jsonl", "TYPESAFE_DEFAULT_MODEL": "env-model"}120+ c := Load(path, func(k string) string { return env[k] }, map[string]string{"log-file": "from-flag.jsonl"})121+ if got := c.Get("log-file"); got.Raw != "from-flag.jsonl" || got.Src.String() != "--log" {122+ t.Errorf("flags win: %+v", got)123+ }124+ if got := c.Get("jev-model"); got.Raw != "env-model" || got.Src.String() != "$TYPESAFE_DEFAULT_MODEL" {125+ t.Errorf("environment beats the file: %+v", got)126+ }127+ if got := c.Get("theme"); got.Raw != "Nord" {128+ t.Errorf("file beats the default: %+v", got)129+ }130+ if got := c.Get("log-level"); got.Raw != "info" || got.Src.String() != "default" {131+ t.Errorf("default: %+v", got)132+ }133+}134+135+func TestEmptyValueResets(t *testing.T) {136+ path := write(t, filepath.Join(t.TempDir(), "config"), "theme = Nord\ntheme =\n")137+ if got := Load(path, noEnv, nil).String("theme"); got != "terminal" {138+ t.Errorf("empty value should reset: %q", got)139+ }140+}141+142+func TestParseFlags(t *testing.T) {143+ flags, rest, err := ParseFlags([]string{"--log", "a.jsonl", "--otlp=http://localhost:4318", "b.csv", "--theme", "Nord"})144+ if err != nil || flags["log-file"] != "a.jsonl" || flags["otlp-endpoint"] != "http://localhost:4318" ||145+ flags["theme"] != "Nord" || !slices.Equal(rest, []string{"b.csv"}) {146+ t.Errorf("got %v %v %v", flags, rest, err)147+ }148+ if _, _, err := ParseFlags([]string{"--otlp"}); err == nil {149+ t.Error("--otlp without a value")150+ }151+}152+153+func TestBaseURL(t *testing.T) {154+ for v, ok := range map[string]bool{155+ "https://api.example.com": true,156+ "http://127.0.0.1:8799": true,157+ "http://localhost:1234/v1": true,158+ "http://[::1]:80": true,159+ "http://api.example.com": false,160+ "http://127.evil.com": false,161+ "ftp://localhost": false,162+ "not a url": false,163+ "https://": false,164+ "http://localhost.evil.com": false,165+ } {166+ if err := CheckBaseURL(v); (err == nil) != ok {167+ t.Errorf("%s: %v", v, err)168+ }169+ }170+ path := write(t, filepath.Join(t.TempDir(), "config"), "jev-base-url = http://attacker.example\n")171+ c := Load(path, noEnv, nil)172+ if c.String("jev-base-url") != "" || !strings.Contains(warnings(c), "must be https") {173+ t.Errorf("an http base URL was accepted: %q %v", c.String("jev-base-url"), c.Warnings)174+ }175+ env := func(k string) string {176+ if k == "TYPESAFE_BASE_URL" {177+ return "http://attacker.example"178+ }179+ return ""180+ }181+ if c := Load("", env, nil); c.String("jev-base-url") != "" {182+ t.Error("an http base URL from the environment was accepted")183+ }184+}185+186+func TestThemeChoice(t *testing.T) {187+ for v, want := range map[string]ThemeChoice{188+ "": {"terminal", "terminal"},189+ "Nord": {"Nord", "Nord"},190+ "light:Nord Light,dark:Nord": {"Nord Light", "Nord"},191+ "dark: B , light: A": {"A", "B"},192+ } {193+ got, err := ParseThemeChoice(v)194+ if err != nil || got != want {195+ t.Errorf("%q: %+v %v", v, got, err)196+ }197+ }198+ for _, bad := range []string{"light:A", "light:A,dim:B", "light:,dark:B"} {199+ if _, err := ParseThemeChoice(bad); err == nil {200+ t.Errorf("%q accepted", bad)201+ }202+ }203+}204+205+func TestSplitCommand(t *testing.T) {206+ for in, want := range map[string][]string{207+ `op read op://Private/TypeSafe/credential`: {"op", "read", "op://Private/TypeSafe/credential"},208+ `pass show "api keys/typesafe"`: {"pass", "show", "api keys/typesafe"},209+ `sh -c 'pass show x | head -1'`: {"sh", "-c", "pass show x | head -1"},210+ `echo a\ b $HOME`: {"echo", "a b", "$HOME"},211+ `printf ''`: {"printf", ""},212+ } {213+ got, err := SplitCommand(in)214+ if err != nil || !slices.Equal(got, want) {215+ t.Errorf("%s: %q %v", in, got, err)216+ }217+ }218+ for _, bad := range []string{`echo 'open`, ``, ` `} {219+ if _, err := SplitCommand(bad); err == nil {220+ t.Errorf("%q accepted", bad)221+ }222+ }223+}224+225+func TestShowRedacts(t *testing.T) {226+ path := write(t, filepath.Join(t.TempDir(), "config"), "otlp-endpoint = https://user:hunter2@collector.example/?token=abc\n")227+ out := Load(path, noEnv, nil).Show()228+ if strings.Contains(out, "hunter2") || strings.Contains(out, "token=abc") {229+ t.Errorf("secret shown:\n%s", out)230+ }231+ if !strings.Contains(out, "otlp-endpoint = https://xxxxx@collector.example/?xxxxx") {232+ t.Errorf("redacted URL missing:\n%s", out)233+ }234+}235+236+func TestDefaultFileParses(t *testing.T) {237+ path := write(t, filepath.Join(t.TempDir(), "config"), DefaultFile())238+ c := Load(path, noEnv, nil)239+ if len(c.Warnings) != 0 {240+ t.Errorf("the default file warns: %v", c.Warnings)241+ }242+ for _, o := range Options {243+ if !strings.Contains(DefaultFile(), "# "+o.Name+" = ") {244+ t.Errorf("default file lacks %s", o.Name)245+ }246+ }247+}248+249+func TestSetInFile(t *testing.T) {250+ path := filepath.Join(t.TempDir(), "sub", "config")251+ if err := SetInFile(path, "theme", "Nord"); err != nil {252+ t.Fatal(err)253+ }254+ write(t, path, "# mine\ntheme = A\nchart-images = false\ntheme = B # later wins\n")255+ if err := SetInFile(path, "theme", "Dracula"); err != nil {256+ t.Fatal(err)257+ }258+ data, _ := os.ReadFile(path)259+ if string(data) != "# mine\ntheme = A\nchart-images = false\ntheme = Dracula\n" {260+ t.Errorf("got %q", data)261+ }262+ if err := SetInFile(path, "nope", "x"); err == nil {263+ t.Error("unknown key accepted")264+ }265+}266+267+func TestRegistry(t *testing.T) {268+ seen := map[string]bool{}269+ for _, o := range Options {270+ if seen[o.Name] {271+ t.Errorf("%s registered twice", o.Name)272+ }273+ seen[o.Name] = true274+ if o.Desc == "" || !slices.Contains(Groups, o.Group) {275+ t.Errorf("%s: needs a description and a known group", o.Name)276+ }277+ if err := o.validate(o.Default); err != nil {278+ t.Errorf("%s: default %q invalid: %v", o.Name, o.Default, err)279+ }280+ }281+}282+283+func TestDir(t *testing.T) {284+ x := t.TempDir()285+ t.Setenv("XDG_CONFIG_HOME", x)286+ if d, _ := Dir(); d != filepath.Join(x, "012") {287+ t.Errorf("XDG_CONFIG_HOME ignored: %s", d)288+ }289+ if p, _ := DefaultPath(); p != filepath.Join(x, "012", "config") {290+ t.Errorf("path %s", p)291+ }292+}293+294+var updateDocs = flag.Bool("update-docs", false, "rewrite the reference in docs/config.md")295+296+// TestConfigDoc keeps docs/config.md's reference in step with the297+// registry: run `go test ./internal/config -update-docs` after changing298+// an option.299+func TestConfigDoc(t *testing.T) {300+ const path = "../../docs/config.md"301+ data, err := os.ReadFile(path)302+ if err != nil {303+ t.Fatal(err)304+ }305+ intro, _, ok := strings.Cut(string(data), DocsMarker)306+ if !ok {307+ t.Fatalf("%s lacks the generated-reference marker", path)308+ }309+ want := intro + DocsMarker + "\n" + Reference()310+ if *updateDocs {311+ if err := os.WriteFile(path, []byte(want), 0o644); err != nil {312+ t.Fatal(err)313+ }314+ return315+ }316+ if string(data) != want {317+ t.Errorf("%s is stale: run go test ./internal/config -update-docs", path)318+ }319+}@@ -0,0 +1,53 @@
1+package config2+3+import (4+ "fmt"5+ "strings"6+)7+8+// DocsMarker divides docs/config.md: the introduction above it is9+// written by hand, the reference below it is Reference's output.10+const DocsMarker = "<!-- Generated from internal/config/registry.go by `go test ./internal/config -update-docs`. Don't edit below. -->"11+12+// Reference is the option reference in Markdown, one section per group.13+func Reference() string {14+ var b strings.Builder15+ for _, g := range Groups {16+ fmt.Fprintf(&b, "\n### %s\n", g)17+ for _, o := range Options {18+ if o.Group != g {19+ continue20+ }21+ fmt.Fprintf(&b, "\n#### `%s`\n\n%s\n\n", o.Name, o.Desc)22+ b.WriteString("| | |\n|---|---|\n")23+ fmt.Fprintf(&b, "| Type | %s |\n", o.typeDoc())24+ def := "(empty)"25+ if o.Default != "" {26+ def = "`" + o.Default + "`"27+ }28+ fmt.Fprintf(&b, "| Default | %s |\n", def)29+ if len(o.Env) > 0 {30+ fmt.Fprintf(&b, "| Environment | `%s` |\n", strings.Join(o.Env, "`, `"))31+ }32+ if o.Flag != "" {33+ fmt.Fprintf(&b, "| Flag | `%s` |\n", o.Flag)34+ }35+ if o.Repeat {36+ b.WriteString("| Repeatable | yes |\n")37+ }38+ reload := "restart 012"39+ if o.Live {40+ reload = "File > Settings > Reload config"41+ }42+ fmt.Fprintf(&b, "| Applies | %s |\n", reload)43+ }44+ }45+ return b.String()46+}47+48+func (o *Option) typeDoc() string {49+ if o.Kind == Enum {50+ return "one of `" + strings.Join(o.Values, "`, `") + "`"51+ }52+ return o.Kind.String()53+}@@ -0,0 +1,186 @@
1+package config2+3+import (4+ "errors"5+ "fmt"6+ "os"7+ "path/filepath"8+ "slices"9+ "strings"10+)11+12+// ParseFlags takes the options that have flags ("--log path" or13+// "--log=path") out of args, returning their values by option name and14+// the arguments left.15+func ParseFlags(args []string) (map[string]string, []string, error) {16+ flags := map[string]string{}17+ var rest []string18+ for i := 0; i < len(args); i++ {19+ name, value, hasValue := strings.Cut(args[i], "=")20+ o := flagOption(name)21+ switch {22+ case o == nil:23+ rest = append(rest, args[i])24+ case hasValue:25+ flags[o.Name] = value26+ case i+1 < len(args):27+ flags[o.Name] = args[i+1]28+ i++29+ default:30+ return nil, nil, errors.New(name + " needs a value")31+ }32+ }33+ return flags, rest, nil34+}35+36+func flagOption(flag string) *Option {37+ if !strings.HasPrefix(flag, "--") {38+ return nil39+ }40+ for i := range Options {41+ if Options[i].Flag == flag {42+ return &Options[i]43+ }44+ }45+ return nil46+}47+48+// FlagUsage lists the flags for a usage line, e.g. "[--log file]".49+func FlagUsage() string {50+ var parts []string51+ for _, o := range Options {52+ if o.Flag != "" {53+ parts = append(parts, "["+o.Flag+" "+strings.TrimPrefix(o.Flag, "--")+"]")54+ }55+ }56+ return strings.Join(parts, " ")57+}58+59+// Show writes the effective configuration: every option with its value60+// and where it came from, values that can hold secrets redacted.61+func (c *Config) Show() string {62+ var b strings.Builder63+ fmt.Fprintf(&b, "# %s\n", c.Path)64+ for _, g := range Groups {65+ fmt.Fprintf(&b, "\n# %s\n", g)66+ for _, o := range Options {67+ if o.Group != g {68+ continue69+ }70+ vals := c.List(o.Name)71+ if !o.Repeat || len(vals) == 0 {72+ vals = []Value{c.Get(o.Name)}73+ }74+ for _, v := range vals {75+ raw := v.Raw76+ if o.Redact != nil && raw != "" {77+ raw = o.Redact(raw)78+ }79+ fmt.Fprintf(&b, "%s = %s", o.Name, raw)80+ b.WriteString(strings.Repeat(" ", max(1, 40-len(o.Name)-len(raw)-3)))81+ fmt.Fprintf(&b, "# %s\n", v.Src)82+ }83+ }84+ }85+ for _, w := range c.Warnings {86+ fmt.Fprintf(&b, "\n# warning: %s", w)87+ }88+ if len(c.Warnings) > 0 {89+ b.WriteByte('\n')90+ }91+ return b.String()92+}93+94+// DefaultFile is a config file with every option commented out at its95+// default, each with its description: what `012 config default` prints96+// and `012 config edit` starts a new file with.97+func DefaultFile() string {98+ var b strings.Builder99+ b.WriteString("# 012 configuration. Lines are key = value; # starts a comment.\n")100+ b.WriteString("# Flags and environment variables override this file.\n")101+ b.WriteString("# See `012 config` for the values in effect and docs/config.md for more.\n")102+ for _, g := range Groups {103+ fmt.Fprintf(&b, "\n# ---- %s ----\n", g)104+ for _, o := range Options {105+ if o.Group != g {106+ continue107+ }108+ b.WriteString("\n")109+ for _, l := range wrap(stripTicks(o.Desc), 74) {110+ b.WriteString("# " + l + "\n")111+ }112+ fmt.Fprintf(&b, "# %s = %s\n", o.Name, o.Default)113+ }114+ }115+ return b.String()116+}117+118+func stripTicks(s string) string { return strings.ReplaceAll(s, "`", "") }119+120+// wrap breaks s into lines of at most w columns at spaces.121+func wrap(s string, w int) []string {122+ var lines []string123+ line := ""124+ for word := range strings.FieldsSeq(s) {125+ if line != "" && len(line)+1+len(word) > w {126+ lines = append(lines, line)127+ line = ""128+ }129+ if line != "" {130+ line += " "131+ }132+ line += word133+ }134+ if line != "" {135+ lines = append(lines, line)136+ }137+ return lines138+}139+140+// SetInFile sets key to value in the config file at path: the last line141+// setting key is replaced, or a line is added at the end. Comments and142+// every other line are kept. The file and its directory are created if143+// needed.144+func SetInFile(path, key, value string) error {145+ if _, ok := byName[key]; !ok {146+ return errors.New("no option " + key)147+ }148+ data, err := os.ReadFile(path)149+ if err != nil && !errors.Is(err, os.ErrNotExist) {150+ return err151+ }152+ lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")153+ if len(data) == 0 {154+ lines = nil155+ }156+ newLine := key + " = " + value157+ at := -1158+ for i, l := range lines {159+ if k, _, ok := splitLine(l); ok && k == key {160+ at = i161+ }162+ }163+ if at >= 0 {164+ lines[at] = newLine165+ } else {166+ lines = append(lines, newLine)167+ }168+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {169+ return err170+ }171+ tmp := path + ".tmp"172+ if err := os.WriteFile(tmp, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil {173+ return err174+ }175+ return os.Rename(tmp, path)176+}177+178+// Names is every option name, sorted.179+func Names() []string {180+ names := make([]string, len(Options))181+ for i, o := range Options {182+ names[i] = o.Name183+ }184+ slices.Sort(names)185+ return names186+}@@ -0,0 +1,310 @@
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/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) stay in the workbook's file. Secrets never go here: the JEV10+// API key lives in the OS credential store (see internal/keyring).11+package config12+13+import (14+ "errors"15+ "fmt"16+ "net"17+ "net/url"18+ "strconv"19+ "strings"20+)21+22+// Kind is an option's type.23+type Kind int24+25+const (26+ String Kind = iota27+ Bool28+ Enum // one of Option.Values29+ Path // a file path; ~ is the home directory30+ URL31+ Command // a program and its arguments, run without a shell32+)33+34+func (k Kind) String() string {35+ return [...]string{"text", "true or false", "one of", "path", "URL", "command"}[k]36+}37+38+// Option is one setting.39+type Option struct {40+ Name string41+ Kind Kind42+ Group string // heading in docs/config.md and `012 config`43+ Default string // as it would be written in the file44+ Values []string // for Enum45+ Env []string // environment variables that set it, first set wins46+ Flag string // command-line flag, e.g. "--log"47+ Repeat bool // may be given more than once; each adds a value48+ Live bool // Reload config applies it without restarting49+ Desc string // what it does, one or more sentences50+ // Check validates a value beyond its kind. Invalid values are51+ // reported as warnings and the option keeps its previous value.52+ Check func(string) error53+ // Redact shows a value safely in `012 config`, e.g. without a URL's54+ // password.55+ Redact func(string) string56+}57+58+// Group names, in the order docs list them.59+const (60+ GroupAppearance = "Appearance"61+ GroupJEV = "JEV functions"62+ GroupTelemetry = "Telemetry"63+ GroupFiles = "Config files"64+)65+66+// Groups is the order groups are listed in.67+var Groups = []string{GroupAppearance, GroupJEV, GroupTelemetry, GroupFiles}68+69+// Options is every setting. Add an option here and read it with70+// Config.String, Bool or List; parsing, `012 config`, docs/config.md71+// (go test ./internal/config -update-docs) and Settings follow.72+var Options = []Option{73+ {Name: "theme", Group: GroupAppearance, Default: "terminal", Env: []string{"O12_THEME"}, Flag: "--theme", Live: true,74+ Desc: "Colors. `terminal` uses the terminal's own 16-color palette. Any other name is a " +75+ "terminal color scheme, built in (`012 config themes` lists them) or a file in the themes " +76+ "directory, drawn in its own colors with solid menu and status bars. " +77+ "`light:NAME,dark:NAME` picks one by the terminal's background and follows it when it changes.",78+ Check: checkTheme},79+ {Name: "chart-images", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_CHART_IMAGES"}, Live: true,80+ Desc: "Draw charts as images on terminals with kitty graphics (kitty, Ghostty, WezTerm). " +81+ "When false, charts are always text."},82+ {Name: "notifications", Kind: Bool, Group: GroupAppearance, Default: "true", Env: []string{"O12_NOTIFICATIONS"}, Live: true,83+ Desc: "Send a desktop notification (OSC 9) when JEV answers or an import finishes while the " +84+ "terminal window is in the background."},85+86+ {Name: "jev-api-key-command", Kind: Command, Group: GroupJEV,87+ Desc: "A command that prints the TypeSafe API key, used when TYPESAFE_API_KEY isn't set and the " +88+ "credential store has no key, e.g. `op read op://Private/TypeSafe/credential` or " +89+ "`pass show typesafe`. It runs without a shell, for up to 10 seconds; for pipes, write " +90+ "`sh -c '...'` yourself. The key itself never goes in this file."},91+ {Name: "jev-credential-store", Kind: Bool, Group: GroupJEV, Default: "true", Env: []string{"O12_JEV_CREDENTIAL_STORE"},92+ Desc: "Look for the API key in the OS credential store (macOS Keychain, Windows Credential Manager, " +93+ "the Secret Service on Linux), where `012 config set-key` and Settings put it."},94+ {Name: "jev-base-url", Kind: URL, Group: GroupJEV, Env: []string{"TYPESAFE_BASE_URL"},95+ Desc: "The TypeSafe service to ask; the SDK's default when empty. Must be https, except on localhost.",96+ Check: CheckBaseURL, Redact: redactURL},97+ {Name: "jev-model", Group: GroupJEV, Env: []string{"TYPESAFE_DEFAULT_MODEL"},98+ Desc: "The JEV model to ask; the service's default when empty."},99+100+ {Name: "log-file", Kind: Path, Group: GroupTelemetry, Env: []string{"O12_LOG"}, Flag: "--log",101+ Desc: "Append telemetry events to this JSON log file. See docs/observability.md."},102+ {Name: "log-level", Kind: Enum, Group: GroupTelemetry, Default: "info", Values: []string{"debug", "info", "warn", "error"},103+ Env: []string{"O12_LOG_LEVEL"},104+ Desc: "The least severe telemetry event recorded; debug adds every frame and command."},105+ {Name: "otlp-endpoint", Kind: URL, Group: GroupTelemetry, Env: []string{"OTEL_EXPORTER_OTLP_ENDPOINT"}, Flag: "--otlp",106+ Desc: "Send telemetry to this OTLP/HTTP collector, e.g. http://localhost:4318. " +107+ "The other OTEL_* variables still apply.",108+ Redact: redactURL},109+110+ {Name: "config-file", Kind: Path, Group: GroupFiles, Repeat: true,111+ Desc: "Read another config file after this one, relative to this file's directory. " +112+ "A leading `?` makes it optional: no warning when it doesn't exist."},113+}114+115+// byName indexes Options.116+var byName = func() map[string]*Option {117+ m := make(map[string]*Option, len(Options))118+ for i := range Options {119+ m[Options[i].Name] = &Options[i]120+ }121+ return m122+}()123+124+// Lookup returns the option called name.125+func Lookup(name string) (*Option, bool) {126+ o, ok := byName[name]127+ return o, ok128+}129+130+// validate checks a value against the option's kind and Check.131+func (o *Option) validate(v string) error {132+ if v == "" {133+ return nil // empty resets to the default134+ }135+ switch o.Kind {136+ case Bool:137+ if _, err := strconv.ParseBool(v); err != nil {138+ return fmt.Errorf("%q isn't true or false", v)139+ }140+ case Enum:141+ if !contains(o.Values, v) {142+ return fmt.Errorf("%q isn't one of %s", v, strings.Join(o.Values, ", "))143+ }144+ case URL:145+ u, err := url.Parse(v)146+ if err != nil || u.Host == "" {147+ return fmt.Errorf("%q isn't a URL", v)148+ }149+ case Command:150+ if _, err := SplitCommand(v); err != nil {151+ return err152+ }153+ }154+ if o.Check != nil {155+ return o.Check(v)156+ }157+ return nil158+}159+160+func contains(list []string, s string) bool {161+ for _, v := range list {162+ if v == s {163+ return true164+ }165+ }166+ return false167+}168+169+// CheckBaseURL accepts https URLs, and http only for this machine, so a170+// config can't send the API key anywhere in the clear.171+func CheckBaseURL(v string) error {172+ u, err := url.Parse(v)173+ if err != nil || u.Host == "" {174+ return fmt.Errorf("%q isn't a URL", v)175+ }176+ switch {177+ case u.Scheme == "https":178+ return nil179+ case u.Scheme == "http" && IsLocalhost(u.Hostname()):180+ return nil181+ }182+ return errors.New("the JEV base URL must be https (http only for localhost)")183+}184+185+// IsLocalhost reports whether host names this machine.186+func IsLocalhost(host string) bool {187+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {188+ return true189+ }190+ ip := net.ParseIP(host)191+ return ip != nil && ip.IsLoopback()192+}193+194+// checkTheme checks the shape of a theme value: a name, or light: and195+// dark: names. Whether the names exist is known only when it's loaded.196+func checkTheme(v string) error {197+ _, err := ParseThemeChoice(v)198+ return err199+}200+201+// ThemeChoice is the theme value: one theme, or one for each kind of202+// terminal background.203+type ThemeChoice struct{ Light, Dark string }204+205+// Pick returns the theme for a dark or light terminal.206+func (c ThemeChoice) Pick(dark bool) string {207+ if dark {208+ return c.Dark209+ }210+ return c.Light211+}212+213+// ParseThemeChoice parses "name" or "light:A,dark:B" (either order).214+func ParseThemeChoice(v string) (ThemeChoice, error) {215+ v = strings.TrimSpace(v)216+ if v == "" {217+ v = "terminal"218+ }219+ if !strings.Contains(v, "light:") && !strings.Contains(v, "dark:") {220+ return ThemeChoice{Light: v, Dark: v}, nil221+ }222+ var c ThemeChoice223+ for part := range strings.SplitSeq(v, ",") {224+ k, name, ok := strings.Cut(strings.TrimSpace(part), ":")225+ name = strings.TrimSpace(name)226+ switch {227+ case !ok || name == "":228+ return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)229+ case k == "light":230+ c.Light = name231+ case k == "dark":232+ c.Dark = name233+ default:234+ return c, fmt.Errorf("theme %q: expected light:NAME,dark:NAME", v)235+ }236+ }237+ if c.Light == "" || c.Dark == "" {238+ return c, fmt.Errorf("theme %q: give both light: and dark:", v)239+ }240+ return c, nil241+}242+243+// String is the choice as written in the file.244+func (c ThemeChoice) String() string {245+ if c.Light == c.Dark {246+ return c.Light247+ }248+ return "light:" + c.Light + ",dark:" + c.Dark249+}250+251+// redactURL hides a URL's password and query, which can carry tokens.252+func redactURL(v string) string {253+ u, err := url.Parse(v)254+ if err != nil {255+ return "(hidden)"256+ }257+ if u.User != nil {258+ u.User = url.User("xxxxx")259+ }260+ if u.RawQuery != "" {261+ u.RawQuery = "xxxxx"262+ }263+ return u.String()264+}265+266+// SplitCommand splits a command line into words, as a shell would267+// without expanding anything: spaces separate words, and single or268+// double quotes keep spaces inside one (a backslash escapes the next269+// character outside single quotes). No variables, globs or pipes.270+func SplitCommand(s string) ([]string, error) {271+ var words []string272+ var cur strings.Builder273+ inWord := false274+ var quote rune275+ escaped := false276+ for _, r := range s {277+ switch {278+ case escaped:279+ cur.WriteRune(r)280+ escaped = false281+ case r == '\\' && quote != '\'':282+ escaped, inWord = true, true283+ case quote != 0 && r == quote:284+ quote = 0285+ case quote != 0:286+ cur.WriteRune(r)287+ case r == '\'' || r == '"':288+ quote, inWord = r, true289+ case r == ' ' || r == '\t':290+ if inWord {291+ words = append(words, cur.String())292+ cur.Reset()293+ inWord = false294+ }295+ default:296+ cur.WriteRune(r)297+ inWord = true298+ }299+ }300+ if quote != 0 || escaped {301+ return nil, fmt.Errorf("command %q: unclosed quote", s)302+ }303+ if inWord {304+ words = append(words, cur.String())305+ }306+ if len(words) == 0 {307+ return nil, errors.New("empty command")308+ }309+ return words, nil310+}