diff --git a/lib/config-dir.go b/lib/config-dir.go new file mode 100644 index 0000000..f8821bf --- /dev/null +++ b/lib/config-dir.go @@ -0,0 +1,50 @@ +// Copyright 2026 Blues Inc. All rights reserved. +// Use of this source code is governed by licenses granted by the copyright holder. + +package lib + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// ConfigDir returns the directory holding the configuration, creating it if needed. +// It is "note" within $XDG_CONFIG_HOME, or ~/.config when that isn't set, and within +// %AppData% on Windows. +func ConfigDir() (string, error) { + base, err := configBaseDir() + if err != nil { + return "", fmt.Errorf("can't find the configuration directory: %w", err) + } + path := filepath.Join(base, "note") + if err := os.MkdirAll(path, 0700); err != nil { + return "", fmt.Errorf("can't create the configuration directory: %w", err) + } + return path, nil +} + +func configBaseDir() (string, error) { + if runtime.GOOS == "windows" { + return os.UserConfigDir() + } + // A relative XDG_CONFIG_HOME is invalid and is ignored, as the XDG spec requires + if dir := os.Getenv("XDG_CONFIG_HOME"); filepath.IsAbs(dir) { + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config"), nil +} + +// Get the pathname of config settings +func defaultConfigSettingsPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "config.json"), nil +} diff --git a/lib/config-refresh_test.go b/lib/config-refresh_test.go index 15180c4..ae84aad 100644 --- a/lib/config-refresh_test.go +++ b/lib/config-refresh_test.go @@ -30,7 +30,7 @@ func refreshTestConfig(t *testing.T, expires time.Time) *ConfigSettings { oldPath, oldHub := configSettingsPath, configSavedHub t.Cleanup(func() { configSettingsPath, configSavedHub = oldPath, oldHub }) path := filepath.Join(t.TempDir(), "config.json") - configSettingsPath = func() string { return path } + configSettingsPath = func() (string, error) { return path, nil } configSavedHub = "api.other.example" t.Setenv(ConfigTokenEnvVar, "") settings := &ConfigSettings{Hub: "api.example.com", HubCreds: map[string]ConfigCreds{ @@ -78,7 +78,7 @@ func TestCredentialRefresh(t *testing.T) { } } t.Setenv(ConfigTokenEnvVar, test.env) - before, _ := os.ReadFile(configSettingsPath()) + before, _ := os.ReadFile(testConfigPath(t)) calls := 0 original := http.DefaultTransport t.Cleanup(func() { http.DefaultTransport = original }) @@ -105,7 +105,7 @@ func TestCredentialRefresh(t *testing.T) { if test.status == 400 && !errors.Is(err, notehub.ErrRefreshTokenRejected) { t.Fatalf("lost rejection: %v", err) } - after, _ := os.ReadFile(configSettingsPath()) + after, _ := os.ReadFile(testConfigPath(t)) if !changed { if !bytes.Equal(before, after) { t.Error("changed credentials without a successful refresh") @@ -237,7 +237,7 @@ func TestConfigRefreshProcess(t *testing.T) { if path == "" { return } - configSettingsPath = func() string { return path } + configSettingsPath = func() (string, error) { return path, nil } settings, err := readConfigFromFile() if err != nil { t.Fatal(err) @@ -291,7 +291,7 @@ func TestConcurrentProcessRefresh(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) t.Cleanup(cancel) c := &child{cmd: exec.CommandContext(ctx, executable, "-test.run=^TestConfigRefreshProcess$")} - c.cmd.Env = append(os.Environ(), "NOTECLI_REFRESH_TEST_PATH="+configSettingsPath(), "NOTECLI_REFRESH_TEST_URL="+server.URL) + c.cmd.Env = append(os.Environ(), "NOTECLI_REFRESH_TEST_PATH="+testConfigPath(t), "NOTECLI_REFRESH_TEST_URL="+server.URL) c.cmd.Stderr = &c.stderr c.stdin, err = c.cmd.StdinPipe() if err != nil { @@ -324,3 +324,13 @@ func TestConcurrentProcessRefresh(t *testing.T) { t.Fatalf("expected one renewal, got %d", calls.Load()) } } + +// testConfigPath returns the configuration path that the test installed +func testConfigPath(t *testing.T) string { + t.Helper() + path, err := configSettingsPath() + if err != nil { + t.Fatal(err) + } + return path +} diff --git a/lib/config-storage.go b/lib/config-storage.go index 4ae0c6e..07d2219 100644 --- a/lib/config-storage.go +++ b/lib/config-storage.go @@ -20,7 +20,11 @@ var configSettingsPath = defaultConfigSettingsPath func withConfigLock(ctx context.Context, fn func() error) error { ctx, cancel := context.WithTimeout(ctx, 45*time.Second) defer cancel() - file, err := os.OpenFile(configSettingsPath()+".lock", os.O_CREATE|os.O_RDWR, 0600) + path, err := configSettingsPath() + if err != nil { + return err + } + file, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) if err != nil { return fmt.Errorf("locking configuration: %w", err) } @@ -44,7 +48,10 @@ func withConfigLock(ctx context.Context, fn func() error) error { // writeConfigJSON replaces a complete file atomically, so readers never see half // of a rotated token pair. The caller holds the configuration lock. func writeConfigJSON(contents []byte) error { - path := configSettingsPath() + path, err := configSettingsPath() + if err != nil { + return err + } file, err := os.CreateTemp(filepath.Dir(path), ".config-*") if err != nil { return fmt.Errorf("saving configuration: %w", err) diff --git a/lib/config-unix.go b/lib/config-unix.go deleted file mode 100644 index d143911..0000000 --- a/lib/config-unix.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2019 Blues Inc. All rights reserved. -// Use of this source code is governed by licenses granted by the -// copyright holder including that found in the LICENSE file. - -//go:build !windows -// +build !windows - -package lib - -import ( - "os" - "os/user" -) - -// ConfigDir returns the config directory -func ConfigDir() string { - usr, err := user.Current() - if err != nil { - return "." - } - path := usr.HomeDir + "/note" - os.MkdirAll(path, 0777) - return path -} - -// Get the pathname of config settings -func defaultConfigSettingsPath() string { - return ConfigDir() + "/config.json" -} diff --git a/lib/config-windows.go b/lib/config-windows.go deleted file mode 100644 index 39980a1..0000000 --- a/lib/config-windows.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2019 Blues Inc. All rights reserved. -// Use of this source code is governed by licenses granted by the -// copyright holder including that found in the LICENSE file. - -//go:build windows -// +build windows - -package lib - -import ( - "os" - "os/user" -) - -// ConfigDir gets the default directory -func ConfigDir() string { - usr, err := user.Current() - if err != nil { - return "." - } - path := usr.HomeDir + "\\note" - os.MkdirAll(path, 0777) - return path -} - -// Get the pathname of config settings -func defaultConfigSettingsPath() string { - return ConfigDir() + "\\config.json" -} diff --git a/lib/config.go b/lib/config.go index 1ea0464..e71b318 100644 --- a/lib/config.go +++ b/lib/config.go @@ -445,7 +445,10 @@ func defaultConfig() *ConfigSettings { // returns (nil, non-nil) if there was some any other error func readConfigFromFile() (*ConfigSettings, error) { // Read the config file - configPath := configSettingsPath() + configPath, err := configSettingsPath() + if err != nil { + return nil, err + } contents, err := os.ReadFile(configPath) if os.IsNotExist(err) { diff --git a/notehub/help-agent.md b/notehub/help-agent.md index 1964989..932afcc 100644 --- a/notehub/help-agent.md +++ b/notehub/help-agent.md @@ -16,9 +16,7 @@ credentials and OAuth renewal when available. Your workflow is: lead the user through authentication, fetch the deployed OpenAPI specification, identify the relevant operations and resources, make -requests, and report the results. The default API base is -`https://api.notefile.net`. Use the selected hub consistently for authentication, -discovery, and requests; select another hub only when the user's task calls for it. +requests through the CLI, and report the results. ## 2. Lead the user through authentication @@ -29,7 +27,7 @@ notehub auth status --json ``` Read the single result object's `signed_in` boolean; exit code 0 alone does not -mean signed in. If true, check the reported `hub` and `user`, then continue with +mean signed in. If true, check the reported `user`, then continue with the task. Otherwise inspect `reason` and `action`. A network or temporary service failure calls for retrying the check, not a new login. When sign-in is needed, the suggested `action` is `notehub login`; as an agent, use the `--json` workflow @@ -38,10 +36,12 @@ below. ### Preferred: guide the user through sign-in Tell the user that you will provide a link for them to approve Notehub access. -Use a recognizable name for your agent: +Use the name of the agent harness you are running in, such as `Grok Bot`, `Muse`, +`Claude` or `Codex`, so the user can recognize which agent is requesting access. +Replace `YOUR_HARNESS_NAME` with that actual name: ```sh -notehub login --name "My Agent" --json +notehub login --name "YOUR_HARNESS_NAME" --json ``` This command opens no browser and requires no terminal input. The user opens @@ -53,7 +53,7 @@ the approval link in their own browser, which can be on another machine. 2. **When a message contains `url`, immediately give that exact link to the user.** In your own friendly words, ask them to open it in their browser, sign in, and approve access for your named agent. For example: - “Please open this link in your browser to complete sign-in for My Agent. + “Please open this link in your browser to complete sign-in. I'll keep watching here and continue once it's approved.” The user does not need to copy anything back to you. 3. **Keep monitoring the same process's stdout.** Messages with @@ -106,24 +106,12 @@ an `Authorization` header yourself. ## 3. Discover the entire public API -Fetch the deployed OpenAPI YAML from -[https://api.notefile.net/openapi.yaml](https://api.notefile.net/openapi.yaml): - -```sh -curl -fsSL 'https://api.notefile.net/openapi.yaml' -o notehub-openapi.yaml -``` - -The CLI can fetch the selected hub's specification too: +Fetch the deployed OpenAPI YAML through the CLI: ```sh notehub api --spec > notehub-openapi.yaml ``` -Both fetches are public and need no login. For the default hub they retrieve -the same specification. If the task targets another hub, fetch that hub's spec -with `--spec --hub HUB` and use the same `--hub HUB` for subsequent commands. -The spec must describe the service you are actually calling. - **Use the live YAML as the complete catalog of public API capabilities.** The CLI's named modes are convenient entry points; `notehub api` can call the other operations described in the spec too, without a dedicated CLI command. @@ -155,14 +143,13 @@ Read the full operation, using a YAML parser when helpful: and any permission or resource annotations. Keep using the CLI's login rather than implementing a separate credential exchange from the spec. -Cache the YAML for the task and refresh it when changing hubs or when an -operation appears to have changed. Discover paths and schemas from this file +Cache the YAML for the task. Refresh it when a response contradicts the contract. +Discover paths and schemas from this file instead of inventing endpoint names or relying on deprecated APIs. ## 4. Use the CLI as an authenticated request proxy -For example, an authenticated GET to -`https://api.notefile.net/v1/projects` becomes: +For example, list the user's projects: ```sh notehub api GET /v1/projects @@ -188,8 +175,8 @@ Quote paths containing braces or query strings. | Response status and headers | `--include` | | Save the response body | Shell redirection, such as `> response.json` | -The CLI supplies authentication for each request using the selected hub's saved -login or `NOTEHUB_TOKEN`. You do not need to extract a token, add an authorization +The CLI supplies authentication for each request using the saved login or +`NOTEHUB_TOKEN`. You do not need to extract a token, add an authorization header, or invoke curl for authenticated Notehub operations. ### Identify resources and fill paths @@ -350,4 +337,4 @@ For guidance on how the user might want to interact with their Notehub work, run notehub --agent-chat ``` -It prints the guide from `https://chat.notehub.io`; no login is needed. +It prints Notehub's conversation guide; no login is needed.