Skip to content

add model-cli config command with INI file support - #892

Open
ericcurtin wants to merge 6 commits into
mainfrom
feat/model-cli-config
Open

ericcurtin wants to merge 6 commits into
mainfrom
feat/model-cli-config

Conversation

@ericcurtin

@ericcurtin ericcurtin commented Apr 28, 2026 •

Copy link
Copy Markdown
Contributor

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.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In unescapeSubsection the comment says unknown backslash escapes are an error, but the implementation silently drops the backslash (matching git); please update the comment to accurately describe the behavior or change the code to match the documented contract.
  • In parseValue the comment mentions line continuation via trailing backslash, but the function currently just treats a trailing backslash as end-of-value with no continuation; consider either implementing proper multi-line continuation or updating the comment to avoid implying continuation is supported.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `unescapeSubsection` the comment says unknown backslash escapes are an error, but the implementation silently drops the backslash (matching git); please update the comment to accurately describe the behavior or change the code to match the documented contract.
- In `parseValue` the comment mentions line continuation via trailing backslash, but the function currently just treats a trailing backslash as end-of-value with no continuation; consider either implementing proper multi-line continuation or updating the comment to avoid implying continuation is supported.

## Individual Comments

### Comment 1
<location path="cmd/cli/gitconfig/gitconfig.go" line_range="75-84" />
<code_context>
+	var entries []Entry
+	var section, subsection string
+
+	scanner := bufio.NewScanner(bytes.NewReader(data))
+	lineNum := 0
+	for scanner.Scan() {
+		lineNum++
+		line := scanner.Text()
+		trimmed := strings.TrimSpace(line)
+
+		// Empty line or comment.
+		if trimmed == "" || trimmed[0] == '#' || trimmed[0] == ';' {
+			continue
+		}
+
+		if trimmed[0] == '[' {
+			// Section header.
+			var err error
+			section, subsection, err = parseSectionHeader(trimmed)
+			if err != nil {
+				return nil, fmt.Errorf("line %d: %w", lineNum, err)
+			}
+			continue
+		}
+
+		// Key-value (or boolean key).
+		if section == "" {
+			return nil, fmt.Errorf("line %d: key outside of section", lineNum)
+		}
+		key, value, err := parseKeyValue(line)
+		if err != nil {
+			return nil, fmt.Errorf("line %d: %w", lineNum, err)
+		}
+		canonical := canonicalKey(section, subsection, key)
+		entries = append(entries, Entry{Key: canonical, Value: value})
+	}
+	return entries, scanner.Err()
+}
+
</code_context>
<issue_to_address>
**suggestion:** Consider guarding against very long lines when using bufio.Scanner

`bufio.Scanner` limits token size to 64KB; lines longer than that will cause `scanner.Err()` to be `ErrTooLong`. If long config values are possible (e.g., certificates, large tokens), consider increasing the buffer with `scanner.Buffer(..., maxCapacity)` and handling `ErrTooLong` explicitly rather than treating it as a generic parse error.

Suggested implementation:

```golang
import (
	"bufio"
	"bytes"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"unicode"
)

const maxConfigLineBytes = 1 << 20 // 1 MiB max line length for gitconfig lines

```

```golang
	var entries []Entry
	var section, subsection string

	scanner := bufio.NewScanner(bytes.NewReader(data))
	// Increase the scanner buffer to allow reasonably large config lines
	// (e.g. certificates, large tokens). The initial size is small, but
	// maxConfigLineBytes is the hard cap.
	scanner.Buffer(make([]byte, 0, 64*1024), maxConfigLineBytes)

	lineNum := 0
	for scanner.Scan() {
		lineNum++
		line := scanner.Text()
		trimmed := strings.TrimSpace(line)

		// Empty line or comment.
		if trimmed == "" || trimmed[0] == '#' || trimmed[0] == ';' {
			continue
		}

		if trimmed[0] == '[' {
			// Section header.
			var err error
			section, subsection, err = parseSectionHeader(trimmed)
			if err != nil {
				return nil, fmt.Errorf("line %d: %w", lineNum, err)
			}
			continue
		}

		// Key-value (or boolean key).
		if section == "" {
			return nil, fmt.Errorf("line %d: key outside of section", lineNum)
		}
		key, value, err := parseKeyValue(line)
		if err != nil {
			return nil, fmt.Errorf("line %d: %w", lineNum, err)
		}
		canonical := canonicalKey(section, subsection, key)
		entries = append(entries, Entry{Key: canonical, Value: value})
	}

	if err := scanner.Err(); err != nil {
		if errors.Is(err, bufio.ErrTooLong) {
			// The failing line is the next line after the last one we
			// successfully scanned.
			return nil, fmt.Errorf("line %d: config line too long (max %d bytes)", lineNum+1, maxConfigLineBytes)
		}
		return nil, err
	}

	return entries, nil

```
</issue_to_address>

