Skip to content

Commit 7759aca

Browse files
cpcloudclaude
andcommitted
feat(config): add config edit and jq-based config get
Restructure `micasa config` into subcommands: - `config get [filter]`: query config values using jq syntax via embedded gojq. Identity filter (`.`) shows annotated TOML. Scalars print bare, objects as TOML, arrays as JSON. Sensitive fields (api_key) and empty values are stripped. - `config edit`: opens the config file in $VISUAL/$EDITOR with proper POSIX shell splitting via shlex. Creates the config file with example TOML if it doesn't exist. Also switches editorBinary() in internal/app to use shlex for consistent secure shell splitting across the codebase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f923a61 commit 7759aca

14 files changed

Lines changed: 734 additions & 39 deletions

File tree

cmd/micasa/main.go

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"errors"
99
"fmt"
1010
"os"
11+
"os/exec"
1112
"path/filepath"
1213
"runtime/debug"
1314

@@ -26,8 +27,8 @@ var version = "dev"
2627
type cli struct {
2728
Run runCmd `cmd:"" default:"withargs" help:"Launch the TUI (default)."`
2829
Backup backupCmd `cmd:"" help:"Back up the database to a file."`
29-
Config configCmd `cmd:"" help:"Print config values or dump the full resolved config."`
30-
Version kong.VersionFlag ` help:"Show version and exit." name:"version"`
30+
Config configCmd `cmd:"" help:"Manage application configuration."`
31+
Version kong.VersionFlag ` help:"Show version and exit." name:"version"`
3132
}
3233

