Skip to content

Commit a6883d8

Browse files
committed
add model-cli config command with INI file support
Introduce 'model-cli config' as a new top-level command with an interface and file format inspired by, but not referencing, 'git config'. - New cmd/cli/iniconfig package: parses and writes INI-style config files (section headers, subsections, boolean keys, inline comments, backslash escapes, quoted values, UTF-8 BOM). Writes are atomic via .lock + rename. - New 'config' command with subcommands: get, set, unset, list, edit. All subcommands accept --global (default per XDG_CONFIG_HOME or ~/.config/model-runner/config), --system (/etc/model-runner/config), and --file/-f flags. - Remove the 'config' alias from 'configure' to avoid a name collision; 'configure' remains hidden and undocumented for existing callers. - 'config' requires no running model-runner instance (pure local file I/O) and is registered outside the withStandaloneRunner group. - Parser: handle trailing comments on section headers ([core] # comment), raise a clear error on lines exceeding 1 MiB, preserve existing file permissions on write (default 0600 for new files). - Editor: split VISUAL/EDITOR on whitespace to support values like 'code --wait'. - Regenerate CLI reference docs.
1 parent 317e689 commit a6883d8

21 files changed

Lines changed: 1829 additions & 5 deletions

cmd/cli/commands/config.go

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
package commands
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"runtime"
9+
"strings"
10+
11+
"github.com/docker/model-runner/cmd/cli/iniconfig"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// defaultConfigPath returns the default (global/user-level) config file path.
16+
// It honours XDG_CONFIG_HOME when set:
17+
//
18+
// $XDG_CONFIG_HOME/model-runner/config
19+
// ~/.config/model-runner/config (fallback)
20+
func defaultConfigPath() string {
21+
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
22+
return filepath.Join(xdg, "model-runner", "config")
23+
}
24+
home, err := os.UserHomeDir()
25+
if err != nil {
26+
return filepath.Join(".config", "model-runner", "config")
27+
}
28+
return filepath.Join(home, ".config", "model-runner", "config")
29+
}
30+
31+
// systemConfigPath returns the system-wide config file path.
32+
func systemConfigPath() string {
33+
if runtime.GOOS == "windows" {
34+
if pd := os.Getenv("ProgramData"); pd != "" {
35+
return filepath.Join(pd, "model-runner", "config")
36+
}
37+
return `C:\ProgramData\model-runner\config`
38+
}
39+
return "/etc/model-runner/config"
40+
}
41+
42+
// resolveConfigPath picks the config file to operate on, given the flags.
43+
// Exactly one of global, system, or file may be set.
44+
func resolveConfigPath(global, system bool, file string) (string, error) {
45+
count := 0
46+
if global {
47+
count++
48+
}
49+
if system {
50+
count++
51+
}
52+
if file != "" {
53+
count++
54+
}
55+
if count > 1 {
56+
return "", fmt.Errorf("only one of --global, --system, or --file may be specified")
57+
}
58+
switch {
59+
case system:
60+
return systemConfigPath(), nil
61+
case file != "":
62+
return file, nil
63+
default:
64+
// --global is the default
65+
return defaultConfigPath(), nil
66+
}
67+
}
68+
69+
// addLocationFlags adds the standard --global/--system/--file flags to a command.
70+
func addLocationFlags(cmd *cobra.Command, global, system *bool, file *string) {
71+
cmd.Flags().BoolVar(global, "global", false, "use the global (user-level) config file")
72+
cmd.Flags().BoolVar(system, "system", false, "use the system-wide config file")
73+
cmd.Flags().StringVarP(file, "file", "f", "", "use a specific config file")
74+
}
75+
76+
// newConfigCmd returns the top-level "config" command.
77+
func newConfigCmd() *cobra.Command {
78+
c := &cobra.Command{
79+
Use: "config",
80+
Short: "Read and write model-runner config file values",
81+
Long: `Read and write model-runner config file values.
82+
83+
The config file uses an INI format with sections and key=value pairs:
84+
85+
[section]
86+
key = value
87+
[section "subsection"]
88+
key = value
89+
90+
Keys are specified in dot notation: section.key or section.subsection.key.
91+
92+
The default file is $XDG_CONFIG_HOME/model-runner/config, falling back to
93+
~/.config/model-runner/config when XDG_CONFIG_HOME is not set.
94+
95+
Examples:
96+
model-cli config set user.name "Alice"
97+
model-cli config get user.name
98+
model-cli config list
99+
model-cli config unset user.name
100+
model-cli config edit`,
101+
// Do not run a PersistentPreRunE that requires a running model-runner;
102+
// config is pure local-file work.
103+
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
104+
return nil
105+
},
106+
}
107+
108+
c.AddCommand(
109+
newConfigGetCmd(),
110+
newConfigSetCmd(),
111+
newConfigUnsetCmd(),
112+
newConfigListCmd(),
113+
newConfigEditCmd(),
114+
)
115+
return c
116+
}
117+
118+
// newConfigGetCmd implements "model-cli config get <key>".
119+
func newConfigGetCmd() *cobra.Command {
120+
var (
121+
global bool
122+
system bool
123+
file string
124+
defaultVal string
125+
hasDefault bool
126+
showAll bool
127+
showOrigin bool
128+
)
129+
130+
c := &cobra.Command{
131+
Use: "get <key>",
132+
Short: "Get the value of a config key",
133+
Long: `Get the value of a config key.
134+
135+
Prints the value of the given key to stdout. If the key appears multiple times
136+
(multi-valued), the last value is printed. Use --all to print all values.
137+
138+
Exit status is 1 if the key is not found (unless --default is given).`,
139+
Args: cobra.ExactArgs(1),
140+
RunE: func(cmd *cobra.Command, args []string) error {
141+
path, err := resolveConfigPath(global, system, file)
142+
if err != nil {
143+
return err
144+
}
145+
f, err := iniconfig.Load(path)
146+
if err != nil {
147+
return err
148+
}
149+
150+
key := args[0]
151+
152+
if showAll {
153+
vals := f.GetAll(key)
154+
if len(vals) == 0 {
155+
if hasDefault {
156+
cmd.Println(defaultVal)
157+
return nil
158+
}
159+
return fmt.Errorf("key not found: %s", key)
160+
}
161+
for _, v := range vals {
162+
if showOrigin {
163+
cmd.Printf("file:%s\t%s\n", path, v)
164+
} else {
165+
cmd.Println(v)
166+
}
167+
}
168+
return nil
169+
}
170+
171+
v, ok := f.Get(key)
172+
if !ok {
173+
if hasDefault {
174+
cmd.Println(defaultVal)
175+
return nil
176+
}
177+
return fmt.Errorf("key not found: %s", key)
178+
}
179+
if showOrigin {
180+
cmd.Printf("file:%s\t%s\n", path, v)
181+
} else {
182+
cmd.Println(v)
183+
}
184+
return nil
185+
},
186+
}
187+
188+
addLocationFlags(c, &global, &system, &file)
189+
c.Flags().StringVar(&defaultVal, "default", "", "value to emit if the key is not set")
190+
c.Flags().BoolVar(&showAll, "all", false, "print all values for multi-valued keys")
191+
c.Flags().BoolVar(&showOrigin, "show-origin", false, "show the origin (file path) of each value")
192+
// Track whether --default was explicitly provided.
193+
c.PreRunE = func(cmd *cobra.Command, args []string) error {
194+
hasDefault = cmd.Flags().Changed("default")
195+
return nil
196+
}
197+
198+
return c
199+
}
200+
201+
// newConfigSetCmd implements "model-cli config set <key> <value>".
202+
func newConfigSetCmd() *cobra.Command {
203+
var global, system bool
204+
var file string
205+
206+
c := &cobra.Command{
207+
Use: "set <key> <value>",
208+
Short: "Set a config key to a value",
209+
Long: `Set a config key to a value.
210+
211+
If the key already exists its value is replaced. The file is written atomically.`,
212+
Args: cobra.ExactArgs(2),
213+
RunE: func(cmd *cobra.Command, args []string) error {
214+
path, err := resolveConfigPath(global, system, file)
215+
if err != nil {
216+
return err
217+
}
218+
f, err := iniconfig.Load(path)
219+
if err != nil {
220+
return err
221+
}
222+
return f.Set(args[0], args[1])
223+
},
224+
}
225+
226+
addLocationFlags(c, &global, &system, &file)
227+
return c
228+
}
229+
230+
// newConfigUnsetCmd implements "model-cli config unset <key>".
231+
func newConfigUnsetCmd() *cobra.Command {
232+
var global, system bool
233+
var file string
234+
235+
c := &cobra.Command{
236+
Use: "unset <key>",
237+
Short: "Remove a config key",
238+
Long: `Remove a config key (and all its values) from the file.`,
239+
Args: cobra.ExactArgs(1),
240+
RunE: func(cmd *cobra.Command, args []string) error {
241+
path, err := resolveConfigPath(global, system, file)
242+
if err != nil {
243+
return err
244+
}
245+
f, err := iniconfig.Load(path)
246+
if err != nil {
247+
return err
248+
}
249+
return f.Unset(args[0])
250+
},
251+
}
252+
253+
addLocationFlags(c, &global, &system, &file)
254+
return c
255+
}
256+
257+
// newConfigListCmd implements "model-cli config list".
258+
func newConfigListCmd() *cobra.Command {
259+
var global, system bool
260+
var file string
261+
var showOrigin bool
262+
263+
c := &cobra.Command{
264+
Use: "list",
265+
Aliases: []string{"ls"},
266+
Short: "List all config key/value pairs",
267+
Long: `List all key=value pairs from the config file, one per line.`,
268+
Args: cobra.NoArgs,
269+
RunE: func(cmd *cobra.Command, args []string) error {
270+
path, err := resolveConfigPath(global, system, file)
271+
if err != nil {
272+
return err
273+
}
274+
f, err := iniconfig.Load(path)
275+
if err != nil {
276+
return err
277+
}
278+
if showOrigin {
279+
for _, e := range f.Entries() {
280+
cmd.Printf("file:%s\t%s=%s\n", path, e.Key, e.Value)
281+
}
282+
return nil
283+
}
284+
return f.List(cmd.OutOrStdout())
285+
},
286+
}
287+
288+
addLocationFlags(c, &global, &system, &file)
289+
c.Flags().BoolVar(&showOrigin, "show-origin", false, "show the origin (file path) of each value")
290+
return c
291+
}
292+
293+
// newConfigEditCmd implements "model-cli config edit".
294+
func newConfigEditCmd() *cobra.Command {
295+
var global, system bool
296+
var file string
297+
298+
c := &cobra.Command{
299+
Use: "edit",
300+
Short: "Open the config file in your editor",
301+
Long: `Open the config file in the default editor.
302+
303+
The editor is determined by the VISUAL or EDITOR environment variables,
304+
falling back to vi on Unix and notepad on Windows.`,
305+
Args: cobra.NoArgs,
306+
RunE: func(cmd *cobra.Command, args []string) error {
307+
path, err := resolveConfigPath(global, system, file)
308+
if err != nil {
309+
return err
310+
}
311+
// Ensure the file (and its parent directory) exist so the editor
312+
// has something to open.
313+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
314+
return err
315+
}
316+
if _, err := os.Stat(path); os.IsNotExist(err) {
317+
// Create with 0600 — config files may hold sensitive values.
318+
f, err2 := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
319+
if err2 != nil {
320+
return err2
321+
}
322+
_ = f.Close()
323+
}
324+
325+
editorStr := os.Getenv("VISUAL")
326+
if editorStr == "" {
327+
editorStr = os.Getenv("EDITOR")
328+
}
329+
if editorStr == "" {
330+
if runtime.GOOS == "windows" {
331+
editorStr = "notepad"
332+
} else {
333+
editorStr = "vi"
334+
}
335+
}
336+
337+
// VISUAL/EDITOR may contain arguments (e.g. "code --wait").
338+
parts := strings.Fields(editorStr)
339+
editorArgs := append(parts[1:], path)
340+
//nolint:gosec // editor is a user-controlled input, which is intentional
341+
editorCmd := exec.CommandContext(cmd.Context(), parts[0], editorArgs...)
342+
editorCmd.Stdin = os.Stdin
343+
editorCmd.Stdout = os.Stdout
344+
editorCmd.Stderr = os.Stderr
345+
return editorCmd.Run()
346+
},
347+
}
348+
349+
addLocationFlags(c, &global, &system, &file)
350+
return c
351+
}

