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
5 changes: 5 additions & 0 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ jobs:

steps:
- uses: actions/checkout@v6
with:
# Check out the released tag (workflow_run head_branch is the tag
# name), not main HEAD, so the packaging scripts and README match
# the release being published.
ref: ${{ github.event.workflow_run.head_branch }}

- uses: actions/setup-node@v4
with:
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ jobs:

steps:
- uses: actions/checkout@v6
with:
# Check out the released tag (workflow_run head_branch is the tag
# name), not main HEAD, so the packaging scripts and README match
# the release being published.
ref: ${{ github.event.workflow_run.head_branch }}

- uses: actions/setup-python@v5
with:
Expand Down Expand Up @@ -58,4 +63,4 @@ jobs:
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/wheels/
skip_existing: true
skip-existing: true
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ binaries → `publish-pypi.yml` assembles wheels and uploads to PyPI.
```
cmd/mint/main.go # entry point; cobra root command; viper config wiring
internal/llm/llm.go # Completer interface for LLM backends
internal/llm/writer.go # TrailingNewlineWriter shared by provider backends
internal/httpx/httpx.go # shared tuned *http.Client used by every provider
internal/provider/
config.go # Config struct; provider validation
provider.go # NewCompleter factory function
Expand Down Expand Up @@ -96,6 +98,6 @@ bin/mint # compiled binary (gitignored)
- Source language: optional `--source` / `-s` flag (BCP-47 tag); flag-only, no env var (a source is per-input, not a persistent preference). When set it skips detection and anchors the rewrite prompt to translate *from* that language, so cross-language homographs (e.g. French `chat` → English `cat`) and romanized input (e.g. `konnichiwa` → `hello`) are translated rather than treated as already-target text. Empty (the default) preserves the original auto-detect behavior. Pure language-neutral input still passes through unchanged regardless.
- Language-neutral pass-through: if detected language is `neutral`, input is printed unchanged with no translation call.
- Same-language behavior: if detected input language matches the target language, the tool performs grammar and spelling correction instead of translation.
- Target language priority: `--target` flag → `MINT_TARGET_LANG` env var → system locale (`$LC_ALL` / `$LANG`) → `en`.
- Target language priority: `--target` flag → `MINT_TARGET_LANG` env var → system locale (`$LC_ALL` / `$LC_MESSAGES` / `$LANG`) → `en`.
- Language rotation: `MINT_TARGET_LANG` accepts a comma-separated list (e.g., `en,zh-TW,ja`); when the detected input language matches a tag in the list, the tool translates to the next tag (wraps around). BCP-47 variants sharing the same primary subtag (e.g., `zh-HK` and `zh-TW`) are treated as equivalent.
- Input from positional arg or stdin (auto-detected).
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)
# Leading "v" is stripped to match goreleaser's {{.Version}}, so `mint --version`
# prints the same string for local builds and released binaries.
VERSION ?= $(shell (git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD) | sed 's/^v//')
COMMIT ?= $(shell git rev-parse HEAD)
LDFLAGS ?= -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)
NEW_FROM_REV ?= HEAD
Expand Down
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ mint "こんにちは" # ja → en: Hello
| `MINT_PROVIDER` | `google-genai` \| `openai` \| `anthropic` | — (必須) |
| `MINT_API_KEY` | APIキー。デフォルトのエンドポイント使用時は必須。`MINT_BASE_URL`設定時は任意(プロキシ側で認証処理する場合) | — |
| `MINT_BASE_URL` | カスタムAPIベースURL(ドメインのみ指定、パスは各プロバイダーが自動付与)。`openai`と組み合わせることで、Ollama(`http://localhost:11434`)、LM Studio(`http://localhost:1234`)、またはOpenAI互換エンドポイントを指定可能 | プロバイダーのデフォルト |
| `MINT_MODEL_NAME` | 使用するモデル名 | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_MODEL_NAME` | 使用するモデル名(`MINT_BASE_URL` 設定時は必須) | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_TARGET_LANG` | ターゲット言語(例: `en` または `en,zh-TW,ja`) | システムのロケール設定 |
| `MINT_VERBOSE` | `true`に設定すると詳細な診断出力が有効になります(`--verbose`相当) | `false` |

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ mint "こんにちは" # ja → en: Hello
| `MINT_PROVIDER` | `google-genai` \| `openai` \| `anthropic` | — (required) |
| `MINT_API_KEY` | API key; required when using the default endpoint; optional when `MINT_BASE_URL` is set (proxy handles auth) | — |
| `MINT_BASE_URL` | Custom API base URL (domain only; each provider appends its own path); use with `openai` to target Ollama (`http://localhost:11434`), LM Studio (`http://localhost:1234`), or any other OpenAI-compatible endpoint | Provider default |
| `MINT_MODEL_NAME` | Model to use | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_MODEL_NAME` | Model to use; required when `MINT_BASE_URL` is set | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_TARGET_LANG` | Target language(s), e.g. `en` or `en,zh-TW,ja` | System locale |
| `MINT_VERBOSE` | Set to `true` to enable verbose diagnostic output (equivalent to `--verbose`) | `false` |

