Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Binaries
sure-cli
/sure-cli
*.exe
*.exe~
*.dll
Expand Down
11 changes: 4 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ sure-cli --help
sure-cli config set api_url http://localhost:3000

# Auth
sure-cli config set api_key <key>
sure-cli config set auth.mode api_key
sure-cli config set auth.api_key <key>
# or OAuth:
sure-cli login --email you@example.com --password "..." [--otp 123456]

Expand All @@ -83,14 +84,14 @@ sure-cli accounts list --format=json
sure-cli accounts show <account_id>

# Transactions
sure-cli transactions list --from 2026-01-01 --to 2026-02-01 --per-page 50 --format=table
sure-cli transactions list --start-date 2026-01-01 --end-date 2026-02-01 --per-page 50 --format=table
sure-cli transactions show <transaction_id>

# Safe writes (default is --dry-run)
sure-cli transactions create --amount "-12.34" --date 2026-02-04 --name "Coffee" --account-id <id>
sure-cli transactions create --amount "-12.34" --date 2026-02-04 --name "Coffee" --account-id <id> --apply

sure-cli transactions update <tx_id> --name "Coffee (fixed)" --dry-run
sure-cli transactions update <tx_id> --name "Coffee (fixed)"
sure-cli transactions delete <tx_id> --apply

