Skip to content
Merged
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
122 changes: 36 additions & 86 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,28 @@

Monarch recently shut down their MCP solution. Other solutions are written in interpreted languages. I wanted a self-contained, single binary solution. And I like learning and building things.

## What it does
## Features

- Lists accounts, transactions, categories, and budgets.
- Fetches transaction details, cashflow summaries, and a financial overview.
- Provides responsive terminal tables and a keyboard-driven transaction pager.
- Emits machine-readable JSON for scripts and pipes.
- Serves the same read-only operations as MCP tools over stdio.
- Stores sessions in the operating system keyring—never a plaintext config file.
- Nice TUI for browsing your transactions.
- Structured output mode for all your agents <3
- All features are available as MCP tools over stdio.
- Stores sessions in the operating system keychain for safety.

Write operations and arbitrary GraphQL are deliberately out of scope.
No write operations (yet).

## Install

### Tell an agent
Tell Codex or another agent to set this up:

Give your coding agent the following instructions:

> Install Monarch CLI from `github.com/matteing/monarch-cli`. Inspect the
> repository before running anything, use mise to download the pinned Go
> toolchain, run the full audit, build and install the CLI, then configure its
> local stdio MCP server. Discover the available commands with `monarch --help`
> and the relevant subcommand help, inspect the MCP tools, and write your own
> local skill for using the read-only interface safely. Do not ask me for
> credentials or place them in commands—stop and ask me to run
> `monarch auth login` in a real terminal. Finish by running `monarch doctor`
> without printing my financial data.
> Install Monarch CLI from `github.com/matteing/monarch-cli`. Review the source,
> use its mise-pinned Go toolchain, run `mise run audit` and `mise run install`,
> then configure stdio MCP. Discover commands with `monarch --help` and inspect
> the MCP tools. Create a local skill named **Monarch CLI** with the exact
> description `Query Monarch Money transactions, accounts, balances, and more.`
> Keep it read-only. Never handle my credentials; ask me to run
> `monarch auth login`. Verify with `monarch doctor` without exposing my data.

### Install manually

Expand All @@ -53,46 +49,18 @@ mise run install
monarch --help
```

The repository pins the supported Go toolchain in `mise.toml`; a separate Go
installation is not required. On Unix-like systems, `mise run install` writes
to `~/.local/bin` by default. On Windows it uses
`%LOCALAPPDATA%\monarch-cli\bin`. Set `MONARCH_INSTALL_DIR` to choose another
user-owned directory. The selected directory must be on `PATH`: add
`$HOME/.local/bin` to your shell profile on Unix-like systems, or add the
Windows directory to your user `PATH`, when it is not already present.

Release builds report their tag through `monarch --version`. Source builds
report `dev-<commit>` and append `-dirty` for local changes (or `dev-dirty`
before a checkout has its first commit), so update verification is meaningful.

## Sign in

```sh
monarch auth login
monarch auth status
monarch doctor
```

Your password and MFA code are not stored anywhere. Monarch CLI keeps only the
resulting session credential in your OS keyring. Cancellation is honored until
the keyring commit begins. Native keyring writes cannot be canceled once they
start, so the CLI waits for that commit and reports its result.

If password login is blocked by CAPTCHA, you can import an existing browser
session instead:
resulting session credential in your OS keyring.

```sh
monarch auth login --method browser-session
```
### Multiple accounts

Copy the `Cookie` request header from an authenticated request in a signed-in
`app.monarch.com` tab. The prompt is hidden, and only the cookies required for
authentication are retained in the OS keyring. There is no plaintext fallback.
Saved records are strict and minimal; if an older record contains extra fields
or cookies, replace it explicitly with `monarch auth login --force`.

Profiles are just named keyring slots. Ignore them if you use one Monarch
account. Names are 1–64 characters, start with a letter or digit, and otherwise
We support profiles, which are just different keyring slots. Names are 1–64 characters, start with a letter or digit, and otherwise
use only letters, digits, dots, underscores, or hyphens. If you use more than
one account or household:

Expand All @@ -105,6 +73,7 @@ monarch --profile household auth logout
## Use the CLI

```text
# some sample commands...
monarch accounts list
monarch transactions list [filters]
monarch transactions get TRANSACTION_ID
Expand All @@ -114,17 +83,9 @@ monarch cashflow summary
monarch overview
```

Human-readable tables are the default. In a terminal, `transactions list`
opens a responsive pager and groups transactions by month:

- `up` / `down` scroll the current page.
- `left` / `right` load the previous or next API page.
- `q`, `esc`, or `ctrl+c` close the pager.
Human-readable tables are the default.

Visited pages are cached. Use `--group none` for one continuous table,
`--limit` to set a page size, or `--cursor` to continue from an earlier result.

For automation, use JSON:
For agents, use JSON:

```sh
monarch transactions list \
Expand All @@ -134,15 +95,19 @@ monarch transactions list \
--output json
```

JSON and piped table output return one bounded page. The JSON response includes
an opaque `next_cursor` when another page is available. Reuse a cursor only with
the same normalized filters and ordering that produced it. Amounts are exact
decimal strings rather than JSON numbers.
- JSON and piped table output return one bounded page.
- The JSON response includes
an opaque `next_cursor` when another page is available.
- Reuse a cursor only with
the same normalized filters and ordering that produced it.
- Amounts are exact
decimal strings rather than JSON numbers.

## Use the MCP server

Sign in once from a real terminal, then configure your MCP host with the
absolute path to the binary:
**You must login using a terminal app before setting this up**.

After signing in, in your `mcp.json`:

```json
{
Expand All @@ -155,27 +120,11 @@ absolute path to the binary:
}
```

The stdio server exposes:

```text
monarch_accounts_list
monarch_transactions_list
monarch_transaction_get
monarch_categories_list
monarch_budgets_get
monarch_cashflow_summary
monarch_financial_overview
```

Each tool has explicit input/output JSON Schema and read-only annotations. MCP
stdout is reserved for JSON-RPC; diagnostics go to stderr. Stdio input is
bounded to 512 KiB per newline-delimited message and 16 MiB per server process;
clients with unusually long sessions should reconnect before the session
budget is exhausted.
Afterwards, ask it what it can do! We expose _just about everything_ that the CLI can do.

## Configuration

Optional non-secret configuration lives at
Some non-secret config lives at
`$XDG_CONFIG_HOME/monarch-cli/config.json` on Unix-like systems, or the
platform-equivalent user config directory:

Expand All @@ -193,6 +142,8 @@ Environment overrides are `MONARCH_PROFILE`, `MONARCH_OUTPUT`,
`MONARCH_TIMEOUT`, `MONARCH_LOG_LEVEL`, and `MONARCH_LOG_FORMAT`. Flags take
final precedence. None of these settings accepts credentials.

### Contracts

Stable exit codes are `2` for invalid input or a missing resource, `3` for
authentication, `4` for a rate limit, `5` for upstream unavailability, and `6`
for a keyring failure. Canceled commands use `130`; unexpected errors use `1`.
Expand All @@ -207,7 +158,7 @@ mise run # list tasks
mise run fmt # format source
mise run check # format, module, test, vet, staticcheck, and build gate
mise run audit # full gate: check, race, shuffle, coverage, vulnerabilities
mise run test:coverage # enforce the 65% statement coverage floor
mise run test:coverage # enforce the statement coverage floor
mise run test:race # race-enabled tests
mise run vuln # scan reachable code with pinned govulncheck
mise run release:check # validate release configuration
Expand All @@ -221,8 +172,7 @@ without printing your financial data:
mise run test:live
```