cmd/cli/commands/configure.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@ func newConfigureCmd() *cobra.Command {
1111
var flags ConfigureFlags
1212

1313
c := &cobra.Command{
14-
Use: "configure [--context-size=<n>] [--speculative-draft-model=<model>] [--hf_overrides=<json>] [--gpu-memory-utilization=<float>] [--mode=<mode>] [--think] [--keep-alive=<duration>] MODEL [-- <runtime-flags...>]",
15-
Aliases: []string{"config"},
16-
Short: "Manage model runtime configurations",
17-
Hidden: true,
14+
Use: "configure [--context-size=<n>] [--speculative-draft-model=<model>] [--hf_overrides=<json>] [--gpu-memory-utilization=<float>] [--mode=<mode>] [--think] [--keep-alive=<duration>] MODEL [-- <runtime-flags...>]",
15+
Short: "Manage model runtime configurations",
16+
Hidden: true,
1817
Args: func(cmd *cobra.Command, args []string) error {
1918
argsBeforeDash := cmd.ArgsLenAtDash()
2019
if argsBeforeDash == -1 {

cmd/cli/commands/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func NewRootCmd(cli *command.DockerCli) *cobra.Command {
105105
newReinstallRunner(),
106106
newSearchCmd(),
107107
newSkillsCmd(),
108+
newConfigCmd(),
108109
)
109110
rootCmd.AddCommand(newGatewayCmd())
110111

0 commit comments

Comments
 (0)