feat: edit/update command

This commit is contained in:
2026-03-23 22:23:14 +02:00
parent 5f00cd4a30
commit bb31850bf3
6 changed files with 794 additions and 1 deletions

View File

@@ -12,7 +12,7 @@ add and list expenses directly from your terminal without opening the web interf
## Features
- **Add**, **list**, and **delete** expenses in Cospend projects via the **REST API**
- **Add**, **edit**, **list**, and **delete** expenses in Cospend projects via the **REST API**
- **List projects** you have access to
- **Filter** expenses by payer, owed members, amount, name, category, payment method, or date
- Resolve categories, payment methods, and members by **name or ID**
@@ -273,6 +273,48 @@ The output includes the bill ID for each expense, which can be used with the del
---
### Editing Expenses
```bash
cospend edit <bill_id> [flags]
cospend update <bill_id> [flags] # alias
```
Only the flags you specify will be updated; all other fields remain unchanged.
#### Examples
```bash
# Update the name of a bill
cospend edit 123 -p myproject -n "Updated name"
# Update amount and category
cospend edit 123 -p myproject -a 50.00 -c restaurant
# Change who paid and who owes
cospend edit 123 -p myproject -b alice -f bob -f charlie
# Update the date and add a comment
cospend edit 123 -p myproject -d 2026-06-15 -o "corrected date"
```
#### Edit Command Flags
| Short | Long | Description |
| ----- | ------------ | ---------------------------------------------------------------------- |
| `-p` | `--project` | Project ID (required) |
| `-n` | `--name` | New name/description |
| `-a` | `--amount` | New amount |
| `-c` | `--category` | Category by ID or case-insensitive name |
| `-b` | `--by` | Paying member username |
| `-f` | `--for` | Owed member username (repeatable) |
| `-m` | `--method` | Payment method by ID or case-insensitive name |
| `-o` | `--comment` | Comment |
| `-d` | `--date` | Date (`YYYY-MM-DD`, `MM-DD`, or relative like `-1d`, `+2w`) |
| `-h` | `--help` | Display help information |
---
### Deleting Expenses
```bash

View File

@@ -44,6 +44,14 @@ func resetFlags() {
paymentMethod = ""
comment = ""
addDate = ""
editName = ""
editAmount = ""
editCategory = ""
editPaidBy = ""
editPaidFor = nil
editPaymentMethod = ""
editComment = ""
editDate = ""
infoCached = false
}

241
cmd/edit.go Normal file
View File

@@ -0,0 +1,241 @@
package cmd
import (
"fmt"
"strconv"
"strings"
"github.com/chenasraf/cospend-cli/internal/api"
"github.com/chenasraf/cospend-cli/internal/cache"
"github.com/chenasraf/cospend-cli/internal/config"
"github.com/chenasraf/cospend-cli/internal/format"
"github.com/spf13/cobra"
)
var (
editName string
editAmount string
editCategory string
editPaidBy string
editPaidFor []string
editPaymentMethod string
editComment string
editDate string
)
// NewEditCommand creates the edit command
func NewEditCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "edit <bill_id>",
Aliases: []string{"update"},
Short: "Edit an existing expense in a Cospend project",
Long: `Edit an existing expense in a Cospend project.
Only specified flags will be updated; other fields remain unchanged.
Examples:
cospend edit 123 -p myproject -n "Updated name"
cospend edit 123 -p myproject -a 30.00 -c restaurant
cospend edit 123 -p myproject -b alice -f bob -f charlie`,
Args: cobra.ExactArgs(1),
RunE: runEdit,
}
cmd.Flags().StringVarP(&editName, "name", "n", "", "New name/description")
cmd.Flags().StringVarP(&editAmount, "amount", "a", "", "New amount")
cmd.Flags().StringVarP(&editCategory, "category", "c", "", "Category by ID or name")
cmd.Flags().StringVarP(&editPaidBy, "by", "b", "", "Paying member username")
cmd.Flags().StringArrayVarP(&editPaidFor, "for", "f", nil, "Owed member username (repeatable)")
cmd.Flags().StringVarP(&editPaymentMethod, "method", "m", "", "Payment method by ID or name")
cmd.Flags().StringVarP(&editComment, "comment", "o", "", "Comment")
cmd.Flags().StringVarP(&editDate, "date", "d", "", "Date (YYYY-MM-DD, MM-DD, or relative like -1d, +2w)")
return cmd
}
func runEdit(cmd *cobra.Command, args []string) error {
if ProjectID == "" {
return fmt.Errorf("project is required (use -p or --project)")
}
billID, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("invalid bill ID: %s", args[0])
}
// Parameters validated, silence usage for subsequent errors
cmd.SilenceUsage = true
cfg, err := config.Load()
if err != nil {
return err
}
client := api.NewClient(cfg)
client.Debug = Debug
client.DebugWriter = cmd.ErrOrStderr()
// Get project (from cache or API)
project, ok := cache.Load(ProjectID)
if !ok {
project, err = client.GetProject(ProjectID)
if err != nil {
return fmt.Errorf("fetching project: %w", err)
}
if err := cache.Save(ProjectID, project); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: failed to cache project: %v\n", err)
}
}
// Fetch all bills to find the existing one
bills, err := client.GetBills(ProjectID)
if err != nil {
return fmt.Errorf("fetching bills: %w", err)
}
var existing *api.BillResponse
for i := range bills {
if bills[i].ID == billID {
existing = &bills[i]
break
}
}
if existing == nil {
return fmt.Errorf("bill #%d not found", billID)
}
// Build member name lookup
memberNames := make(map[int]string)
for _, m := range project.Members {
memberNames[m.ID] = m.Name
}
// Start from existing values
bill := api.Bill{
What: existing.What,
Amount: existing.Amount,
PayerID: existing.PayerID,
Date: existing.Date,
Comment: existing.Comment,
PaymentModeID: existing.PaymentModeID,
CategoryID: existing.CategoryID,
}
for _, o := range existing.Owers {
bill.OwedTo = append(bill.OwedTo, o.ID)
}
// Apply changes for flags that were explicitly set
if cmd.Flags().Changed("name") {
bill.What = editName
}
if cmd.Flags().Changed("amount") {
amount, err := strconv.ParseFloat(editAmount, 64)
if err != nil {
return fmt.Errorf("invalid amount: %s", editAmount)
}
bill.Amount = amount
}
if cmd.Flags().Changed("by") {
payerID, err := cache.ResolveMember(project, editPaidBy)
if err != nil {
return fmt.Errorf("resolving payer: %w", err)
}
bill.PayerID = payerID
}
if cmd.Flags().Changed("for") {
var owedIDs []int
for _, username := range editPaidFor {
memberID, err := cache.ResolveMember(project, username)
if err != nil {
return fmt.Errorf("resolving owed member: %w", err)
}
owedIDs = append(owedIDs, memberID)
}
bill.OwedTo = owedIDs
}
if cmd.Flags().Changed("date") {
parsed, err := parseDate(editDate)
if err != nil {
return err
}
bill.Date = parsed
}
if cmd.Flags().Changed("category") {
categoryID, err := cache.ResolveCategory(project, editCategory)
if err != nil {
return fmt.Errorf("resolving category: %w", err)
}
bill.CategoryID = categoryID
}
if cmd.Flags().Changed("method") {
methodID, err := cache.ResolvePaymentMode(project, editPaymentMethod)
if err != nil {
return fmt.Errorf("resolving payment method: %w", err)
}
bill.PaymentModeID = methodID
}
if cmd.Flags().Changed("comment") {
bill.Comment = editComment
}
// Edit the bill
if err := client.EditBill(ProjectID, billID, bill); err != nil {
return fmt.Errorf("editing bill: %w", err)
}
// Fetch user info for locale-aware formatting
locale := "en_US"
userInfo, ok := cache.LoadUserInfo()
if !ok {
userInfo, err = client.GetUserInfo()
if err == nil {
_ = cache.SaveUserInfo(userInfo)
}
}
if userInfo != nil && userInfo.Locale != "" {
locale = userInfo.Locale
} else if userInfo != nil && userInfo.Language != "" {
locale = userInfo.Language
}
formatter := format.NewAmountFormatter(locale, project.CurrencyName)
out := cmd.OutOrStdout()
_, _ = fmt.Fprintf(out, "Updated bill #%d\n", billID)
_, _ = fmt.Fprintf(out, " Name: %s\n", bill.What)
_, _ = fmt.Fprintf(out, " Amount: %s\n", formatter.Format(bill.Amount))
_, _ = fmt.Fprintf(out, " Date: %s\n", bill.Date)
_, _ = fmt.Fprintf(out, " Paid by: %s\n", memberNames[bill.PayerID])
var owerNames []string
for _, id := range bill.OwedTo {
owerNames = append(owerNames, memberNames[id])
}
_, _ = fmt.Fprintf(out, " Paid for: %s\n", strings.Join(owerNames, ", "))
if bill.CategoryID != 0 {
for _, c := range project.Categories {
if c.ID == bill.CategoryID {
_, _ = fmt.Fprintf(out, " Category: %s\n", c.Name)
break
}
}
}
if bill.PaymentModeID != 0 {
for _, pm := range project.PaymentModes {
if pm.ID == bill.PaymentModeID {
_, _ = fmt.Fprintf(out, " Method: %s\n", pm.Name)
break
}
}
}
if bill.Comment != "" {
_, _ = fmt.Fprintf(out, " Comment: %s\n", bill.Comment)
}
return nil
}

449
cmd/edit_test.go Normal file
View File

@@ -0,0 +1,449 @@
package cmd
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/chenasraf/cospend-cli/internal/api"
)
func resetEditFlags() {
ProjectID = ""
editName = ""
editAmount = ""
editCategory = ""
editPaidBy = ""
editPaidFor = nil
editPaymentMethod = ""
editComment = ""
editDate = ""
}
func TestNewEditCommand(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
cmd := NewEditCommand()
if cmd.Use != "edit <bill_id>" {
t.Errorf("Wrong Use: %s", cmd.Use)
}
if len(cmd.Aliases) != 1 || cmd.Aliases[0] != "update" {
t.Errorf("Wrong Aliases: %v", cmd.Aliases)
}
flags := []string{"name", "amount", "category", "by", "for", "method", "comment", "date"}
for _, flag := range flags {
if cmd.Flags().Lookup(flag) == nil {
t.Errorf("Missing flag: %s", flag)
}
}
shortFlags := map[string]string{
"n": "name",
"a": "amount",
"c": "category",
"b": "by",
"f": "for",
"m": "method",
"o": "comment",
"d": "date",
}
for short, long := range shortFlags {
flag := cmd.Flags().ShorthandLookup(short)
if flag == nil {
t.Errorf("Missing short flag: -%s", short)
} else if flag.Name != long {
t.Errorf("Short flag -%s maps to %s, want %s", short, flag.Name, long)
}
}
}
func TestEditCommandMissingProject(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
cmd := NewEditCommand()
cmd.SetArgs([]string{"123"})
err := cmd.Execute()
if err == nil {
t.Error("Expected error for missing project flag")
}
}
func TestEditCommandMissingBillID(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
ProjectID = "myproject"
cmd := NewEditCommand()
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil {
t.Error("Expected error for missing bill ID argument")
}
}
func TestEditCommandInvalidBillID(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("API should not be called with invalid bill ID")
}))
defer server.Close()
t.Setenv("NEXTCLOUD_DOMAIN", server.URL)
t.Setenv("NEXTCLOUD_USER", "testuser")
t.Setenv("NEXTCLOUD_PASSWORD", "testpass")
ProjectID = "myproject"
cmd := NewEditCommand()
buf := new(bytes.Buffer)
cmd.SetOut(buf)
cmd.SetErr(buf)
cmd.SetArgs([]string{"not-a-number"})
err := cmd.Execute()
if err == nil {
t.Error("Expected error for invalid bill ID")
}
}
func testEditServer(t *testing.T, project api.Project, bills []api.BillResponse, onPut func(r *http.Request)) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
return
}
if r.URL.Path == "/ocs/v2.php/cloud/user" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
return
}
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" ||
r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills/42" {
if r.Method == "GET" {
billsData := struct {
Bills []api.BillResponse `json:"bills"`
}{Bills: bills}
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, billsData))
return
}
if r.Method == "PUT" {
if onPut != nil {
onPut(r)
}
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, "OK"))
return
}
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestEditCommandSuccess(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
{ID: 2, Name: "Alice", UserID: "alice"},
{ID: 3, Name: "Bob", UserID: "bob"},
},
Categories: []api.Category{
{ID: 5, Name: "Restaurant"},
},
PaymentModes: []api.PaymentMode{
{ID: 3, Name: "Credit Card"},
},
}
bills := []api.BillResponse{
{
ID: 42,
What: "Old Dinner",
Amount: 30.00,
Date: "2026-01-15",
PayerID: 1,
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
Comment: "old comment",
},
}
var receivedBill map[string]string
server := testEditServer(t, project, bills, func(r *http.Request) {
_ = r.ParseForm()
receivedBill = make(map[string]string)
for k, v := range r.Form {
if len(v) > 0 {
receivedBill[k] = v[0]
}
}
})
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewEditCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{
"42",
"-n", "Updated Dinner",
"-a", "50.00",
"-b", "alice",
"-f", "bob",
"-c", "restaurant",
"-m", "credit card",
"-o", "new comment",
"-d", "2026-06-15",
})
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if receivedBill["what"] != "Updated Dinner" {
t.Errorf("Wrong what: %s", receivedBill["what"])
}
if receivedBill["amount"] != "50.00" {
t.Errorf("Wrong amount: %s", receivedBill["amount"])
}
if receivedBill["payer"] != "2" { // Alice's ID
t.Errorf("Wrong payer: %s", receivedBill["payer"])
}
if receivedBill["payedFor"] != "3" { // Bob only
t.Errorf("Wrong payedFor: %s", receivedBill["payedFor"])
}
if receivedBill["categoryId"] != "5" {
t.Errorf("Wrong categoryId: %s", receivedBill["categoryId"])
}
if receivedBill["paymentModeId"] != "3" {
t.Errorf("Wrong paymentModeId: %s", receivedBill["paymentModeId"])
}
if receivedBill["comment"] != "new comment" {
t.Errorf("Wrong comment: %s", receivedBill["comment"])
}
if receivedBill["date"] != "2026-06-15" {
t.Errorf("Wrong date: %s", receivedBill["date"])
}
if !bytes.Contains(stdout.Bytes(), []byte("Updated bill #42")) {
t.Errorf("Missing success message in output: %s", stdout.String())
}
}
func TestEditCommandPartialUpdate(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
{ID: 2, Name: "Alice", UserID: "alice"},
},
}
bills := []api.BillResponse{
{
ID: 42,
What: "Original Name",
Amount: 25.00,
Date: "2026-01-15",
PayerID: 1,
Owers: []api.Ower{{ID: 1, Weight: 1}, {ID: 2, Weight: 1}},
Comment: "original comment",
},
}
var receivedBill map[string]string
server := testEditServer(t, project, bills, func(r *http.Request) {
_ = r.ParseForm()
receivedBill = make(map[string]string)
for k, v := range r.Form {
if len(v) > 0 {
receivedBill[k] = v[0]
}
}
})
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewEditCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
// Only change the name
cmd.SetArgs([]string{"42", "-n", "New Name"})
err := cmd.Execute()
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
// Name should be updated
if receivedBill["what"] != "New Name" {
t.Errorf("Wrong what: %s", receivedBill["what"])
}
// Other fields should be preserved from the original bill
if receivedBill["amount"] != "25.00" {
t.Errorf("Amount should be preserved: got %s, want 25.00", receivedBill["amount"])
}
if receivedBill["payer"] != "1" {
t.Errorf("Payer should be preserved: got %s, want 1", receivedBill["payer"])
}
if receivedBill["payedFor"] != "1,2" {
t.Errorf("PayedFor should be preserved: got %s, want 1,2", receivedBill["payedFor"])
}
if receivedBill["date"] != "2026-01-15" {
t.Errorf("Date should be preserved: got %s, want 2026-01-15", receivedBill["date"])
}
if receivedBill["comment"] != "original comment" {
t.Errorf("Comment should be preserved: got %s, want 'original comment'", receivedBill["comment"])
}
}
func TestEditCommandBillNotFound(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
},
}
// Empty bills list
server := testEditServer(t, project, []api.BillResponse{}, nil)
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewEditCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"999", "-n", "whatever"})
err := cmd.Execute()
if err == nil {
t.Error("Expected error for bill not found")
}
}
func TestEditCommandMemberNotFound(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
},
}
bills := []api.BillResponse{
{
ID: 42,
What: "Test",
Amount: 10.00,
Date: "2026-01-15",
PayerID: 1,
Owers: []api.Ower{{ID: 1, Weight: 1}},
},
}
server := testEditServer(t, project, bills, nil)
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewEditCommand()
cmd.SetArgs([]string{"42", "-b", "nonexistent"})
err := cmd.Execute()
if err == nil {
t.Error("Expected error for nonexistent member")
}
}
func TestEditCommandAPIError(t *testing.T) {
resetEditFlags()
defer resetEditFlags()
project := api.Project{
ID: "test-project",
Name: "Test Project",
Members: []api.Member{
{ID: 1, Name: "testuser", UserID: "testuser"},
},
}
bills := []api.BillResponse{
{
ID: 42,
What: "Test",
Amount: 10.00,
Date: "2026-01-15",
PayerID: 1,
Owers: []api.Ower{{ID: 1, Weight: 1}},
},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, project))
return
}
if r.URL.Path == "/ocs/v2.php/cloud/user" {
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, map[string]string{"locale": "en_US", "language": "en"}))
return
}
if r.Method == "GET" && r.URL.Path == "/ocs/v2.php/apps/cospend/api/v1/projects/test-project/bills" {
billsData := struct {
Bills []api.BillResponse `json:"bills"`
}{Bills: bills}
_ = json.NewEncoder(w).Encode(makeOCSResponse(200, billsData))
return
}
// Return error for PUT
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("Internal Server Error"))
}))
defer server.Close()
cleanup := setupTestEnv(t, server.URL)
defer cleanup()
ProjectID = "test-project"
cmd := NewEditCommand()
cmd.SetArgs([]string{"42", "-n", "New Name"})
err := cmd.Execute()
if err == nil {
t.Error("Expected error from API")
}
}

View File

@@ -469,6 +469,58 @@ func (c *Client) GetUserInfo() (*UserInfo, error) {
return &userInfo, nil
}
// EditBill updates an existing bill in the project
func (c *Client) EditBill(projectID string, billID int, bill Bill) error {
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID)
// Build form data
data := url.Values{}
data.Set("what", bill.What)
data.Set("amount", strconv.FormatFloat(bill.Amount, 'f', 2, 64))
data.Set("payer", strconv.Itoa(bill.PayerID))
data.Set("date", bill.Date)
data.Set("timestamp", strconv.FormatInt(time.Now().Unix(), 10))
// Format owed member IDs as comma-separated string
owedIDs := make([]string, len(bill.OwedTo))
for i, id := range bill.OwedTo {
owedIDs[i] = strconv.Itoa(id)
}
data.Set("payedFor", strings.Join(owedIDs, ","))
data.Set("comment", bill.Comment)
if bill.PaymentModeID != 0 {
data.Set("paymentModeId", strconv.Itoa(bill.PaymentModeID))
}
if bill.CategoryID != 0 {
data.Set("categoryId", strconv.Itoa(bill.CategoryID))
}
c.debugf("Request body: %s", data.Encode())
resp, err := c.doRequest("PUT", path, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("editing bill: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var ocsResp OCSResponse
if err := json.NewDecoder(resp.Body).Decode(&ocsResp); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
if ocsResp.OCS.Meta.StatusCode != 200 {
return fmt.Errorf("API error: %s", ocsResp.OCS.Meta.Message)
}
return nil
}
// DeleteBill deletes a bill from the project
func (c *Client) DeleteBill(projectID string, billID int) error {
path := fmt.Sprintf("/ocs/v2.php/apps/cospend/api/v1/projects/%s/bills/%d", url.PathEscape(projectID), billID)

View File

@@ -25,6 +25,7 @@ func main() {
rootCmd.AddCommand(cmd.NewInitCommand())
rootCmd.AddCommand(cmd.NewListCommand())
rootCmd.AddCommand(cmd.NewDeleteCommand())
rootCmd.AddCommand(cmd.NewEditCommand())
rootCmd.AddCommand(cmd.NewProjectsCommand())
rootCmd.AddCommand(cmd.NewInfoCommand())