### Comment 2
<location path="cmd/cli/gitconfig/gitconfig.go" line_range="206-208" />
<code_context>
+		case inQuotes && c == '"':
+			inQuotes = false
+			i++
+		case c == '\\':
+			if i+1 >= len(raw) {
+				// Trailing backslash = line continuation (we don't handle
+				// multi-line here; treat as end of value).
+				goto done
</code_context>
<issue_to_address>
**issue (bug_risk):** Line-continuation handling with trailing backslash may drop content silently

This logic treats a trailing backslash as "end of value" and discards both the backslash and any intended continuation, which diverges from git’s continuation semantics and can silently truncate values if a user adds a trailing `\`. Consider either implementing continuation by reading the next physical line(s), or treating an unterminated escape at end-of-line as a parse error to avoid silent data loss.
</issue_to_address>

### Comment 3
<location path="cmd/cli/gitconfig/gitconfig.go" line_range="405" />
<code_context>
+	newEntries := transform(append([]Entry(nil), f.entries...))
+
+	data := serialise(newEntries)
+	if err := os.WriteFile(lockPath, data, 0o644); err != nil {
+		return err
+	}
</code_context>
<issue_to_address>
**🚨 issue (security):** Writing with fixed 0644 permissions may weaken existing file permissions

Hardcoding 0644 means a previously more restrictive config (e.g., 0600 with secrets) will be relaxed on rewrite. Consider preserving the existing file mode when the file already exists, or defaulting to a more restrictive mode (e.g., 0600) for per-user configs. You can `os.Stat(f.path)` to reuse its mode and fall back to a secure default otherwise.
</issue_to_address>

### Comment 4
<location path="cmd/cli/commands/config.go" line_range="310-314" />
<code_context>
+			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+				return err
+			}
+			if _, err := os.Stat(path); os.IsNotExist(err) {
+				f, err2 := os.Create(path)
+				if err2 != nil {
+					return err2
+				}
+				_ = f.Close()
+			}
+
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Newly created config file in `config edit` likely should use restrictive permissions

`os.Create` will respect the umask but will usually produce a file around 0644, which is too permissive if this config can store tokens or other sensitive data. Please create the file with an explicit 0600 mode (e.g., via `os.OpenFile` with the desired permissions) so it behaves like a private user config file.

```suggestion
			// Ensure the file (and its parent directory) exist so the editor
			// has something to open.
			if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
				return err
			}
			if _, err := os.Stat(path); os.IsNotExist(err) {
				f, err2 := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
				if err2 != nil {
					return err2
				}
				_ = f.Close()
			}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread cmd/cli/iniconfig/iniconfig.go
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/commands/config.go

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new config command to the CLI, enabling users to read and write configuration values in a git-style INI format. The implementation includes a new gitconfig package for parsing and serializing these files, along with comprehensive tests. Key feedback includes a critical security concern regarding the preservation of file permissions during atomic writes, a parsing limitation where trailing comments on section headers cause failures, and an issue with the edit command failing when environment variables like EDITOR contain arguments.

Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment on lines +405 to +407
if err := os.WriteFile(lockPath, data, 0o644); err != nil {
return err
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

The writeAtomic function uses a hardcoded file mode of 0o644 when writing the config file. This is a security risk as it can make sensitive configuration files (e.g., those containing API keys or tokens) world-readable, even if the original file had more restrictive permissions (like 0600). The implementation should preserve the permissions of the existing file if it exists.

Suggested change
if err := os.WriteFile(lockPath, data, 0o644); err != nil {
return err
}
mode := os.FileMode(0o644)
if info, err := os.Stat(f.path); err == nil {
mode = info.Mode()
}
if err := os.WriteFile(lockPath, data, mode); err != nil {
return err
}

Comment thread cmd/cli/commands/config.go Outdated
}