# Phase 4 (read-only heuristics)
Expand Down Expand Up @@ -194,8 +195,4 @@ sure-cli config heuristics # show all heuristic settings
sure-cli config fee-keywords # show active fee keywords (60+ defaults)
```

## Known Upstream Limitations

- **`GET /api/v1/accounts/:id`** returns 404 upstream. `accounts show` falls back to list lookup.

See `docs/ROADMAP.md` for planned features.
24 changes: 6 additions & 18 deletions cmd/sure-cli/root/accounts_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import (
"fmt"
"net/url"

"github.com/spf13/cobra"
"github.com/we-promise/sure-cli/internal/api"
"github.com/we-promise/sure-cli/internal/output"
"github.com/spf13/cobra"
)

func newAccountsCmd() *cobra.Command {
Expand Down Expand Up @@ -43,29 +43,17 @@ func newAccountsCmd() *cobra.Command {

cmd.AddCommand(&cobra.Command{
Use: "show <id>",
Short: "Show account (client-side lookup; API show is not implemented upstream yet)",
Short: "Show account",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// NOTE: Sure currently does not implement GET /api/v1/accounts/:id (route exists but controller/view missing).
// Workaround: fetch list and find by id.
client := api.New()
var res map[string]any
r, err := client.Get("/api/v1/accounts", &res)
var res any
path := fmt.Sprintf("/api/v1/accounts/%s", url.PathEscape(args[0]))
r, err := client.Get(path, &res)
if err != nil {
output.Fail("request_failed", err.Error(), nil)
}
accounts, _ := res["accounts"].([]any)
for _, a := range accounts {
m, ok := a.(map[string]any)
if !ok {
continue
}
if m["id"] == args[0] {
_ = output.Print(format, output.Envelope{Data: m, Meta: &output.Meta{Status: r.StatusCode()}})
return
}
}
output.Fail("not_found", fmt.Sprintf("account %s not found", args[0]), map[string]any{"hint": "API endpoint GET /api/v1/accounts/:id is not implemented upstream; using list lookup"})
_ = output.Print(format, output.Envelope{Data: res, Meta: &output.Meta{Status: r.StatusCode()}})
},
})

Expand Down
57 changes: 40 additions & 17 deletions cmd/sure-cli/root/trades_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package root
import (
"fmt"
"net/url"
"strings"

"github.com/spf13/cobra"
"github.com/we-promise/sure-cli/internal/api"
Expand All @@ -13,9 +14,10 @@ func newTradesCmd() *cobra.Command {
cmd := &cobra.Command{Use: "trades", Short: "Trades"}

var from, to string
var account, symbol string
var startDate, endDate string
var account, accountID string
var accountIDs []string
var page, perPage int
var limit int

list := &cobra.Command{
Use: "list",
Expand All @@ -24,27 +26,33 @@ func newTradesCmd() *cobra.Command {
client := api.New()

q := url.Values{}
if from != "" {
q.Set("from", from)
if startDate == "" {
startDate = from
}
if to != "" {
q.Set("to", to)
if endDate == "" {
endDate = to
}
if account != "" {
q.Set("account", account)
if accountID == "" {
accountID = account
}
if symbol != "" {
q.Set("symbol", symbol)
if startDate != "" {
q.Set("start_date", startDate)
}
if endDate != "" {
q.Set("end_date", endDate)
}
if accountID != "" {
q.Set("account_id", accountID)
}
for _, id := range splitTradeFlagValues(accountIDs) {
q.Add("account_ids[]", id)
}
if page > 0 {
q.Set("page", fmt.Sprintf("%d", page))
}
if perPage > 0 {
q.Set("per_page", fmt.Sprintf("%d", perPage))
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}

u := url.URL{Path: "/api/v1/trades", RawQuery: q.Encode()}
path := u.String()
Expand All @@ -62,11 +70,13 @@ func newTradesCmd() *cobra.Command {

list.Flags().StringVar(&from, "from", "", "start date (YYYY-MM-DD)")
list.Flags().StringVar(&to, "to", "", "end date (YYYY-MM-DD)")
list.Flags().StringVar(&account, "account", "", "account id")
list.Flags().StringVar(&symbol, "symbol", "", "symbol/ticker")
list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD, maps to start_date)")
list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD, maps to end_date)")
list.Flags().StringVar(&account, "account", "", "account id (alias for --account-id)")
list.Flags().StringVar(&accountID, "account-id", "", "account id")
list.Flags().StringSliceVar(&accountIDs, "account-ids", nil, "account ids (repeat or comma-separated)")
list.Flags().IntVar(&page, "page", 1, "page number")
list.Flags().IntVar(&perPage, "per-page", 25, "items per page (maps to per_page)")
list.Flags().IntVar(&limit, "limit", 50, "max results")
cmd.AddCommand(list)

cmd.AddCommand(&cobra.Command{
Expand All @@ -76,7 +86,7 @@ func newTradesCmd() *cobra.Command {
Run: func(cmd *cobra.Command, args []string) {
client := api.New()
var res any
path := fmt.Sprintf("/api/v1/trades/%s", args[0])
path := fmt.Sprintf("/api/v1/trades/%s", url.PathEscape(args[0]))
r, err := client.Get(path, &res)
if err != nil {
output.Fail("request_failed", err.Error(), nil)
Expand All @@ -89,3 +99,16 @@ func newTradesCmd() *cobra.Command {

return cmd
}

func splitTradeFlagValues(values []string) []string {
var out []string
for _, value := range values {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
}
return out
}
6 changes: 3 additions & 3 deletions cmd/sure-cli/root/trades_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ func TestTradesList_Flags(t *testing.T) {
}

// Verify expected flags exist
expectedFlags := []string{"from", "to", "account", "symbol", "page", "per-page", "limit"}
expectedFlags := []string{"from", "to", "start-date", "end-date", "account", "account-id", "account-ids", "page", "per-page"}
for _, name := range expectedFlags {
if list.Flags().Lookup(name) == nil {
t.Fatalf("expected flag %q to exist", name)
Expand All @@ -26,8 +26,8 @@ func TestTradesList_Flags(t *testing.T) {
if !strings.Contains(s, "from") {
t.Fatalf("expected from in usage")
}
if !strings.Contains(s, "symbol") {
t.Fatalf("expected symbol in usage")
if !strings.Contains(s, "account-id") {
t.Fatalf("expected account-id in usage")
}
}

Expand Down
105 changes: 85 additions & 20 deletions cmd/sure-cli/root/transactions_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,22 @@ import (
"net/url"
"strings"

"github.com/spf13/cobra"
"github.com/we-promise/sure-cli/internal/api"
"github.com/we-promise/sure-cli/internal/output"
"github.com/spf13/cobra"
)

func newTransactionsCmd() *cobra.Command {
cmd := &cobra.Command{Use: "transactions", Short: "Transactions"}

var from, to string
var startDate, endDate string
var account, category, merchant string
var accountID, categoryID, merchantID string
var typ, search string
var accountIDs, categoryIDs, merchantIDs, tagIDs []string
var minAmount, maxAmount string
var page, perPage int
var limit int

list := &cobra.Command{
Use: "list",
Expand All @@ -25,30 +29,66 @@ func newTransactionsCmd() *cobra.Command {
client := api.New()

q := url.Values{}
if from != "" {
q.Set("from", from)
if startDate == "" {
startDate = from
}
if endDate == "" {
endDate = to
}
if accountID == "" {
accountID = account
}
if categoryID == "" {
categoryID = category
}
if merchantID == "" {
merchantID = merchant
}
if startDate != "" {
q.Set("start_date", startDate)
}
if endDate != "" {
q.Set("end_date", endDate)
}
if accountID != "" {
q.Set("account_id", accountID)
}
if categoryID != "" {
q.Set("category_id", categoryID)
}
if to != "" {
q.Set("to", to)
if merchantID != "" {
q.Set("merchant_id", merchantID)
}
if account != "" {
q.Set("account", account)
if minAmount != "" {
q.Set("min_amount", minAmount)
}
if category != "" {
q.Set("category", category)
if maxAmount != "" {
q.Set("max_amount", maxAmount)
}
if merchant != "" {
q.Set("merchant", merchant)
if typ != "" {
q.Set("type", typ)
}
if search != "" {
q.Set("search", search)
}
for _, id := range splitFlagValues(accountIDs) {
q.Add("account_ids[]", id)
}
for _, id := range splitFlagValues(categoryIDs) {
q.Add("category_ids[]", id)
}
for _, id := range splitFlagValues(merchantIDs) {
q.Add("merchant_ids[]", id)
}
for _, id := range splitFlagValues(tagIDs) {
q.Add("tag_ids[]", id)
}
if page > 0 {
q.Set("page", fmt.Sprintf("%d", page))
}
if perPage > 0 {
q.Set("per_page", fmt.Sprintf("%d", perPage))
}
if limit > 0 {
q.Set("limit", fmt.Sprintf("%d", limit))
}

path := "/api/v1/transactions"
if enc := strings.TrimPrefix(q.Encode(), ""); enc != "" {
Expand All @@ -66,12 +106,24 @@ func newTransactionsCmd() *cobra.Command {

list.Flags().StringVar(&from, "from", "", "start date (YYYY-MM-DD)")
list.Flags().StringVar(&to, "to", "", "end date (YYYY-MM-DD)")
list.Flags().StringVar(&account, "account", "", "account id")
list.Flags().StringVar(&category, "category", "", "category id")
list.Flags().StringVar(&merchant, "merchant", "", "merchant id")
list.Flags().StringVar(&startDate, "start-date", "", "start date (YYYY-MM-DD, maps to start_date)")
list.Flags().StringVar(&endDate, "end-date", "", "end date (YYYY-MM-DD, maps to end_date)")
list.Flags().StringVar(&account, "account", "", "account id (alias for --account-id)")
list.Flags().StringVar(&category, "category", "", "category id (alias for --category-id)")
list.Flags().StringVar(&merchant, "merchant", "", "merchant id (alias for --merchant-id)")
list.Flags().StringVar(&accountID, "account-id", "", "account id")
list.Flags().StringVar(&categoryID, "category-id", "", "category id")
list.Flags().StringVar(&merchantID, "merchant-id", "", "merchant id")
list.Flags().StringVar(&minAmount, "min-amount", "", "minimum amount")
list.Flags().StringVar(&maxAmount, "max-amount", "", "maximum amount")
list.Flags().StringVar(&typ, "type", "", "transaction type: income|expense")
list.Flags().StringVar(&search, "search", "", "search name, notes, or merchant")
list.Flags().StringSliceVar(&accountIDs, "account-ids", nil, "account ids (repeat or comma-separated)")
list.Flags().StringSliceVar(&categoryIDs, "category-ids", nil, "category ids (repeat or comma-separated)")
list.Flags().StringSliceVar(&merchantIDs, "merchant-ids", nil, "merchant ids (repeat or comma-separated)")
list.Flags().StringSliceVar(&tagIDs, "tag-ids", nil, "tag ids (repeat or comma-separated)")
list.Flags().IntVar(&page, "page", 1, "page number")
list.Flags().IntVar(&perPage, "per-page", 25, "items per page (maps to per_page)")
list.Flags().IntVar(&limit, "limit", 50, "max results")
cmd.AddCommand(list)

cmd.AddCommand(&cobra.Command{
Expand All @@ -81,7 +133,7 @@ func newTransactionsCmd() *cobra.Command {
Run: func(cmd *cobra.Command, args []string) {
client := api.New()
var res any
path := fmt.Sprintf("/api/v1/transactions/%s", args[0])
path := fmt.Sprintf("/api/v1/transactions/%s", url.PathEscape(args[0]))
r, err := client.Get(path, &res)
if err != nil {
output.Fail("request_failed", err.Error(), nil)
Expand All @@ -96,3 +148,16 @@ func newTransactionsCmd() *cobra.Command {

return cmd
}

func splitFlagValues(values []string) []string {
var out []string
for _, value := range values {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
}
return out
}
10 changes: 5 additions & 5 deletions cmd/sure-cli/root/transactions_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ func TestTransactionsList_QueryString(t *testing.T) {
}

// build flags
_ = list.Flags().Set("from", "2026-01-01")
_ = list.Flags().Set("to", "2026-01-31")
_ = list.Flags().Set("account", "1")
_ = list.Flags().Set("limit", "10")
_ = list.Flags().Set("start-date", "2026-01-01")
_ = list.Flags().Set("end-date", "2026-01-31")
_ = list.Flags().Set("account-id", "1")
_ = list.Flags().Set("type", "expense")

// We don't call Run (would hit network), but we can ensure flags exist and are set.
// This test mainly guards against accidental flag removal/renaming.
for _, name := range []string{"from", "to", "account", "category", "merchant", "page", "per-page", "limit"} {
for _, name := range []string{"from", "to", "start-date", "end-date", "account", "account-id", "category-id", "merchant-id", "type", "search", "page", "per-page"} {
if list.Flags().Lookup(name) == nil {
t.Fatalf("expected flag %q to exist", name)
}
Expand Down
Loading
Loading