diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0c9d663 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Lint + uses: golangci/golangci-lint-action@v7 + + - name: Test + run: make test + + - name: Build + run: make build + + - name: Check goreleaser config + uses: goreleaser/goreleaser-action@v6 + with: + args: check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9f7bd2f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index a372f43..d3fc989 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,8 @@ token.json config.json .DS_Store *.test +coverage.out +coverage.html +dist/ +token.json.lock +token.json.tmp diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0af7aaa --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,32 @@ +version: "2" + +linters: + enable: + - bodyclose + - errorlint + - misspell + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + - fmt.Print + - fmt.Printf + - fmt.Println + - (io.Writer).Write + - (*text/tabwriter.Writer).Flush + - (*github.com/spf13/cobra.Command).MarkFlagRequired + - (*net/http.Server).Serve + - (*net/http.Server).Shutdown + - (*github.com/gofrs/flock.Flock).Unlock + - os.Remove + - (*encoding/json.Encoder).Encode + - (*encoding/json.Decoder).Decode + - (net/http.ResponseWriter).Write + - (io.Closer).Close + - (net.Listener).Close + - (*net.TCPListener).Close + +run: + timeout: 5m diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5034187..214f82e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,11 +17,13 @@ builds: - arm64 archives: - - format: tar.gz + - formats: + - tar.gz name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: + - zip checksum: name_template: checksums.txt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..db2161c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing to mf-cli + +## Development Setup + +1. Go 1.26+ required +2. Clone the repository +3. Run `make test` to verify your setup + +### Optional Tools + +- [golangci-lint](https://golangci-lint.run/) v2 — `make lint` +- [goreleaser](https://goreleaser.com/) — `goreleaser check` + +## Making Changes + +- Follow existing code patterns (Cobra commands in `cmd/`, business logic in `internal/`) +- All external API calls must be mockable via `httptest` +- Run `make lint` before submitting + +## Testing Policy + +- Run: `make test` (includes `-race` flag) +- See [CLAUDE.md](CLAUDE.md) for the full testing policy + +## Pull Requests + +- One feature/fix per PR +- Ensure CI passes (lint + test) +- Japanese or English in commit messages — both are welcome diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..272e67a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 beatinaniwa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bd0dd38 --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build test lint coverage coverage-html clean + +build: + go build -o mf . + +test: + go test -race -count=1 ./... + +lint: + golangci-lint run ./... + +coverage.out: + go test -race -coverprofile=coverage.out ./... + +coverage: coverage.out + go tool cover -func=coverage.out + +coverage-html: coverage.out + go tool cover -html=coverage.out -o coverage.html + +clean: + rm -f mf coverage.out coverage.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..86764a8 --- /dev/null +++ b/README.md @@ -0,0 +1,242 @@ +# mf - MoneyForward会計 CLI + +[![Go](https://img.shields.io/github/go-mod/go-version/beatinaniwa/mf-cli)](https://go.dev/) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![CI](https://github.com/beatinaniwa/mf-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/beatinaniwa/mf-cli/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/beatinaniwa/mf-cli)](https://github.com/beatinaniwa/mf-cli/releases) + +[マネーフォワード クラウド会計](https://biz.moneyforward.com/accounting)のAPIを操作するコマンドラインツールです。 + +> **Note:** リポジトリ名は `mf-cli` ですが、インストールされるバイナリ名は `mf` です。 + +## 特徴 + +- OAuth 2.0 + PKCE による認証 +- 仕訳帳・勘定科目・取引先・部門・税区分などのCRUD操作 +- 試算表・推移表などの財務レポート取得 +- JSON / テーブル形式での出力切替 +- `--dry-run` による安全な事前確認 +- `--fields` によるJSONフィールドフィルタリング +- 組み込みOpenAPIスキーマブラウザ(`mf describe`) +- AIエージェントとの連携に最適化されたJSON出力 + +## インストール + +### go install + +```bash +go install github.com/beatinaniwa/mf-cli@latest +``` + +### バイナリダウンロード + +[GitHub Releases](https://github.com/beatinaniwa/mf-cli/releases) から各プラットフォーム向けのバイナリをダウンロードできます。 + +対応プラットフォーム: Linux / macOS / Windows(amd64 / arm64) + +### ソースからビルド + +```bash +git clone https://github.com/beatinaniwa/mf-cli.git +cd mf-cli +make build # ./mf が生成されます +``` + +## クイックスタート + +### 1. APIクレデンシャルの取得 + +[マネーフォワード クラウド API](https://api.biz.moneyforward.com/) でアプリケーションを登録し、クライアントIDを取得してください。 + +**リダイレクトURIの登録が必要です:** + +``` +http://127.0.0.1:8089/callback +``` + +> `MF_AUTH_PORT` を変更する場合は、リダイレクトURIのポート番号も合わせて変更してください。 + +### 2. 環境変数の設定 + +```bash +export MF_CLIENT_ID=your_client_id +export MF_CLIENT_SECRET=your_client_secret # 公開クライアントの場合は省略可 +``` + +### 3. 認証 + +```bash +# ブラウザで認証(デフォルト) +mf auth login + +# 書き込み権限も含める場合 +mf auth login --scopes all + +# ブラウザを使わずに認証(SSHセッション等) +mf auth login --no-browser + +# 認証状態の確認 +mf auth status +``` + +### 4. 基本的な使い方 + +```bash +# 事業所情報を表示 +mf office + +# 勘定科目一覧をテーブル形式で表示 +mf accounts --format table + +# 仕訳帳を取得 +mf journals list --start-date 2025-01-01 --end-date 2025-03-31 + +# 仕訳を新規作成(dry-run で事前確認) +mf journals create --json '{"..."}' --dry-run + +# 試算表(P/L)を取得 +mf reports trial-balance-pl --fiscal-year 2024 + +# APIスキーマを確認 +mf describe --list # 利用可能なリソース一覧 +mf describe journals # journals の詳細 +``` + +## コマンド一覧 + +| コマンド | 説明 | テーブル | dry-run | --json | +|---------|------|:------:|:------:|:------:| +| `auth login` | OAuth認証 | | | | +| `auth status` | 認証状態表示(JSON) | | | | +| `auth logout` | トークン削除 | | | | +| `accounts` | 勘定科目一覧 | o | | | +| `sub-accounts` | 補助科目一覧 | o | | | +| `departments` | 部門一覧 | o | | | +| `taxes` | 税区分一覧 | o | | | +| `connected-accounts` | 連携口座一覧 | o | | | +| `office` | 事業所情報 | o | | | +| `trade-partners list` | 取引先一覧 | o | | | +| `trade-partners create` | 取引先作成 | | o | o | +| `journals list` | 仕訳一覧 | o | | | +| `journals get ` | 仕訳詳細 | | | | +| `journals create` | 仕訳作成 | | o | o | +| `journals update ` | 仕訳更新 | | o | o | +| `journals delete ` | 仕訳削除 | | o | | +| `transactions create` | 取引作成 | | o | o | +| `vouchers create` | 証憑作成 | | o | o | +| `vouchers delete` | 証憑削除 | | o | | +| `reports trial-balance-bs` | 試算表(B/S) | | | | +| `reports trial-balance-pl` | 試算表(P/L) | o | | | +| `reports transition-bs` | 推移表(B/S) | | | | +| `reports transition-pl` | 推移表(P/L) | | | | +| `describe [resource]` | APIスキーマ表示 | o | | | +| `version` | バージョン表示 | | | | + +## 入出力 + +### 出力形式 + +```bash +# JSON出力(デフォルト) +mf accounts + +# テーブル出力(対応コマンドのみ) +mf accounts --format table + +# 特定フィールドのみ出力(JSONモードのみ有効) +mf accounts --fields id,display_name + +# デバッグ出力(HTTPリクエスト/レスポンス詳細) +mf accounts --debug +``` + +> `--fields` はJSON出力時のみフィルタリングが適用されます。テーブルモードでは無視されます。 +> `reports trial-balance-pl` は `--fields` 指定時、テーブルの代わりにJSON出力にフォールバックします。 + +### JSON入力 + +書き込みコマンドでは `--json` フラグでリクエストボディを指定します。 + +```bash +# 直接指定 +mf journals create --json '{"description": "test"}' + +# 標準入力から読み込み +cat request.json | mf journals create --json - +``` + +### dry-run + +書き込みコマンドでは `--dry-run` で実行前にリクエスト内容を確認できます。 + +```bash +mf journals create --json '{"..."}' --dry-run +``` + +## 設定 + +### 環境変数 + +| 変数 | 説明 | デフォルト | +|------|------|-----------| +| `MF_CLIENT_ID` | OAuthクライアントID | (必須) | +| `MF_CLIENT_SECRET` | OAuthクライアントシークレット | (公開クライアントでは省略可) | +| `MF_AUTH_PORT` | ローカルコールバックポート | `8089` | +| `MF_CONFIG_DIR` | 設定ディレクトリのパス | OS依存(下記参照) | +| `MF_SCOPES` | スペース区切りのスコープリスト | `--scopes` フラグより優先 | + +環境変数は設定ファイルの値を上書きします。 + +### 設定ファイル + +設定ファイルの保存場所は OS の `UserConfigDir` に基づきます: + +| OS | パス | +|----|------| +| macOS | `~/Library/Application Support/mf-cli/` | +| Linux | `~/.config/mf-cli/` | +| Windows | `%AppData%\mf-cli\` | + +`MF_CONFIG_DIR` 環境変数でオーバーライドできます。 + +#### config.json + +```json +{ + "client_id": "your_client_id", + "client_secret": "your_client_secret", + "base_url": "https://api-accounting.moneyforward.com", + "auth_port": 8089 +} +``` + +#### token.json + +認証トークンは同ディレクトリ内の `token.json` に自動保存されます。 + +## AIエージェント向け + +mf-cli はAIエージェント(Claude Code、Codex等)からの利用に適した設計です。 + +- **リソースコマンド**(accounts, journals, reports 等)と `auth status` はデフォルトでJSON出力 +- `auth login/logout`、`version` は平文出力 +- `--fields` でJSON出力から必要なフィールドのみ抽出可能 +- `--dry-run` で安全にリクエスト内容を事前確認 +- `--json -` で標準入力からJSONを渡せる +- エラーは構造化JSONとしてstderrに出力 + +## 開発 + +```bash +make build # バイナリをビルド +make test # テスト実行(-race付き) +make lint # golangci-lint実行 +make coverage # カバレッジレポート +make clean # 成果物を削除 +``` + +詳細は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。 + +## ライセンス + +[MIT License](LICENSE) diff --git a/cmd/auth.go b/cmd/auth.go index 318aae4..2dae844 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -39,7 +39,7 @@ var authLoginCmd = &cobra.Command{ exitError(err, 1) } - scopes := auth.DefaultScopes + var scopes []string if envScopes := os.Getenv("MF_SCOPES"); envScopes != "" { scopes = strings.Split(envScopes, " ") } else { diff --git a/cmd/helpers.go b/cmd/helpers.go index 6291fb1..e90b3fc 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -1,7 +1,9 @@ package cmd import ( + "bytes" "encoding/json" + "errors" "fmt" "io" "os" @@ -36,25 +38,18 @@ func outputResult(data []byte, tableRenderer func([]byte)) { data = filtered } - // Write raw JSON to stdout. - var indented json.RawMessage - if err := json.Unmarshal(data, &indented); err != nil { - // If it's not valid JSON, write as-is. + var buf bytes.Buffer + if err := json.Indent(&buf, data, "", " "); err != nil { fmt.Fprintln(os.Stdout, string(data)) return } - pretty, err := json.MarshalIndent(indented, "", " ") - if err != nil { - fmt.Fprintln(os.Stdout, string(data)) - return - } - fmt.Fprintln(os.Stdout, string(pretty)) + fmt.Fprintln(os.Stdout, buf.String()) } // exitError prints an error in JSON format to stderr and exits. func exitError(err error, code int) { - apiErr, ok := err.(*api.APIError) - if ok { + var apiErr *api.APIError + if errors.As(err, &apiErr) { output.PrintError(os.Stderr, apiErr.Error(), apiErr.StatusCode, apiErr.Operation, apiErr.Errors, apiErr.RawResponse) } else { output.PrintError(os.Stderr, err.Error(), 0, "", nil, "") @@ -88,9 +83,3 @@ func handleDryRun(client *api.Client, method, path string, body any) { fmt.Fprintln(os.Stdout, string(data)) } -func unmarshalOrExit(data []byte, v any) { - if err := json.Unmarshal(data, v); err != nil { - output.PrintError(os.Stderr, fmt.Sprintf("failed to parse response: %s", err), 0, "", nil, "") - os.Exit(1) - } -} diff --git a/internal/api/do_test.go b/internal/api/do_test.go index 309d15a..b330c87 100644 --- a/internal/api/do_test.go +++ b/internal/api/do_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -184,8 +185,8 @@ func TestDo_401_RefreshFails_ReturnsOriginalError(t *testing.T) { if err == nil { t.Fatal("expected error") } - apiErr, ok := err.(*APIError) - if !ok { + var apiErr *APIError + if !errors.As(err, &apiErr) { t.Fatalf("expected *APIError, got %T: %v", err, err) } if apiErr.StatusCode != 401 { @@ -224,8 +225,8 @@ func TestDo_401_RefreshSuccess_RetryStillFails(t *testing.T) { if err == nil { t.Fatal("expected error when retry also returns 401") } - apiErr, ok := err.(*APIError) - if !ok { + var apiErr *APIError + if !errors.As(err, &apiErr) { t.Fatalf("expected *APIError, got %T", err) } if apiErr.StatusCode != 401 { @@ -346,8 +347,8 @@ func TestDo_POST_429_ReturnsErrorWithRetryAfter(t *testing.T) { if err == nil { t.Fatal("expected error for POST 429") } - apiErr, ok := err.(*APIError) - if !ok { + var apiErr *APIError + if !errors.As(err, &apiErr) { t.Fatalf("expected *APIError, got %T", err) } if apiErr.StatusCode != 429 { @@ -374,7 +375,10 @@ func TestDo_POST_429_NoRetryAfterHeader(t *testing.T) { if err == nil { t.Fatal("expected error for POST 429") } - apiErr := err.(*APIError) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T", err) + } if apiErr.Operation != "rate limited (429)" { t.Errorf("Operation = %q, want %q", apiErr.Operation, "rate limited (429)") } @@ -400,8 +404,8 @@ func TestDo_400_ReturnsAPIError(t *testing.T) { if err == nil { t.Fatal("expected error for 400") } - apiErr, ok := err.(*APIError) - if !ok { + var apiErr *APIError + if !errors.As(err, &apiErr) { t.Fatalf("expected *APIError, got %T", err) } if apiErr.StatusCode != 400 { @@ -428,7 +432,10 @@ func TestDo_404_ReturnsAPIError(t *testing.T) { if err == nil { t.Fatal("expected error for 404") } - apiErr := err.(*APIError) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T", err) + } if apiErr.StatusCode != 404 { t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode) } diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 41b227c..23bb83e 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -681,12 +681,6 @@ func freePort(t *testing.T) int { return port } -// callbackServerResult is shared by StartCallbackServer tests. -type callbackServerResult struct { - code string - err error -} - func TestStartCallbackServer_Success(t *testing.T) { state := "test-state-abc" code := "auth-code-xyz" diff --git a/internal/client/client.go b/internal/client/client.go index 71f6a8a..c71c448 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -49,7 +49,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values) return nil, fmt.Errorf("getting token: %w", err) } - status, body, resp, err := c.sendRequest(ctx, method, path, query, token) + status, body, resp, err := c.sendRequest(ctx, method, path, query, token) //nolint:bodyclose // body is closed inside sendRequest if err != nil { return nil, err } @@ -59,7 +59,7 @@ func (c *Client) do(ctx context.Context, method, path string, query url.Values) if refreshErr != nil { return nil, fmt.Errorf("refreshing token: %w", refreshErr) } - status, body, resp, err = c.sendRequest(ctx, method, path, query, newToken) + status, body, resp, err = c.sendRequest(ctx, method, path, query, newToken) //nolint:bodyclose // body is closed inside sendRequest if err != nil { return nil, err } @@ -115,7 +115,7 @@ func (c *Client) retryWithBackoff(ctx context.Context, method, path string, quer case <-time.After(delay): } - status, body, resp, err := c.sendRequest(ctx, method, path, query, token) + status, body, resp, err := c.sendRequest(ctx, method, path, query, token) //nolint:bodyclose // body is closed inside sendRequest if err != nil { return nil, err }