OneChat is a Go program. It prints its name. It is in early development.
Linux is supported.
+****************
+ Data directory
+****************
+
+OneChat keeps its data and its token file in one directory. It uses the
+first of these:
+
+#. ``ONECHAT_DIR``, when it is set.
+#. ``$XDG_DATA_HOME/onechat``, when ``XDG_DATA_HOME`` is an absolute
+ path. A relative value is ignored.
+#. ``~/.local/share/onechat``. With no home directory, OneChat exits
+ with an error.
+
+OneChat creates the directory with mode 0700.
+
+***************
+ Configuration
+***************
+
+``config.json`` in the data directory holds the settings. OneChat does
+not create it. A missing file or field takes the default, and an unknown
+field is an error.
+
+.. code:: json
+
+ {
+ "endpoint": "chatgpt",
+ "compactor": {"model": "gpt-6-luna", "effort": "high"}
+ }
+
+``endpoint``
+ ``chatgpt`` (the default) sends model calls to the ChatGPT backend
+ with the subscription login. ``api`` sends them to the OpenAI API
+ with the key in ``OPENAI_API_KEY``, which must then be set. With
+ ``chatgpt``, OneChat ignores ``OPENAI_API_KEY``.
+
+``compactor``
+ The model and the reasoning effort of the summaries. The defaults are
+ ``gpt-6-luna`` and ``high``.
+
*************
Development
*************
--- /dev/null
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+const (
+ // endpointChatGPT names the ChatGPT backend with the subscription login.
+ endpointChatGPT = "chatgpt"
+
+ // endpointAPI names the OpenAI API with an API key.
+ endpointAPI = "api"
+)
+
+// Config holds the settings of config.json in the data directory.
+type Config struct {
+ Endpoint string `json:"endpoint"`
+ Compactor ModelConfig `json:"compactor"`
+}
+
+// ModelConfig names a model and its reasoning effort.
+type ModelConfig struct {
+ Model string `json:"model"`
+ Effort string `json:"effort"`
+}
+
+// defaultConfig returns the settings that apply when config.json does not
+// set them.
+func defaultConfig() Config {
+ return Config{
+ Endpoint: endpointChatGPT,
+ Compactor: ModelConfig{
+ Model: "gpt-6-luna",
+ Effort: "high",
+ },
+ }
+}
+
+// loadConfig reads config.json in dir over the defaults. A missing file gives
+// the defaults. An unknown field is an error, so a typo does not become a
+// silent default.
+func loadConfig(dir string) (Config, error) {
+ config := defaultConfig()
+ data, err := os.ReadFile(filepath.Join(dir, "config.json"))
+ if errors.Is(err, os.ErrNotExist) {
+ return config, nil
+ }
+
+ if err != nil {
+ return Config{}, fmt.Errorf("read config: %w", err)
+ }
+
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ err = decoder.Decode(&config)
+ if err == nil && !errors.Is(decoder.Decode(&struct{}{}), io.EOF) {
+ err = errors.New("data after the JSON object")
+ }
+
+ if err == nil {
+ err = config.validate()
+ }
+
+ if err != nil {
+ return Config{}, fmt.Errorf("config.json: %w", err)
+ }
+
+ return config, nil
+}
+
+// validate makes sure that each setting has a valid value.
+func (c Config) validate() error {
+ if c.Endpoint != endpointChatGPT && c.Endpoint != endpointAPI {
+ return fmt.Errorf("endpoint %q is not %q or %q", c.Endpoint, endpointChatGPT, endpointAPI)
+ }
+
+ if c.Compactor.Model == "" || c.Compactor.Effort == "" {
+ return errors.New("compactor needs a model and an effort")
+ }
+
+ return nil
+}
+
+// dataDir returns the data directory and creates it with mode 0700 when it
+// does not exist. It is ONECHAT_DIR when that is set. Otherwise it is
+// $XDG_DATA_HOME/onechat when XDG_DATA_HOME is an absolute path, or else
+// ~/.local/share/onechat.
+func dataDir() (string, error) {
+ dir := os.Getenv("ONECHAT_DIR")
+ if dir == "" {
+ var err error
+ dir, err = defaultDataDir()
+ if err != nil {
+ return "", err
+ }
+ }
+
+ err := os.MkdirAll(dir, 0o700)
+ if err != nil {
+ return "", fmt.Errorf("create data directory: %w", err)
+ }
+
+ return dir, nil
+}
+
+// defaultDataDir returns the data directory for an unset ONECHAT_DIR. The XDG
+// specification says to ignore a relative XDG_DATA_HOME.
+func defaultDataDir() (string, error) {
+ xdg := os.Getenv("XDG_DATA_HOME")
+ if filepath.IsAbs(xdg) {
+ return filepath.Join(xdg, "onechat"), nil
+ }
+
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("find data directory: set ONECHAT_DIR: %w", err)
+ }
+
+ return filepath.Join(home, ".local", "share", "onechat"), nil
+}
--- /dev/null
+package main
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoadConfigGivesDefaultsWithoutAFile(t *testing.T) {
+ dir := t.TempDir()
+ config, err := loadConfig(dir)
+ require.NoError(t, err)
+ require.Equal(t, defaultConfig(), config)
+ require.NoFileExists(t, filepath.Join(dir, "config.json"))
+}
+
+func TestLoadConfigKeepsDefaultsForMissingFields(t *testing.T) {
+ dir := t.TempDir()
+ writeConfig(t, dir, `{"endpoint": "api", "compactor": {"effort": "xhigh"}}`)
+
+ config, err := loadConfig(dir)
+ require.NoError(t, err)
+ require.Equal(t, Config{
+ Endpoint: endpointAPI,
+ Compactor: ModelConfig{
+ Model: "gpt-6-luna",
+ Effort: "xhigh",
+ },
+ }, config)
+}
+
+func TestLoadConfigRejectsInvalidFiles(t *testing.T) {
+ testCases := []struct {
+ name string
+ text string
+ }{
+ {"unknown field", `{"endpont": "api"}`},
+ {"unknown nested field", `{"compactor": {"modle": "x"}}`},
+ {"endpoint URL", `{"endpoint": "https://example.com/v1/responses"}`},
+ {"empty model", `{"compactor": {"model": ""}}`},
+ {"second object", `{} {}`},
+ {"broken JSON", `{"endpoint": `},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ dir := t.TempDir()
+ writeConfig(t, dir, tc.text)
+
+ _, err := loadConfig(dir)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestDataDirFollowsTheOrder(t *testing.T) {
+ root := t.TempDir()
+ testCases := []struct {
+ name string
+ dir string
+ xdg string
+ home string
+ want string
+ }{
+ {"ONECHAT_DIR first", filepath.Join(root, "a"), filepath.Join(root, "xdg"), root, filepath.Join(root, "a")},
+ {"absolute XDG_DATA_HOME", "", filepath.Join(root, "xdg"), root, filepath.Join(root, "xdg", "onechat")},
+ {"relative XDG_DATA_HOME ignored", "", "xdg", root, filepath.Join(root, ".local", "share", "onechat")},
+ {"home directory last", "", "", root, filepath.Join(root, ".local", "share", "onechat")},
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", tc.dir)
+ t.Setenv("XDG_DATA_HOME", tc.xdg)
+ t.Setenv("HOME", tc.home)
+
+ dir, err := dataDir()
+ require.NoError(t, err)
+ require.Equal(t, tc.want, dir)
+
+ info, err := os.Stat(dir)
+ require.NoError(t, err)
+ require.Equal(t, os.FileMode(0o700), info.Mode().Perm())
+ })
+ }
+}
+
+func TestDataDirFailsWithoutAHome(t *testing.T) {
+ t.Setenv("ONECHAT_DIR", "")
+ t.Setenv("XDG_DATA_HOME", "")
+ t.Setenv("HOME", "")
+
+ _, err := dataDir()
+ require.ErrorContains(t, err, "ONECHAT_DIR")
+}
+
+func writeConfig(t *testing.T, dir, text string) {
+ t.Helper()
+
+ err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(text), 0o600)
+ require.NoError(t, err)
+}