diff options
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | docs/configuration.md | 1 | ||||
| -rw-r--r-- | docs/usage.md | 1 | ||||
| -rw-r--r-- | internal/editor/editor.go | 20 | ||||
| -rw-r--r-- | internal/editor/editor_test.go | 42 | ||||
| -rw-r--r-- | internal/hexaicli/runner.go | 25 | ||||
| -rw-r--r-- | internal/hexaicli/runner_test.go | 55 | ||||
| -rw-r--r-- | internal/version.go | 2 |
8 files changed, 146 insertions, 1 deletions
@@ -12,6 +12,7 @@ It has got improved capabilities for Go code understanding (for example, create * LSP AI Code actions * LSP in-editor chat with the LLM * Stand-alone command line tool for LLM interaction + - `hexai config` opens the global `config.toml` in `$HEXAI_EDITOR` or `$EDITOR` (supports `--config` for an alternate file) - Includes `--tps-simulation` to preview how fast a model would feel by streaming placeholder text or piped stdin at a chosen token-per-second rate * Task management CLI for agent-managed project work - Entrypoint: `ask` (the binary was briefly named `do`; use `ask` in scripts and documentation) diff --git a/docs/configuration.md b/docs/configuration.md index 573d8e7..81de647 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -5,6 +5,7 @@ This page explains where the config lives and how to choose a style; the authori Global config file - Location: `$XDG_CONFIG_HOME/hexai/config.toml` (usually `~/.config/hexai/config.toml`). +- Edit from a terminal: `hexai config` opens that file in `$HEXAI_EDITOR` or `$EDITOR` (or `hexai --config /path/to.toml config` for a specific file). - Style: sectioned tables only — see [config.toml.example](../config.toml.example) for a complete, commented reference. Per-project config file diff --git a/docs/usage.md b/docs/usage.md index 096b93a..40e8e82 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -81,6 +81,7 @@ Process text via the configured LLM: - `cat SOMEFILE.txt | hexai` - `hexai 'some prompt text here'` - `cat SOMEFILE.txt | hexai 'some prompt text here'` (stdin and arg are concatenated) +- `hexai config` opens the global config file in `$HEXAI_EDITOR` or `$EDITOR` (use `hexai --config /path/to.toml config` for another file) - `hexai --tps-simulation 12-18` to simulate model output speed without calling a provider Defaults: concise answers. If the prompt asks for commands, Hexai outputs only commands. Add the word `explain` to request a verbose explanation. Exit codes: `0` success, `1` provider/config error, `2` no input`. diff --git a/internal/editor/editor.go b/internal/editor/editor.go index a1af1be..722e336 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -68,3 +68,23 @@ func OpenTempAndEdit(initial []byte) (string, error) { } return strings.TrimSpace(string(b)), nil } + +// OpenFile ensures the parent directory exists, then opens path in the editor +// from Resolve() (HEXAI_EDITOR or EDITOR). +func OpenFile(path string) error { + ed, err := Resolve() + if err != nil { + return err + } + path = filepath.Clean(strings.TrimSpace(path)) + if path == "" || path == "." { + return errors.New("config path is empty") + } + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + return mkErr + } + } + return RunEditor(ed, path) +} diff --git a/internal/editor/editor_test.go b/internal/editor/editor_test.go index 260fb85..403d165 100644 --- a/internal/editor/editor_test.go +++ b/internal/editor/editor_test.go @@ -182,3 +182,45 @@ func TestOpenTempAndEdit_TempFileCleanup(t *testing.T) { t.Fatalf("temp file was not cleaned up: %s", capturedPath) } } + +func TestOpenFile_CreatesParentAndInvokesEditor(t *testing.T) { + old := RunEditor + t.Cleanup(func() { RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "dummy") + target := filepath.Join(t.TempDir(), "nested", "config.toml") + var gotEditor, gotPath string + RunEditor = func(editorCmd, path string) error { + gotEditor = editorCmd + gotPath = path + return nil + } + if err := OpenFile(target); err != nil { + t.Fatalf("OpenFile: %v", err) + } + if gotEditor != "dummy" { + t.Fatalf("editor = %q", gotEditor) + } + if gotPath != target { + t.Fatalf("path = %q, want %q", gotPath, target) + } + if _, err := os.Stat(filepath.Dir(target)); err != nil { + t.Fatalf("parent dir: %v", err) + } +} + +func TestOpenFile_NoEditor(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "") + t.Setenv("EDITOR", "") + err := OpenFile(filepath.Join(t.TempDir(), "x.toml")) + if err == nil { + t.Fatal("expected error when no editor is set") + } +} + +func TestOpenFile_EmptyPath(t *testing.T) { + t.Setenv("HEXAI_EDITOR", "true") + err := OpenFile(" ") + if err == nil { + t.Fatal("expected error for empty path") + } +} diff --git a/internal/hexaicli/runner.go b/internal/hexaicli/runner.go index f372021..eaae1cd 100644 --- a/internal/hexaicli/runner.go +++ b/internal/hexaicli/runner.go @@ -67,6 +67,31 @@ func NewRunner() *Runner { func (r *Runner) Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { runner := normalizeRunner(r) + if len(args) > 0 { + sub := args[0] + if sub == "config" { + if len(args) > 1 { + err := fmt.Errorf(`hexai %s: unexpected arguments (use only %q)`, sub, sub) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"%v"+logging.AnsiReset+"\n", err) + return err + } + cfgPath := strings.TrimSpace(configPathFromContext(ctx)) + if cfgPath == "" { + p, pathErr := appconfig.ConfigPath() + if pathErr != nil { + err := fmt.Errorf("hexai %s: %w", sub, pathErr) + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"%v"+logging.AnsiReset+"\n", err) + return err + } + cfgPath = p + } + if err := editor.OpenFile(cfgPath); err != nil { + _, _ = fmt.Fprintf(stderr, logging.AnsiBase+"hexai %s: %v"+logging.AnsiReset+"\n", sub, err) + return err + } + return nil + } + } if spec, ok, err := tpsSimulationFromContext(ctx); err != nil { _, _ = fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) return err diff --git a/internal/hexaicli/runner_test.go b/internal/hexaicli/runner_test.go index 1d438b0..009af54 100644 --- a/internal/hexaicli/runner_test.go +++ b/internal/hexaicli/runner_test.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "log" + "path/filepath" "strings" "testing" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/editor" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/stats" ) @@ -59,3 +61,56 @@ func TestRunner_UsesInjectedDependencies(t *testing.T) { t.Fatalf("expected one global status update, got %d", sink.globalCalls) } } + +func TestRunner_ConfigSubcommand_OpensConfigFromContext(t *testing.T) { + old := editor.RunEditor + t.Cleanup(func() { editor.RunEditor = old }) + t.Setenv("EDITOR", "true") + var gotPath string + editor.RunEditor = func(_, path string) error { + gotPath = path + return nil + } + cfgFile := filepath.Join(t.TempDir(), "hexai", "config.toml") + ctx := WithCLIConfigPath(context.Background(), cfgFile) + runner := NewRunner() + if err := runner.Run(ctx, []string{"config"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatalf("Run: %v", err) + } + if gotPath != cfgFile { + t.Fatalf("opened %q, want %q", gotPath, cfgFile) + } +} + +func TestRunner_ConfigSubcommand_UsesXDGWhenNoOverride(t *testing.T) { + old := editor.RunEditor + t.Cleanup(func() { editor.RunEditor = old }) + t.Setenv("HEXAI_EDITOR", "true") + xdg := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdg) + var gotPath string + editor.RunEditor = func(_, path string) error { + gotPath = path + return nil + } + runner := NewRunner() + want := filepath.Join(xdg, "hexai", "config.toml") + if err := runner.Run(context.Background(), []string{"config"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatalf("Run: %v", err) + } + if gotPath != want { + t.Fatalf("opened %q, want %q", gotPath, want) + } +} + +func TestRunner_ConfigSubcommand_RejectsExtraArgs(t *testing.T) { + runner := NewRunner() + var stderr bytes.Buffer + err := runner.Run(context.Background(), []string{"config", "nope"}, strings.NewReader(""), &bytes.Buffer{}, &stderr) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "unexpected arguments") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/version.go b/internal/version.go index 4fa574f..4305500 100644 --- a/internal/version.go +++ b/internal/version.go @@ -1,4 +1,4 @@ // Package internal provides the Hexai semantic version identifier used by CLI and LSP binaries. package internal -const Version = "0.38.3" +const Version = "0.38.4" |
