-
Notifications
You must be signed in to change notification settings - Fork 120
Add "docker model code" command #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,289 @@ | ||||||||||||||||
| package commands | ||||||||||||||||
|
|
||||||||||||||||
| import ( | ||||||||||||||||
| "fmt" | ||||||||||||||||
| "os" | ||||||||||||||||
| "os/exec" | ||||||||||||||||
| "path/filepath" | ||||||||||||||||
| "strings" | ||||||||||||||||
|
|
||||||||||||||||
| "github.com/docker/model-runner/cmd/cli/desktop" | ||||||||||||||||
| "github.com/docker/model-runner/cmd/cli/pkg/types" | ||||||||||||||||
| "github.com/docker/model-runner/pkg/inference/models" | ||||||||||||||||
| "github.com/spf13/cobra" | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| func newCodeCmd() *cobra.Command { | ||||||||||||||||
| var backend string | ||||||||||||||||
| var aiderImage string | ||||||||||||||||
|
|
||||||||||||||||
| const cmdArgs = "MODEL [PROMPT]" | ||||||||||||||||
| c := &cobra.Command{ | ||||||||||||||||
| Use: "code " + cmdArgs, | ||||||||||||||||
| Short: "Run aider in a container to edit code with AI assistance", | ||||||||||||||||
| Long: `Run aider in an ephemeral Docker container to edit code with AI assistance. | ||||||||||||||||
|
|
||||||||||||||||
| This command runs aider (https://github.com/paul-gauthier/aider) in a Docker container | ||||||||||||||||
| that can interact with your local codebase and talk to Docker Model Runner. | ||||||||||||||||
|
|
||||||||||||||||
| The command must be run from the root of a Git repository. If no PROMPT is provided, | ||||||||||||||||
| it will open your configured text editor (via EDITOR or VISUAL environment variables, | ||||||||||||||||
| defaulting to vim) to compose a prompt, similar to how 'git commit' works.`, | ||||||||||||||||
| Example: ` # Open editor to compose prompt | ||||||||||||||||
| docker model code ai/smollm2 | ||||||||||||||||
|
|
||||||||||||||||
| # Provide prompt directly | ||||||||||||||||
| docker model code ai/smollm2 "Add error handling to the main function" | ||||||||||||||||
|
|
||||||||||||||||
| # Use with a specific backend | ||||||||||||||||
| docker model code --backend openai gpt-4 "Refactor the authentication logic"`, | ||||||||||||||||
| PreRunE: func(cmd *cobra.Command, args []string) error { | ||||||||||||||||
| // Check if we're in a git repository | ||||||||||||||||
| gitCmd := exec.Command("git", "rev-parse", "--show-toplevel") | ||||||||||||||||
| if err := gitCmd.Run(); err != nil { | ||||||||||||||||
| return fmt.Errorf("must be run from within a git repository") | ||||||||||||||||
| } | ||||||||||||||||
| return nil | ||||||||||||||||
| }, | ||||||||||||||||
| RunE: func(cmd *cobra.Command, args []string) error { | ||||||||||||||||
| // Validate backend if specified | ||||||||||||||||
| if backend != "" { | ||||||||||||||||
| if err := validateBackend(backend); err != nil { | ||||||||||||||||
| return err | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Normalize model name to add default org and tag if missing | ||||||||||||||||
| model := models.NormalizeModelName(args[0]) | ||||||||||||||||
| prompt := "" | ||||||||||||||||
| argsLen := len(args) | ||||||||||||||||
| if argsLen > 1 { | ||||||||||||||||
| prompt = strings.Join(args[1:], " ") | ||||||||||||||||
| if prompt == "" { | ||||||||||||||||
| if strings.TrimSpace(prompt) == "" { | ||||||||||||||||
| fmt.Fprintf(os.Stderr, "Aborting coding task due to empty commit message.\n") | ||||||||||||||||
| return nil | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // If no prompt provided, open editor | ||||||||||||||||
| if prompt == "" { | ||||||||||||||||
| var err error | ||||||||||||||||
| prompt, err = getPromptFromEditor() | ||||||||||||||||
| if err != nil { | ||||||||||||||||
| return fmt.Errorf("failed to get prompt from editor: %w", err) | ||||||||||||||||
| } | ||||||||||||||||
| if strings.TrimSpace(prompt) == "" { | ||||||||||||||||
| fmt.Fprintf(os.Stderr, "Aborting coding task due to empty commit message.\n") | ||||||||||||||||
|
||||||||||||||||
| return nil | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+60
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic for handling the prompt argument is a bit convoluted and has a bug. If a user provides a prompt that consists only of whitespace (e.g., if argsLen > 1 {
prompt = strings.Join(args[1:], " ")
}
// If no prompt provided, or if it's all whitespace, open editor
if strings.TrimSpace(prompt) == "" {
var err error
prompt, err = getPromptFromEditor()
if err != nil {
return fmt.Errorf("failed to get prompt from editor: %w", err)
}
if strings.TrimSpace(prompt) == "" {
fmt.Fprintf(os.Stderr, "Aborting coding task due to empty commit message.\n")
return nil
}
} |
||||||||||||||||
|
|
||||||||||||||||
| // Get the git repository root | ||||||||||||||||
| gitCmd := exec.Command("git", "rev-parse", "--show-toplevel") | ||||||||||||||||
| repoRootBytes, err := gitCmd.Output() | ||||||||||||||||
| if err != nil { | ||||||||||||||||
| return fmt.Errorf("failed to get repository root: %w", err) | ||||||||||||||||
| } | ||||||||||||||||
| repoRoot := strings.TrimSpace(string(repoRootBytes)) | ||||||||||||||||
|
|
||||||||||||||||
| // Get the model runner URL | ||||||||||||||||
| modelRunnerURL := getModelRunnerURL() | ||||||||||||||||
|
|
||||||||||||||||
| // Ensure model is available | ||||||||||||||||
| if backend != "openai" { | ||||||||||||||||
| if _, err := ensureStandaloneRunnerAvailable(cmd.Context(), cmd); err != nil { | ||||||||||||||||
| return fmt.Errorf("unable to initialize standalone model runner: %w", err) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| _, err := desktopClient.Inspect(model, false) | ||||||||||||||||
| if err != nil { | ||||||||||||||||
| cmd.Println("Unable to find model '" + model + "' locally. Pulling from the server.") | ||||||||||||||||
| if err := pullModel(cmd, desktopClient, model, false); err != nil { | ||||||||||||||||
| return err | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Run aider in Docker container | ||||||||||||||||
| return runAiderInContainer(cmd, aiderImage, repoRoot, model, prompt, modelRunnerURL) | ||||||||||||||||
| }, | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| c.Args = func(cmd *cobra.Command, args []string) error { | ||||||||||||||||
| if len(args) < 1 { | ||||||||||||||||
| return fmt.Errorf("requires at least 1 argument: MODEL") | ||||||||||||||||
| } | ||||||||||||||||
| return nil | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| c.Flags().StringVar(&backend, "backend", "", "inference backend to use") | ||||||||||||||||
| c.Flags().StringVar(&aiderImage, "aider-image", "paulgauthier/aider", "Docker image to use for aider") | ||||||||||||||||
|
|
||||||||||||||||
| return c | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // getPromptFromEditor opens a text editor for the user to compose a prompt | ||||||||||||||||
| func getPromptFromEditor() (string, error) { | ||||||||||||||||
| editor := os.Getenv("VISUAL") | ||||||||||||||||
| if editor == "" { | ||||||||||||||||
| editor = os.Getenv("EDITOR") | ||||||||||||||||
| } | ||||||||||||||||
| if editor == "" { | ||||||||||||||||
| editor = "vim" | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Create a temporary file for the prompt | ||||||||||||||||
| tmpFile, err := os.CreateTemp("", "model-code-prompt-*.txt") | ||||||||||||||||
| if err != nil { | ||||||||||||||||
| return "", fmt.Errorf("failed to create temporary file: %w", err) | ||||||||||||||||
| } | ||||||||||||||||
| tmpPath := tmpFile.Name() | ||||||||||||||||
| defer os.Remove(tmpPath) | ||||||||||||||||
|
|
||||||||||||||||
| // Write instructions to the file | ||||||||||||||||
| instructions := ` | ||||||||||||||||
|
|
||||||||||||||||
| # Please enter the commit message for your changes. Lines starting | ||||||||||||||||
| # with '#' will be ignored, and an empty message aborts the commit | ||||||||||||||||
|
Comment on lines
+148
to
+149
|
||||||||||||||||
| # Please enter the commit message for your changes. Lines starting | |
| # with '#' will be ignored, and an empty message aborts the commit | |
| # Please enter the prompt for your coding task below. Lines starting | |
| # with '#' will be ignored, and an empty prompt will abort the operation. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
security (go.lang.security.audit.dangerous-exec-command): Detected non-static command inside Command. Audit the input to 'exec.Command'. If unverified user data can reach this call site, this is a code injection vulnerability. A malicious actor can inject a malicious script to execute arbitrary code.
Source: opengrep
Copilot
AI
Oct 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hard-coding the 'openai/' prefix for all models may not be appropriate. This should be conditional based on the backend or model configuration.
| func runAiderInContainer(cmd *cobra.Command, aiderImage, repoRoot, model, prompt, modelRunnerURL string) error { | |
| model = "openai/" + model | |
| func runAiderInContainer(cmd *cobra.Command, aiderImage, repoRoot, model, prompt, modelRunnerURL string, backend string) error { | |
| // Only prepend "openai/" if backend is openai | |
| if backend == "openai" && !strings.HasPrefix(model, "openai/") { | |
| model = "openai/" + model | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current method of removing the --network host arguments is fragile. It uses a generic removeElement function twice, which could unintentionally remove other arguments if they happen to be "host". A more robust approach is to specifically find and remove the --network host pair. This also allows removing the now-unnecessary removeElement helper function.
if isDockerDesktop() {
// Remove --network host and use Docker Desktop's DNS
newAiderArgs := make([]string, 0, len(aiderArgs))
for i := 0; i < len(aiderArgs); i++ {
if aiderArgs[i] == "--network" && i+1 < len(aiderArgs) && aiderArgs[i+1] == "host" {
i++ // Skip both "--network" and "host"
} else {
newAiderArgs = append(newAiderArgs, aiderArgs[i])
}
}
aiderArgs = newAiderArgs
}
Copilot
AI
Oct 19, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The format string expects two parameters but aiderArgs is a slice. This will print the slice address rather than the arguments. Consider using strings.Join(aiderArgs, \" \") or a different format.
| cmd.Printf("Running aider with model %s %s...\n", model, aiderArgs) | |
| cmd.Printf("Running aider with model %s %s...\n", model, strings.Join(aiderArgs, " ")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The inner condition on line 63 will never be true since line 62 already checks if prompt is empty. The nested check for
strings.TrimSpace(prompt) == \"\"is unreachable.