It requires a valid session in the selected profile. The normal test suite is
offline: it does not touch a real Monarch account or keyring.
It requires a valid session in the selected profile.

Tagged releases are built for macOS, Linux, and Windows on `amd64` and `arm64`,
with checksums. Push a tag such as `v0.1.0` to start the release workflow.
Expand Down
8 changes: 4 additions & 4 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,10 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string)
return session.Session{}, apperr.New(apperr.KindUnavailable, op, "Monarch returned malformed login JSON", decodeErr)
}
if strings.Count(decoded.Token, ".") == 2 {
return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned a short-lived feature token; use browser-session login", nil)
return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned an unsupported short-lived feature token", nil)
}
if expiration := strings.TrimSpace(string(decoded.TokenExpiration)); expiration != "" && expiration != "null" && expiration != `"null"` {
return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned a short-lived token; use browser-session login", nil)
return session.Session{}, apperr.New(apperr.KindAuth, op, "Monarch returned an unsupported short-lived token", nil)
}
value, err := session.NewToken(decoded.Token)
if err != nil {
Expand All @@ -130,7 +130,7 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string)
}
if resp.StatusCode == http.StatusForbidden {
if decodeErr == nil && strings.EqualFold(decoded.ErrorCode, "CAPTCHA_REQUIRED") {
return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "programmatic login is blocked by CAPTCHA; retry with `monarch auth login --method browser-session`", StatusCode: resp.StatusCode}
return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "programmatic login is blocked by CAPTCHA", StatusCode: resp.StatusCode}
}
if decodeErr == nil && mfaRequired(decoded.ErrorCode, decoded.Detail) {
message := "MFA code required"
Expand All @@ -148,7 +148,7 @@ func (a *Authenticator) Login(ctx context.Context, email, password, totp string)
}
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "Monarch rejected the login; check credentials or use browser-session login", StatusCode: resp.StatusCode}
return session.Session{}, &apperr.Error{Kind: apperr.KindAuth, Op: op, Message: "Monarch rejected the login; check your credentials", StatusCode: resp.StatusCode}
}
return session.Session{}, &apperr.Error{Kind: apperr.KindUnavailable, Op: op, Message: fmt.Sprintf("Monarch login failed with HTTP %d", resp.StatusCode), StatusCode: resp.StatusCode, Retryable: resp.StatusCode >= 500}
}
Expand Down
14 changes: 0 additions & 14 deletions internal/auth/method.go