3334
type runCmd struct {
@@ -43,10 +44,16 @@ type backupCmd struct {
4344
}
4445

4546
type configCmd struct {
46-
Key string `arg:"" optional:"" help:"Dot-delimited config key (e.g. chat.llm.model, documents.max_file_size)."`
47-
Dump bool ` help:"Print the fully resolved config as TOML and exit."`
47+
Get configGetCmd `cmd:"" default:"withargs" help:"Query config values with a jq filter (default: identity)."`
48+
Edit configEditCmd `cmd:"" help:"Open the config file in an editor."`
4849
}
4950

51+
type configGetCmd struct {
52+
Filter string `arg:"" optional:"" help:"jq filter expression, e.g. .chat.llm.model or .extraction (default: identity)."`
53+
}
54+
55+
type configEditCmd struct{}
56+
5057
func main() {
5158
var c cli
5259
kctx := kong.Parse(&c,
@@ -203,22 +210,34 @@ func (cmd *runCmd) resolveDBPath() (string, error) {
203210
return data.DefaultDBPath()
204211
}
205212

206-
func (cmd *configCmd) Run() error {
207-
if !cmd.Dump && cmd.Key == "" {
208-
return fmt.Errorf("provide a config key or use --dump to print the full config")
209-
}
213+
func (cmd *configGetCmd) Run() error {
210214
cfg, err := config.Load()
211215
if err != nil {
212216
return fmt.Errorf("load config: %w", err)
213217
}
214-
if cmd.Dump {
215-
return cfg.ShowConfig(os.Stdout)
218+
return cfg.Query(os.Stdout, cmd.Filter)
219+
}
220+
221+
func (cmd *configEditCmd) Run() error {
222+
path := config.Path()
223+
if err := config.EnsureConfigFile(path); err != nil {
224+
return err
216225
}
217-
val, err := cfg.Get(cmd.Key)
226+
name, args, err := config.EditorCommand(path)
218227
if err != nil {
219228
return err
220229
}
221-
fmt.Println(val)
230+
c := exec.CommandContext( //nolint:gosec // user-controlled editor from $VISUAL/$EDITOR
231+
context.Background(),
232+
name,
233+
args...,
234+
)
235+
c.Stdin = os.Stdin
236+
c.Stdout = os.Stdout
237+
c.Stderr = os.Stderr
238+
if err := c.Run(); err != nil {
239+
return fmt.Errorf("run editor: %w", err)
240+
}
222241
return nil
223242
}
224243

cmd/micasa/main_test.go

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -147,36 +147,84 @@ func TestConfigCmd(t *testing.T) {
147147
t.Parallel()
148148
bin := buildTestBinary(t)
149149

150-
t.Run("LLMModel", func(t *testing.T) {
151-
cmd := exec.CommandContext(
152-
t.Context(),
153-
bin,
154-
"config",
155-
"chat.llm.model",
156-
)
150+
t.Run("GetScalar", func(t *testing.T) {
151+
cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm.model")
157152
out, err := cmd.CombinedOutput()
158-
require.NoError(t, err, "config chat.llm.model failed: %s", out)
153+
require.NoError(t, err, "config get .chat.llm.model failed: %s", out)
159154
got := strings.TrimSpace(string(out))
160155
assert.NotEmpty(t, got)
156+
assert.NotContains(t, got, `"`, "scalar should not be JSON-quoted")
161157
})
162158

163-
t.Run("UnknownKey", func(t *testing.T) {
164-
cmd := exec.CommandContext(
165-
t.Context(),
166-
bin,
167-
"config",
168-
"bogus.key",
169-
)
159+
t.Run("GetSection", func(t *testing.T) {
160+
cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm")
170161
out, err := cmd.CombinedOutput()
171-
require.Error(t, err)
172-
assert.Contains(t, string(out), "unknown config key")
162+
require.NoError(t, err, "config get .chat.llm failed: %s", out)
163+
s := string(out)
164+
assert.Contains(t, s, "model =")
165+
assert.Contains(t, s, "provider =")
166+
assert.NotContains(t, s, "api_key")
167+
})
168+
169+
t.Run("GetNull", func(t *testing.T) {
170+
cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".bogus")
171+
out, err := cmd.CombinedOutput()
172+
require.NoError(t, err, "config get .bogus failed: %s", out)
173+
assert.Equal(t, "null\n", string(out))
174+
})
175+
176+
t.Run("GetKeys", func(t *testing.T) {
177+
cmd := exec.CommandContext(t.Context(), bin, "config", "get", ".chat.llm | keys")
178+
out, err := cmd.CombinedOutput()
179+
require.NoError(t, err, "config get '.chat.llm | keys' failed: %s", out)
180+
assert.Contains(t, string(out), `"model"`)
181+
})
182+
183+
t.Run("GetDefaultShowConfig", func(t *testing.T) {
184+
cmd := exec.CommandContext(t.Context(), bin, "config", "get")
185+
out, err := cmd.CombinedOutput()
186+
require.NoError(t, err, "config get (no filter) failed: %s", out)
187+
assert.Contains(t, string(out), "[chat.llm]")
188+
assert.Contains(t, string(out), "model =")
173189
})
174190

175-
t.Run("MissingKey", func(t *testing.T) {
191+
t.Run("GetDefaultViaConfig", func(t *testing.T) {
176192
cmd := exec.CommandContext(t.Context(), bin, "config")
177193
out, err := cmd.CombinedOutput()
178-
require.Error(t, err)
179-
assert.Contains(t, string(out), "provide a config key or use --dump")
194+
require.NoError(t, err, "config (no args) failed: %s", out)
195+
assert.Contains(t, string(out), "[chat.llm]")
196+
assert.Contains(t, string(out), "model =")
197+
})
198+
199+
t.Run("EditCreatesConfig", func(t *testing.T) {
200+
tmpDir := t.TempDir()
201+
cmd := exec.CommandContext(t.Context(), bin, "config", "edit")
202+
cmd.Env = envWithEditor(tmpDir, noopEditor())
203+
out, err := cmd.CombinedOutput()
204+
require.NoError(t, err, "config edit failed: %s", out)
205+
206+
configPath := filepath.Join(tmpDir, "micasa", "config.toml")
207+
info, statErr := os.Stat(configPath)
208+
require.NoError(t, statErr, "config file should have been created")
209+
assert.Positive(t, info.Size(), "config file should not be empty")
210+
})
211+
212+
t.Run("EditExistingConfig", func(t *testing.T) {
213+
tmpDir := t.TempDir()
214+
dir := filepath.Join(tmpDir, "micasa")
215+
require.NoError(t, os.MkdirAll(dir, 0o750))
216+
configPath := filepath.Join(dir, "config.toml")
217+
original := "[locale]\ncurrency = \"EUR\"\n"
218+
require.NoError(t, os.WriteFile(configPath, []byte(original), 0o600))
219+
220+
cmd := exec.CommandContext(t.Context(), bin, "config", "edit")
221+
cmd.Env = envWithEditor(tmpDir, noopEditor())
222+
out, err := cmd.CombinedOutput()
223+
require.NoError(t, err, "config edit failed: %s", out)
224+
225+
content, readErr := os.ReadFile(configPath) //nolint:gosec // test reads its own temp file
226+
require.NoError(t, readErr)
227+
assert.Equal(t, original, string(content), "existing config should be untouched")
180228
})
181229
}
182230

@@ -350,3 +398,34 @@ func TestBackupCmd(t *testing.T) {
350398
assert.Contains(t, string(out), "not a micasa database")
351399
})
352400
}
401+
402+
// noopEditor returns an editor command that exits 0 without modifying
403+
// any files. On Windows this uses "cmd /c echo" (ignores extra args
404+
// safely); on Unix it uses "true".
405+
func noopEditor() string {
406+
if runtime.GOOS == "windows" {
407+
return "cmd /c echo"
408+
}
409+
return "true"
410+
}
411+
412+
// envWithEditor returns a copy of os.Environ() with EDITOR and VISUAL
413+
// replaced, and XDG_CONFIG_HOME set to configHome. This avoids the
414+
// first-occurrence-wins semantics that would let the parent's EDITOR
415+
// shadow the test's override.
416+
func envWithEditor(configHome, editor string) []string {
417+
var env []string
418+
for _, e := range os.Environ() {
419+
if strings.HasPrefix(e, "EDITOR=") ||
420+
strings.HasPrefix(e, "VISUAL=") ||
421+
strings.HasPrefix(e, "XDG_CONFIG_HOME=") {
422+
continue
423+
}
424+
env = append(env, e)
425+
}
426+
return append(env,
427+
"XDG_CONFIG_HOME="+configHome,
428+
"EDITOR="+editor,
429+
"VISUAL="+editor,
430+
)
431+
}

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
inherit version;
3434
src = ./.;
3535
subPackages = [ "cmd/micasa" ];
36-
vendorHash = "sha256-uIoFny9WbkirU2Sin1KKQPzdKKl3V6vfl4WaRgK8Ksk=";
36+
vendorHash = "sha256-x1Ar5Dnbpey4eRknnTvZkkRnSt9yz1/Crx1ksVHtPfs=";
3737
env.CGO_ENABLED = 0;
3838
preCheck = ''
3939
export HOME="$(mktemp -d)"

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ require (
1717
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
1818
github.com/charmbracelet/x/ansi v0.11.6
1919
github.com/dustin/go-humanize v1.0.1
20+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
2021
github.com/iancoleman/strcase v0.3.0
22+
github.com/itchyny/gojq v0.12.18
2123
github.com/lrstanley/bubblezone v1.0.0
2224
github.com/mozilla-ai/any-llm-go v0.9.0
2325
github.com/rmhubbert/bubbletea-overlay v0.6.5
2426
github.com/stretchr/testify v1.11.1
2527
github.com/tj/go-naturaldate v1.3.0
28+
golang.org/x/sys v0.42.0
2629
golang.org/x/term v0.40.0
2730
golang.org/x/text v0.34.0
2831
gorm.io/gorm v1.31.1
@@ -63,6 +66,7 @@ require (
6366
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
6467
github.com/gorilla/css v1.0.1 // indirect
6568
github.com/gorilla/websocket v1.5.3 // indirect
69+
github.com/itchyny/timefmt-go v0.1.7 // indirect
6670
github.com/jinzhu/inflection v1.0.0 // indirect
6771
github.com/jinzhu/now v1.1.5 // indirect
6872
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -99,7 +103,6 @@ require (
99103
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
100104
golang.org/x/net v0.51.0 // indirect
101105
golang.org/x/sync v0.20.0 // indirect
102-
golang.org/x/sys v0.42.0 // indirect
103106
google.golang.org/genai v1.49.0 // indirect
104107
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
105108
google.golang.org/grpc v1.79.2 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
104104
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
105105
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
106106
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
107+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
108+
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
107109
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
108110
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
109111
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
@@ -120,6 +122,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
120122
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
121123
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
122124
github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
125+
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
126+
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
127+
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
128+
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
123129
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
124130
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
125131
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=

internal/app/model.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
tea "github.com/charmbracelet/bubbletea"
2121
"github.com/charmbracelet/huh"
2222
"github.com/charmbracelet/lipgloss"
23+
"github.com/cpcloud/micasa/internal/config"
2324
"github.com/cpcloud/micasa/internal/data"
2425
"github.com/cpcloud/micasa/internal/extract"
2526
"github.com/cpcloud/micasa/internal/llm"
@@ -2852,8 +2853,12 @@ var tabKindIndex = func() map[TabKind]int {
28522853

28532854
// editorBinary returns the user's preferred editor binary and any extra
28542855
// arguments parsed from $EDITOR or $VISUAL (e.g. "code --wait" yields
2855-
// binary="code", args=["--wait"]). The binary is resolved via exec.LookPath
2856-
// to verify it exists and is executable.
2856+
// binary="code", args=["--wait"]). On non-Windows platforms, the command
2857+
// is split using POSIX shell-style parsing to handle quoted paths; on
2858+
// Windows, config.SplitEditorCommand uses windows.DecomposeCommandLine to
2859+
// support quoted paths and spaces, with a plain-whitespace split only as a
2860+
// fallback if parsing fails. The binary is resolved via exec.LookPath to
2861+
// verify it exists and is executable.
28572862
func editorBinary() (string, []string, error) {
28582863
raw := os.Getenv("EDITOR")
28592864
if raw == "" {
@@ -2865,7 +2870,15 @@ func editorBinary() (string, []string, error) {
28652870
)
28662871
}
28672872

2868-
parts := strings.Fields(raw)
2873+
parts, err := config.SplitEditorCommand(raw)
2874+
if err != nil {
2875+
return "", nil, fmt.Errorf("parse editor command: %w", err)
2876+
}
2877+
if len(parts) == 0 {
2878+
return "", nil, errors.New(
2879+
"no editor configured: set $EDITOR or $VISUAL to an executable (e.g. export EDITOR=vim)",
2880+
)
2881+
}
28692882
bin, err := exec.LookPath(parts[0])
28702883
if err != nil {
28712884
return "", nil, fmt.Errorf(

internal/config/config.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,8 @@ func (c Config) Get(key string) (string, error) {
582582
}
583583

584584
// getField walks a struct value using dot-delimited TOML tag names and returns
585-
// the leaf value as a string.
585+
// the leaf value as a string. Returns an error if the key resolves to a
586+
// section (struct) rather than a scalar value.
586587
func getField(v reflect.Value, key string) (string, error) {
587588
parts := strings.SplitN(key, ".", 2)
588589
tag := parts[0]
@@ -607,6 +608,18 @@ func getField(v reflect.Value, key string) (string, error) {
607608
return "", fmt.Errorf("key %q: %q is not a section", key, tag)
608609
}
609610

611+
// Reject sections -- use "config get" (e.g., "micasa config get .") instead.
612+
ft := f.Type
613+
if ft.Kind() == reflect.Pointer {
614+
ft = ft.Elem()
615+
}
616+
if isConfigSection(ft) {
617+
return "", fmt.Errorf(
618+
"%q is a config section, not a key -- use \"micasa config get\" or \"micasa config get .\" to see the full config",
619+
key,
620+
)
621+
}
622+
610623
// Leaf field -- format the value.
611624
return formatValue(fv)
612625
}

internal/config/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -951,7 +951,7 @@ func writeConfigPerm(t *testing.T, content string, perm os.FileMode) string {
951951
}
952952

953953
func TestPermissionWarningWithAPIKey(t *testing.T) {
954-
if runtime.GOOS == "windows" {
954+
if runtime.GOOS == "windows" { //nolint:goconst // standard runtime value
955955
t.Skip("os.Chmod is a no-op on Windows")
956956
}
957957
path := writeConfigPerm(t, `[chat.llm]

0 commit comments

Comments
 (0)