From 2d83bb607ac78ba78450b69e0d0eebd1f3286994 Mon Sep 17 00:00:00 2001 From: Keith Kim Date: Mon, 8 Dec 2025 17:25:23 -0500 Subject: [PATCH] Add sort flags to list/ls: -t (time), -U (unsorted so by name), -S (size), -i (ID), -r (reversed) --- cmd/cmd.go | 76 ++++++++++++++++++++++++++- cmd/cmd_test.go | 135 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 200 insertions(+), 11 deletions(-) diff --git a/cmd/cmd.go b/cmd/cmd.go index d77bb2c58..1575adf46 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -49,6 +49,20 @@ import ( const ConnectInstructions = "To sign in, navigate to:\n %s\n\n" +type ListSortOrder int +const ( + ListByTime ListSortOrder = iota // -t + ListBySize // -S + ListByName // -U + ListByID // -i +) +var listSortByTime bool +var listSortBySize bool +var listSortByName bool +var listSortByID bool +var listSortReverse bool +var listSortGrouped bool + // ensureThinkingSupport emits a warning if the model does not advertise thinking support func ensureThinkingSupport(ctx context.Context, client *api.Client, name string) { if name == "" { @@ -702,9 +716,11 @@ func ListHandler(cmd *cobra.Command, args []string) error { return err } + sorted := sortListModels(models) + var data [][]string - for _, m := range models.Models { + for _, m := range sorted { if len(args) == 0 || strings.HasPrefix(strings.ToLower(m.Name), strings.ToLower(args[0])) { var size string if m.RemoteModel != "" { @@ -731,6 +747,58 @@ func ListHandler(cmd *cobra.Command, args []string) error { return nil } +func sortListModels(models *api.ListResponse) []*api.ListModelResponse { + sorted := make([]*api.ListModelResponse, len(models.Models)) + for i := 0; i < len(models.Models); i++ { + sorted[i] = &models.Models[i] + } + + var sortFn func(i, j int) bool + switch { + case listSortByName: + sortFn = func(i, j int) bool { + return sorted[i].Name < sorted[j].Name + } + case listSortBySize: + sortFn = func(i, j int) bool { + return sorted[i].Size < sorted[j].Size + } + case listSortByID: + sortFn = func(i, j int) bool { + return sorted[i].Digest < sorted[j].Digest + } + case listSortByTime: + listSortReverse = !listSortReverse + } + + if sortFn != nil { + sort.Slice(sorted, sortFn) + } + if listSortReverse { + slices.Reverse(sorted) + } + if listSortGrouped { + groups := make([]string, 0, len(sorted)) + grouped := make(map[string][]*api.ListModelResponse) + for _, model := range sorted { + group := strings.Split(model.Name, ":")[0] + ms, ok := grouped[group] + if !ok { + groups = append(groups, group) + } + grouped[group] = append(ms, model) + } + i := 0 + for _, group := range groups { + for _, model := range grouped[group] { + sorted[i] = model + i++ + } + } + } + return sorted +} + func ListRunningHandler(cmd *cobra.Command, args []string) error { client, err := api.ClientFromEnvironment() if err != nil { @@ -1811,6 +1879,12 @@ func NewCLI() *cobra.Command { PreRunE: checkServerHeartbeat, RunE: ListHandler, } + listCmd.Flags().BoolVarP(&listSortByTime, "time", "t", false, "Sort by date/time, chronologically (default reversed).") + listCmd.Flags().BoolVarP(&listSortBySize, "size", "S", false, "Sort by file size, smallest first.") + listCmd.Flags().BoolVarP(&listSortByName, "name", "U", false, "Sort by name in alphabetical order.") + listCmd.Flags().BoolVarP(&listSortByID, "id", "i", false, "Sort by ID in alphabetical order.") + listCmd.Flags().BoolVarP(&listSortReverse, "reverse", "r", false, "Reverse the sort order.") + listCmd.Flags().BoolVarP(&listSortGrouped, "grouped", "g", false, "Group models.") psCmd := &cobra.Command{ Use: "ps", diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 1c9d19942..557d43d35 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "reflect" + "slices" "strings" "testing" "time" @@ -962,6 +963,93 @@ func TestListHandler(t *testing.T) { expectedOutput: "NAME ID SIZE MODIFIED \n" + "model1 sha256:abc12 1.0 KB 24 hours ago \n", }, + { + name: "list all models ordered by name", + args: []string{"--name"}, + serverResponse: []api.ListModelResponse{ + {Name: "model1", Digest: "sha256:abc123", Size: 1024, ModifiedAt: time.Now().Add(-24 * time.Hour)}, + {Name: "model2", Digest: "sha256:def456", Size: 2048, ModifiedAt: time.Now().Add(-48 * time.Hour)}, + {Name: "model0", Digest: "sha256:bcd789", Size: 1536, ModifiedAt: time.Now().Add(-72 * time.Hour)}, + }, + expectedOutput: "NAME ID SIZE MODIFIED \n" + + "model0 sha256:bcd78 1.5 KB 3 days ago \n" + + "model1 sha256:abc12 1.0 KB 24 hours ago \n" + + "model2 sha256:def45 2.0 KB 2 days ago \n", + }, + { + name: "list all models ordered by ID", + args: []string{"--id"}, + serverResponse: []api.ListModelResponse{ + {Name: "model1", Digest: "sha256:abc123", Size: 1024, ModifiedAt: time.Now().Add(-24 * time.Hour)}, + {Name: "model2", Digest: "sha256:def456", Size: 2048, ModifiedAt: time.Now().Add(-48 * time.Hour)}, + {Name: "model0", Digest: "sha256:bcd789", Size: 1536, ModifiedAt: time.Now().Add(-72 * time.Hour)}, + }, + expectedOutput: "NAME ID SIZE MODIFIED \n" + + "model1 sha256:abc12 1.0 KB 24 hours ago \n" + + "model0 sha256:bcd78 1.5 KB 3 days ago \n" + + "model2 sha256:def45 2.0 KB 2 days ago \n", + }, + { + name: "list all models ordered by size", + args: []string{"--size"}, + serverResponse: []api.ListModelResponse{ + {Name: "model1", Digest: "sha256:abc123", Size: 1024, ModifiedAt: time.Now().Add(-24 * time.Hour)}, + {Name: "model2", Digest: "sha256:def456", Size: 2048, ModifiedAt: time.Now().Add(-48 * time.Hour)}, + {Name: "model0", Digest: "sha256:bcd789", Size: 1536, ModifiedAt: time.Now().Add(-72 * time.Hour)}, + }, + expectedOutput: "NAME ID SIZE MODIFIED \n" + + "model1 sha256:abc12 1.0 KB 24 hours ago \n" + + "model0 sha256:bcd78 1.5 KB 3 days ago \n" + + "model2 sha256:def45 2.0 KB 2 days ago \n", + }, + { + name: "list all models reversed", + args: []string{"--reverse"}, + serverResponse: []api.ListModelResponse{ + {Name: "model1", Digest: "sha256:abc123", Size: 1024, ModifiedAt: time.Now().Add(-24 * time.Hour)}, + {Name: "model2", Digest: "sha256:def456", Size: 2048, ModifiedAt: time.Now().Add(-48 * time.Hour)}, + {Name: "model0", Digest: "sha256:bcd789", Size: 1536, ModifiedAt: time.Now().Add(-72 * time.Hour)}, + }, + expectedOutput: "NAME ID SIZE MODIFIED \n" + + "model0 sha256:bcd78 1.5 KB 3 days ago \n" + + "model2 sha256:def45 2.0 KB 2 days ago \n" + + "model1 sha256:abc12 1.0 KB 24 hours ago \n", + }, + { + name: "list all models grouped", + args: []string{"--grouped"}, + serverResponse: []api.ListModelResponse{ + {Name: "b:model4", Digest: "sha256:cde357", Size: 3072, ModifiedAt: time.Now().Add(-12 * time.Hour)}, + {Name: "a:model1", Digest: "sha256:abc123", Size: 1024, ModifiedAt: time.Now().Add(-24 * time.Hour)}, + {Name: "a:model2", Digest: "sha256:def456", Size: 2048, ModifiedAt: time.Now().Add(-48 * time.Hour)}, + {Name: "b:model0", Digest: "sha256:bcd789", Size: 1536, ModifiedAt: time.Now().Add(-72 * time.Hour)}, + }, + expectedOutput: "NAME ID SIZE MODIFIED \n" + + "b:model4 sha256:cde35 3.1 KB 12 hours ago \n" + + "b:model0 sha256:bcd78 1.5 KB 3 days ago \n" + + "a:model1 sha256:abc12 1.0 KB 24 hours ago \n" + + "a:model2 sha256:def45 2.0 KB 2 days ago \n", + }, + { + name: "list help", + args: []string{"--help"}, + expectedOutput: "Usage:\n" + + " ollama list [flags]\n" + + "\n" + + "Aliases:\n" + + " list, ls\n" + + "\n" + + "Flags:\n" + + " -g, --grouped Group models.\n" + + " -i, --id Sort by ID in alphabetical order.\n" + + " -U, --name Sort by name in alphabetical order.\n" + + " -r, --reverse Reverse the sort order.\n" + + " -S, --size Sort by file size, smallest first.\n" + + " -t, --time Sort by date/time, chronologically (default reversed).\n" + + "\n" + + "Environment Variables:\n" + + " OLLAMA_HOST IP Address for the ollama server (default 127.0.0.1:11434)\n", + }, { name: "server error", args: []string{}, @@ -992,20 +1080,47 @@ func TestListHandler(t *testing.T) { t.Setenv("OLLAMA_HOST", mockServer.URL) - cmd := &cobra.Command{} + cmd := func() *cobra.Command { + cli := NewCLI() + listCmd, _, err := cli.Find([]string{"list"}) + if err != nil || listCmd == nil { + return nil + } + return listCmd + }() + if cmd == nil { + t.Fatal("list command not found - did cmd package change?") + } + isHelp := slices.Equal(tt.args, []string{"--help"}) + var args []string + if !isHelp { + if err := cmd.ParseFlags(tt.args); err != nil { + t.Fatal(err) + } + args = cmd.Flags().Args() + } cmd.SetContext(t.Context()) - // Capture stdout - oldStdout := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w + var output []byte + var err error + if isHelp { + var buf bytes.Buffer + cmd.SetOut(&buf) + err = cmd.Usage() + output = buf.Bytes() + } else { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w - err := ListHandler(cmd, tt.args) + err = ListHandler(cmd, args) - // Restore stdout and get output - w.Close() - os.Stdout = oldStdout - output, _ := io.ReadAll(r) + // Restore stdout and get output + w.Close() + os.Stdout = oldStdout + output, _ = io.ReadAll(r) + } if tt.expectedError == "" { if err != nil {