This file was deleted.

21 changes: 5 additions & 16 deletions internal/command/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"github.com/spf13/cobra"

"github.com/matteing/monarch-cli/internal/apperr"
"github.com/matteing/monarch-cli/internal/auth"
"github.com/matteing/monarch-cli/internal/session"
"github.com/matteing/monarch-cli/internal/tui"
)
Expand All @@ -22,7 +21,6 @@ type loginResult struct {
}

func (a *application) loginCommand() *cobra.Command {
var method string
var force bool
command := &cobra.Command{
Use: "login",
Expand All @@ -31,10 +29,6 @@ func (a *application) loginCommand() *cobra.Command {
"Your password and MFA code are not stored anywhere. They are discarded after login; password login keeps only the session token.",
Args: noArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
loginMethod := auth.Method(method)
if !loginMethod.Valid() {
return apperr.New(apperr.KindInvalidInput, "login", "pick password or browser-session for --method", nil)
}
if !force {
existing, loadErr := a.store.Load(a.config.Profile)
if loadErr == nil {
Expand All @@ -52,7 +46,7 @@ func (a *application) loginCommand() *cobra.Command {
}
}

value, err := a.login(cmd.Context(), loginMethod)
value, err := a.login(cmd.Context())
if err != nil {
return err
}
Expand All @@ -62,37 +56,32 @@ func (a *application) loginCommand() *cobra.Command {
PasswordSaved: false, MFACodeSaved: false,
})
}
message := loginSuccessMessage(value.Mode)
message := loginSuccessMessage()
if tui.IsTerminal(a.out) {
return tui.WriteSuccess(a.out, message)
}
_, err = fmt.Fprintln(a.out, message)
return err
},
}
command.Flags().StringVar(&method, "method", string(auth.MethodPassword), "login method: password or browser-session")
command.Flags().BoolVar(&force, "force", false, "replace any existing saved session")
return command
}

func loginSuccessMessage(mode session.Mode) string {
if mode == session.ModeCookie {
return "Signed in. Only the required authentication cookies were retained in the OS keyring."
}
func loginSuccessMessage() string {
return "Signed in. Only the session token was saved to the OS keyring."
}

func (a *application) login(ctx context.Context, method auth.Method) (session.Session, error) {
func (a *application) login(ctx context.Context) (session.Session, error) {
if !tui.IsTerminal(a.in) || !tui.IsTerminal(a.errOut) {
return session.Session{}, apperr.New(apperr.KindInvalidInput, "login", "interactive login requires a terminal", nil)
}
authenticate := func(ctx context.Context, email, password, code string) (session.Session, error) {
return a.authenticate(ctx, a.config.Timeout, email, password, code)
}
return tui.RunLogin(tui.LoginOptions{
Context: ctx, Input: a.in, Output: a.errOut, Method: method, Profile: a.config.Profile,
Context: ctx, Input: a.in, Output: a.errOut, Profile: a.config.Profile,
Authenticate: authenticate,
ParseCookies: session.ParseCookieHeader,
Verify: a.verify,
Save: a.store.Save,
})
Expand Down
13 changes: 7 additions & 6 deletions internal/command/copy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@ package command
import (
"strings"
"testing"

"github.com/matteing/monarch-cli/internal/session"
)

func TestLoginCopyStaysFriendlyAndClear(t *testing.T) {
command := (&application{}).loginCommand()
if command.Flags().Lookup("method") != nil {
t.Fatal("login still exposes authentication method selection")
}
for _, phrase := range []string{"save a session credential", "not stored anywhere", "only the session token"} {
if !strings.Contains(command.Short+"\n"+command.Long, phrase) {
t.Fatalf("login copy does not contain %q", phrase)
}
}
}

func TestCookieLoginSuccessDescribesCredentialMinimization(t *testing.T) {
message := loginSuccessMessage(session.ModeCookie)
if !strings.Contains(message, "Only the required authentication cookies were retained") {
t.Fatalf("cookie login success = %q", message)
func TestLoginSuccessDescribesCredentialMinimization(t *testing.T) {
message := loginSuccessMessage()
if !strings.Contains(message, "Only the session token was saved") {
t.Fatalf("login success = %q", message)
}
}
2 changes: 1 addition & 1 deletion internal/command/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func buildRoot(cfg config.Config, deps Dependencies) (*cobra.Command, *applicati
}
root := &cobra.Command{
Use: "monarch", Short: "Read-only Monarch Money CLI and MCP server",
Long: "monarch provides interactive login, read-only queries, JSON output, and a local stdio MCP server for Monarch Money.",
Long: "A joyful CLI utility and MCP server for Monarch Money.",
Version: app.version, SilenceErrors: true, SilenceUsage: true,
TraverseChildren: true, Args: subcommandsOnly, RunE: showHelp,
Annotations: map[string]string{"help-only": "true"},
Expand Down
Loading