Expand Down
2 changes: 1 addition & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ mint "こんにちは" # ja → en: Hello
| `MINT_PROVIDER` | `google-genai` \| `openai` \| `anthropic` | — (必填) |
| `MINT_API_KEY` | API 金鑰;使用預設 endpoint 時必填;設定 `MINT_BASE_URL` 時選填(由代理處理認證) | — |
| `MINT_BASE_URL` | 自訂 API base URL(僅填 domain,各提供商自行附加路徑);搭配 `openai` 可指向 Ollama(`http://localhost:11434`)、LM Studio(`http://localhost:1234`)或任何 OpenAI 相容端點 | 提供商預設 |
| `MINT_MODEL_NAME` | 使用的模型 | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_MODEL_NAME` | 使用的模型;設定 `MINT_BASE_URL` 時必填 | `gemini-3.1-flash-lite` / `gpt-4o-mini` / `claude-haiku-4-5` |
| `MINT_TARGET_LANG` | 目標語言,例如 `en` 或 `en,zh-TW,ja` | 系統區域設定 |
| `MINT_VERBOSE` | 設為 `true` 可啟用詳細診斷輸出(等同於 `--verbose`) | `false` |

Expand Down
49 changes: 41 additions & 8 deletions cmd/mint/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,24 +38,56 @@ func main() {
os.Exit(run())
}

// signalError is the cancel cause recorded when a termination signal arrives;
// it carries the signal so run() can map it to the conventional exit code.
type signalError struct{ sig os.Signal }

func (e *signalError) Error() string { return "interrupted by " + e.sig.String() }

// exitCode maps the signal to the shell convention 128+N: 130 for SIGINT,
// 143 for SIGTERM.
func (e *signalError) exitCode() int {
if s, ok := e.sig.(syscall.Signal); ok {
return 128 + int(s)
}

return 130
}

func run() int {
// No request timeout: the CLI waits as long as the backend needs (handy for
// slow local models). Ctrl+C / SIGTERM cancels the in-flight request.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
//
// signal.NotifyContext is not used because it hides which signal fired,
// and SIGINT (130) and SIGTERM (143) must map to different exit codes; the
// cancel cause carries the signal instead.
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)

sigCh := make(chan os.Signal, 1)

signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(sigCh)

go func() {
cancel(&signalError{sig: <-sigCh})
}()

if err := newRootCmd().ExecuteContext(ctx); err != nil {
// Compare against context.Cause(ctx), not context.Canceled: net/http
// surfaces context.Cause(ctx), which signal.NotifyContext sets to a
// private signalError rather than context.Canceled. Checking errors.Is
// surfaces context.Cause(ctx), which the signal goroutine above sets
// to a *signalErr rather than context.Canceled. Checking errors.Is
// against the actual cause (instead of just ctx.Err() != nil) also
// makes sure an unrelated error isn't misreported as a clean interrupt
// merely because a signal happened to arrive around the same time.
// (context.Cause(ctx) is nil until ctx is done, and errors.Is(err, nil)
// is always false for a non-nil err, so no separate nil check is needed.)
if errors.Is(err, context.Cause(ctx)) {
cause := context.Cause(ctx)

var sigErr *signalError
if errors.Is(err, cause) && errors.As(cause, &sigErr) {
// Interrupted by the user — exit quietly with the conventional code.
return 130
return sigErr.exitCode()
}

fmt.Fprintln(os.Stderr, "Error:", err)
Expand Down Expand Up @@ -505,9 +537,10 @@ func buildDetectPrompt(text string) (system, user, nonce string) {
return system, user, nonce
}

// getSystemLanguage gets the system language from the OS locale.
// getSystemLanguage gets the system language from the OS locale, following the
// POSIX priority order: LC_ALL > LC_MESSAGES > LANG.
func getSystemLanguage() string {
for _, env := range []string{"LC_ALL", "LANG"} {
for _, env := range []string{"LC_ALL", "LC_MESSAGES", "LANG"} {
if lang := os.Getenv(env); lang != "" {
// Strip encoding suffix before cutting on "_":
// "C.UTF-8" → "C"; "en_US.UTF-8" → "en_US" (no change here)
Expand Down
95 changes: 95 additions & 0 deletions cmd/mint/main_signal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Copyright 2026 The Mint Authors.

// Signal-delivery tests live behind a unix build tag: they send real POSIX
// signals to the test process via syscall.Kill, which does not exist on
// Windows (and neither does meaningful SIGINT/SIGTERM delivery there).

//go:build unix

package main

import (
"net/http"
"net/http/httptest"
"os"
"syscall"
"testing"
"time"
)

// TestRunInterrupted covers both signals run() registers (os.Interrupt and
// syscall.SIGTERM): either one must cancel an in-flight request and exit
// quietly with the conventional 128+N code (130 for SIGINT, 143 for SIGTERM).
func TestRunInterrupted(t *testing.T) {
signals := []struct {
name string
sig syscall.Signal
wantCode int
}{
{"SIGINT", syscall.SIGINT, 130},
{"SIGTERM", syscall.SIGTERM, 143},
}

for _, tt := range signals {
t.Run(tt.name, func(t *testing.T) {
started := make(chan struct{})
done := make(chan struct{})

srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
close(started)
<-done // held open until the test releases it, regardless of client-side cancellation
}))

defer func() {
close(done)
srv.Close()
}()

t.Setenv("MINT_PROVIDER", "openai")
t.Setenv("MINT_API_KEY", "test")
t.Setenv("MINT_BASE_URL", srv.URL)
t.Setenv("MINT_MODEL_NAME", "test-model")

old := os.Args

os.Args = []string{"mint", "--target", "en", "hello"}
defer func() { os.Args = old }()

flushOut := captureStdout(t)
flushErr := captureStderr(t)

codeCh := make(chan int, 1)

go func() { codeCh <- run() }()

select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("request never reached the server")
}

if err := syscall.Kill(os.Getpid(), tt.sig); err != nil {
t.Fatalf("failed to send %s: %v", tt.name, err)
}

var code int

select {
case code = <-codeCh:
case <-time.After(5 * time.Second):
t.Fatalf("run() did not return after %s", tt.name)
}

stderrOutput := flushErr()
_ = flushOut()

if code != tt.wantCode {
t.Errorf("expected exit code %d, got %d", tt.wantCode, code)
}

if stderrOutput != "" {
t.Errorf("expected no stderr output on interrupt, got: %q", stderrOutput)
}
})
}
}
Loading
Loading