//nolint:gosec // editor and path are user-controlled inputs, which is intentional
editorCmd := exec.CommandContext(cmd.Context(), editor, path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The edit command uses exec.CommandContext with the editor string directly as the executable name. This will fail if the VISUAL or EDITOR environment variables contain arguments (e.g., EDITOR="code --wait" or EDITOR="vim -R"). To support common user configurations, the command string should be parsed into an executable and its arguments before being passed to exec.CommandContext.

@ericcurtin
ericcurtin force-pushed the feat/model-cli-config branch 2 times, most recently from 9a8c63e to 741374a Compare April 28, 2026 12:12
@ericcurtin ericcurtin changed the title add model-cli config command with git-format INI file support add model-cli config command with INI file support Apr 28, 2026
@ericcurtin
ericcurtin force-pushed the feat/model-cli-config branch from 741374a to a6883d8 Compare April 28, 2026 12:22
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.
@ericcurtin
ericcurtin force-pushed the feat/model-cli-config branch from a6883d8 to 4017e46 Compare April 28, 2026 12:28
}
if count > 1 {
return "", fmt.Errorf("only one of --global, --system, or --file may be specified")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can do something like

count := 0
for _, configFlag := range []bool{global, system, file != ""} {
        if  configFlag{
            count++
        }
    }
    if count > 1 {
        return "", fmt.Errorf("only one of --global, --system, or --file may be specified")
    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, done in 72fb485.

Comment thread cmd/cli/commands/config.go Outdated
return nil
}

v, ok := f.Get(key)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove duplicate code and logic by using a slice to hold the values

key := args[0]

 var vals []string
 if showAll {
     vals = f.GetAll(key)
 } else if v, ok := f.Get(key); ok {
     vals = []string{v}
 }

 if len(vals) == 0 {
     if hasDefault {
         cmd.Println(defaultVal)
         return nil
     }
     return fmt.Errorf("key not found: %s", key)
 }

 for _, v := range vals {
     if showOrigin {
         cmd.Printf("file:%s\t%s\n", path, v)
     } else {
         cmd.Println(v)
     }
 }
 return nil
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, done in 72fb485.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical concurrent-write data loss and multiple moderate parser and editor issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 5 Medium severity

Open (6)
What changed in this PR

Adds a local INI-backed model config command with get, set, unset, list, and edit subcommands, plus generated documentation.

Changes:

  • Added INI parsing, serialization, persistence, and tests.
  • Registered config and removed the conflicting configure alias.
  • Regenerated CLI reference documentation.
File Summary
cmd/​cli/​iniconfig/​testmain_test.go Goroutine leak test setup.
cmd/​cli/​iniconfig/​iniconfig.go INI parser and writer; issues remain with locking, comments, quoting, subsection brackets, escapes, and the 1 MiB limit.
cmd/​cli/​iniconfig/​iniconfig_test.go Parser and persistence tests.
cmd/​cli/​docs/​reference/​model.md Documents the new command.
cmd/​cli/​docs/​reference/​model_config.md Config command reference.
cmd/​cli/​docs/​reference/​model_config_unset.md Unset subcommand reference.
cmd/​cli/​docs/​reference/​model_config_set.md Set subcommand reference.
cmd/​cli/​docs/​reference/​model_config_list.md List subcommand reference.
cmd/​cli/​docs/​reference/​model_config_get.md Get subcommand reference.
cmd/​cli/​docs/​reference/​model_config_edit.md Edit subcommand reference.
cmd/​cli/​docs/​reference/​docker_model.yaml Generated command metadata.
cmd/​cli/​docs/​reference/​docker_model_configure.yaml Updated configure metadata.
cmd/​cli/​docs/​reference/​docker_model_config.yaml Generated config metadata.
cmd/​cli/​docs/​reference/​docker_model_config_unset.yaml Generated unset metadata.
cmd/​cli/​docs/​reference/​docker_model_config_set.yaml Generated set metadata.
cmd/​cli/​docs/​reference/​docker_model_config_list.yaml Generated list metadata.
cmd/​cli/​docs/​reference/​docker_model_config_get.yaml Generated get metadata.
cmd/​cli/​docs/​reference/​docker_model_config_edit.yaml Generated edit metadata.
cmd/​cli/​commands/​root.go Registers the config command.
cmd/​cli/​commands/​configure.go Removes the conflicting alias.
cmd/​cli/​commands/​config.go Implements config subcommands; needs empty-editor handling and command-level tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread cmd/cli/iniconfig/iniconfig.go
Comment thread cmd/cli/commands/config.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved moderate issues affect parsing correctness, concurrent writes, output formatting, permissions, and editor handling.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 7 Medium severity

Open (8)

Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread cmd/cli/iniconfig/iniconfig.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread cmd/cli/iniconfig/iniconfig.go
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
if err := os.WriteFile(lockPath, data, mode); err != nil {
return err
}
if err := os.Rename(lockPath, f.path); err != nil {

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread cmd/cli/iniconfig/iniconfig.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed-off-by: Eric Curtin <eric.curtin@docker.com>
@ericcurtin

Copy link
Copy Markdown
Contributor Author

Pushed 72fb485: flag-count and get cleanups, plus fixes for empty VISUAL/EDITOR, file mode under umask, trailing backslash, ] in quoted subsections, and control chars in subsections. Tests added.

@ericcurtin

Copy link
Copy Markdown
Contributor Author

@sathiraumesh @ilopezluna Addressed your feedback, PTAL. Thank you!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved key validation, serialization, synchronization, line-limit, and Windows replacement issues remain.

Review effort: Lite
Findings: 4 High severity · 1 Medium severity

Open (5)
Resolved since last review (8)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate keys before applying default values

cmd/​cli/​commands/​config.go:155

Malformed keys are treated as missing because Get/GetAll swallow ParseKey errors. Consequently, config get --default fallback core..name returns fallback instead of rejecting the invalid key, unlike set and unset. Validate the key with iniconfig.ParseKey before querying so --default only applies to a valid but absent key.

Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Signed-off-by: Eric Curtin <eric.curtin@docker.com>
Signed-off-by: Eric Curtin <eric.curtin@docker.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate issues remain in persistence, parsing, formatting, and input validation.

Review effort: Lite
Findings: 3 High severity · 1 Medium severity

Open (4)
Resolved since last review (4)

Comment thread cmd/cli/iniconfig/iniconfig.go Outdated
Comment thread cmd/cli/iniconfig/iniconfig.go
Comment thread cmd/cli/iniconfig/iniconfig.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Moderate unresolved issues remain in config path handling, key validation, INI parsing, and list output.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate subsection control characters during parsing

cmd/​cli/​iniconfig/​iniconfig.go:163

The parser does not apply the same control-character validation to subsections that ParseKey applies later. A file containing a subsection with \r or \x00 is accepted into Entries, but Get/Set reject the corresponding key, making that entry inaccessible and preventing a later write from addressing it. Reject \n, \r, and NUL after unescaping here, matching ParseKey.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unresolved critical and moderate validation and round-trip issues must be addressed.

Review effort: Lite
Findings: 2 High severity · 1 Medium severity

Open (3)

Comment thread cmd/cli/iniconfig/iniconfig.go
Comment thread cmd/cli/commands/config.go Outdated
Signed-off-by: Eric Curtin <eric.curtin@docker.com>
@ericcurtin

Copy link
Copy Markdown
Contributor Author

@sathiraumesh @ilopezluna Feedback is addressed. PTAL when you get a chance. Thank you!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Resolve the two moderate INI parser issues before approval.

Review effort: Lite
Findings: 1 High severity

Open (1)
Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Validate the first Unicode rune instead of its UTF-8 byte

cmd/​cli/​iniconfig/​iniconfig.go:349

name[0] is the first UTF-8 byte, not the first rune. Because the rest of this validator uses unicode.IsLetter, valid letter-starting names such as Hebrew א can be rejected (its leading byte is interpreted as ×), while a non-letter Unicode digit can pass the initial check. Decode the first rune before applying the letter check, or consistently restrict the whole name to ASCII as the comment claims.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants