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
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ blank commit message.

- **Pluggable providers**: GitHub Copilot, OpenAI-compatible Chat
Completions APIs (including local servers like Ollama and LM Studio), or
the local [`apfel`](https://github.com/Arthur-Ficial/apfel) on-device
model (macOS only) — no network calls required. A provider must be
the local [`apfel`](https://github.com/Arthur-Ficial/apfel) or built-in
`fm` Apple Foundation Models CLI (macOS only) — no network calls required.
A provider must be
selected explicitly via `--provider`/a shortcut flag, `LAZYCOMMIT_PROVIDER`,
or `git config lazycommit.provider`; there is no default.
- **Editor review by default**: the generated message is pre-populated in
Expand Down Expand Up @@ -84,13 +85,14 @@ lazycommit.provider copilot"); precedence is flag > env var > git config.

Options:
-p, --patch Interactively stage hunks via git add -p before committing
--provider <p> Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
--provider <p> Provider to use: copilot, openai, apfel, fm (or use LAZYCOMMIT_PROVIDER)
--model <m> Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url <url> Override the API base URL (copilot/openai providers)
--api-key <key> API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt <text> Override the prompt template (or use LAZYCOMMIT_PROMPT)
--copilot Shorthand for --provider copilot
--apfel Shorthand for --provider apfel (local Apple model, no network; macOS only)
--fm Shorthand for --provider fm (local Apple Foundation Model, macOS 27+ only)
--ollama Shorthand for --provider openai --base-url http://localhost:11434/v1
--lmstudio Shorthand for --provider openai --base-url http://localhost:1234/v1
--no-edit Skip the $EDITOR review step and commit the message as-is
Expand Down Expand Up @@ -128,7 +130,7 @@ Prompt template:

A provider must be specified — either pass `--provider <name>`/a shortcut
flag on every invocation, set `export LAZYCOMMIT_PROVIDER=copilot` (or
`openai`/`apfel`) once, or run `git config lazycommit.provider copilot` so
`openai`/`apfel`/`fm`) once, or run `git config lazycommit.provider copilot` so
you don't have to repeat it:

```sh
Expand All @@ -150,6 +152,9 @@ lazycommit --provider openai --model gpt-4o --api-key sk-...
# Use the local apfel model (no network calls, macOS only)
lazycommit --provider apfel # or: lazycommit --apfel

# Use the built-in Apple Foundation Models CLI (macOS 27+ only)
lazycommit --provider fm --model system # or: lazycommit --fm

# Use a local Ollama or LM Studio server (OpenAI-compatible, no API key needed)
lazycommit --ollama
lazycommit --lmstudio
Expand Down Expand Up @@ -218,18 +223,35 @@ instead of trying (and failing) to exec a nonexistent `apfel` binary.
— it just talks to whatever on-device model `apfel` provides); using
`--list-models --apfel`/`--list-models --provider apfel` is an error.

### `fm` (macOS 27+ only)

Shells out to the built-in Apple Foundation Models CLI (`fm`) with
`fm respond --no-stream` to run the local on-device model — no network calls
or API key. Equivalent to `--provider fm` or the `--fm` shorthand.

Available beginning with macOS 27, `fm` and its underlying on-device model
are Apple-only. `lazycommit` binaries built for other platforms omit the
real provider implementation entirely (via a Go build constraint), so
selecting `fm`/`--fm` on Linux or Windows fails immediately with a clear
error.

- `--model` selects the `fm` model when supplied; `fm` currently provides
the `system` model.
- Does not support `--list-models`; using
`--list-models --fm`/`--list-models --provider fm` is an error.

## Configuration

Settings can be provided via a CLI flag, an environment variable, or a
`git config` key, in that order of precedence (flags win over environment
variables, which win over `git config`). `--provider` (or one of
`--copilot`/`--apfel`/`--ollama`/`--lmstudio`), `LAZYCOMMIT_PROVIDER`, or
`--copilot`/`--apfel`/`--fm`/`--ollama`/`--lmstudio`), `LAZYCOMMIT_PROVIDER`, or
`git config lazycommit.provider` is **required** — there is no default
provider.

| Flag | Environment variable | `git config` key | Description |
|---------------|-------------------------|---------------------------|------------------------------------------------------|
| `--provider` | `LAZYCOMMIT_PROVIDER` | `lazycommit.provider` | Provider to use: `copilot`, `openai`, or `apfel` (macOS only) (required) |
| `--provider` | `LAZYCOMMIT_PROVIDER` | `lazycommit.provider` | Provider to use: `copilot`, `openai`, `apfel`, or `fm` (macOS only) (required) |
| `--model` | `LAZYCOMMIT_MODEL` | `lazycommit.model` | Model name (provider-specific default if omitted) |
| `--prompt` | `LAZYCOMMIT_PROMPT` | `lazycommit.prompt` | Prompt template override (see below) |
| `--base-url` | `GITHUB_API_URL` | `lazycommit.baseUrl` | Base URL override for the `copilot` provider |
Expand Down
6 changes: 5 additions & 1 deletion internal/app/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ func ParseArgs(args []string) (*Config, []string, error) {
case "--apfel":
// Shorthand for --provider apfel.
cfg.Provider = "apfel"
case "--fm":
// Shorthand for --provider fm.
cfg.Provider = "fm"
case "--copilot":
// Shorthand for --provider copilot.
cfg.Provider = "copilot"
Expand Down Expand Up @@ -142,13 +145,14 @@ lazycommit.provider copilot"); precedence is flag > env var > git config.

Options:
-p, --patch Interactively stage hunks via git add -p before committing
--provider <p> Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
--provider <p> Provider to use: copilot, openai, apfel, fm (or use LAZYCOMMIT_PROVIDER)
--model <m> Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url <url> Override the API base URL (copilot/openai providers)
--api-key <key> API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt <text> Override the prompt template (or use LAZYCOMMIT_PROMPT)
--copilot Shorthand for --provider copilot
--apfel Shorthand for --provider apfel (local Apple model, no network; macOS only)
--fm Shorthand for --provider fm (local Apple Foundation Model, macOS 27+ only)
--ollama Shorthand for --provider openai --base-url http://localhost:11434/v1
--lmstudio Shorthand for --provider openai --base-url http://localhost:1234/v1
--no-edit Skip the $EDITOR review step and commit the message as-is
Expand Down
5 changes: 5 additions & 0 deletions internal/app/args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ func TestParseArgs(t *testing.T) {
args: []string{"--apfel"},
wantCfg: Config{Provider: "apfel"},
},
{
name: "fm shorthand",
args: []string{"--fm"},
wantCfg: Config{Provider: "fm"},
},
{
name: "copilot shorthand",
args: []string{"--copilot"},
Expand Down
9 changes: 9 additions & 0 deletions internal/app/fm_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//go:build darwin

package app

import "github.com/evilmarty/lazycommit/provider"

func newFMProvider(model string) (provider.Generator, error) {
return &provider.FoundationModelProvider{Model: model}, nil
}
37 changes: 37 additions & 0 deletions internal/app/fm_darwin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//go:build darwin

package app

import (
"testing"

"github.com/evilmarty/lazycommit/provider"
)

func TestNewFMProviderDarwin(t *testing.T) {
gen, err := newFMProvider("system")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
fm, ok := gen.(*provider.FoundationModelProvider)
if !ok {
t.Fatalf("expected *provider.FoundationModelProvider, got %T", gen)
}
if fm.Model != "system" {
t.Errorf("expected Model system, got %q", fm.Model)
}
}

func TestNewProviderFMViaFactoryDarwin(t *testing.T) {
gen, err := NewProvider(ProviderConfig{Name: "fm", Model: "system"}, sources(nil, nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
fm, ok := gen.(*provider.FoundationModelProvider)
if !ok {
t.Fatalf("expected *provider.FoundationModelProvider, got %T", gen)
}
if fm.Model != "system" {
t.Errorf("expected Model system, got %q", fm.Model)
}
}
13 changes: 13 additions & 0 deletions internal/app/fm_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !darwin

package app

import (
"fmt"

"github.com/evilmarty/lazycommit/provider"
)

func newFMProvider(_ string) (provider.Generator, error) {
return nil, fmt.Errorf("unknown provider %q: fm is only supported on macOS", "fm")
}
34 changes: 34 additions & 0 deletions internal/app/fm_other_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//go:build !darwin

package app

import (
"strings"
"testing"
)

func TestNewFMProviderNonDarwin(t *testing.T) {
gen, err := newFMProvider("system")
if err == nil {
t.Fatal("expected error on non-darwin platforms")
}
if gen != nil {
t.Fatalf("expected nil Generator, got %v", gen)
}
if !strings.Contains(err.Error(), "macOS") {
t.Errorf("expected error to mention macOS, got %q", err)
}
}

func TestNewProviderFMViaFactoryNonDarwin(t *testing.T) {
gen, err := NewProvider(ProviderConfig{Name: "fm"}, sources(nil, nil))
if err == nil {
t.Fatal("expected error on non-darwin platforms")
}
if gen != nil {
t.Fatalf("expected nil Generator, got %v", gen)
}
if !strings.Contains(err.Error(), "macOS") {
t.Errorf("expected error to mention macOS, got %q", err)
}
}
4 changes: 3 additions & 1 deletion internal/app/provider_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,10 @@ func NewProvider(cfg ProviderConfig, sources Sources) (provider.Generator, error
}, nil
case "apfel":
return newApfelProvider()
case "fm":
return newFMProvider(cfg.Model)
default:
return nil, fmt.Errorf("unknown provider %q (expected copilot, openai, or apfel)", cfg.Name)
return nil, fmt.Errorf("unknown provider %q (expected copilot, openai, apfel, or fm)", cfg.Name)
}
}

Expand Down
4 changes: 2 additions & 2 deletions internal/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func RunWithDeps(args []string, stdout, stderr io.Writer, getenv GetEnv, deps De
if cfg.ListModels {
providerName := ResolveProvider(cfg.Provider, sources)
if providerName == "" {
fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider <name>, a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider <name>, a shortcut flag (--copilot, --apfel, --fm, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
return 1
}
model := ResolveModel(cfg.Model, sources)
Expand Down Expand Up @@ -139,7 +139,7 @@ func RunWithDeps(args []string, stdout, stderr io.Writer, getenv GetEnv, deps De

providerName := ResolveProvider(cfg.Provider, sources)
if providerName == "" {
fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider <name>, a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider <name>, a shortcut flag (--copilot, --apfel, --fm, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
return 1
}
model := ResolveModel(cfg.Model, sources)
Expand Down
29 changes: 16 additions & 13 deletions internal/app/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,19 +164,22 @@ func TestRunListModelsListError(t *testing.T) {
}

func TestRunListModelsUnsupportedProvider(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := Deps{
NewProvider: func(cfg ProviderConfig, sources Sources) (provider.Generator, error) {
// apfel (and any provider that doesn't implement ModelLister).
return fakeGenerator{}, nil
},
}
code := RunWithDeps([]string{"--list-models", "--apfel"}, &stdout, &stderr, envMap(nil), deps)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
if !strings.Contains(stderr.String(), "does not support listing models") {
t.Errorf("expected unsupported-provider error, got %q", stderr.String())
for _, providerFlag := range []string{"--apfel", "--fm"} {
t.Run(providerFlag, func(t *testing.T) {
var stdout, stderr bytes.Buffer
deps := Deps{
NewProvider: func(cfg ProviderConfig, sources Sources) (provider.Generator, error) {
return fakeGenerator{}, nil
},
}
code := RunWithDeps([]string{"--list-models", providerFlag}, &stdout, &stderr, envMap(nil), deps)
if code != 1 {
t.Fatalf("expected exit 1, got %d", code)
}
if !strings.Contains(stderr.String(), "does not support listing models") {
t.Errorf("expected unsupported-provider error, got %q", stderr.String())
}
})
}
}

Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Command lazycommit auto-generates a commit message from the staged diff
// using a pluggable LLM provider (Copilot, OpenAI, or the local apfel CLI),
// using a pluggable LLM provider (Copilot, OpenAI, or a local macOS CLI),
// optionally opens it in $EDITOR for review, then commits.
package main

Expand Down
43 changes: 43 additions & 0 deletions provider/fm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//go:build darwin

package provider

import (
"context"
"fmt"
"strings"

"github.com/evilmarty/lazycommit/internal/cmdrunner"
)

// FoundationModelProvider generates commit messages using macOS's local
// Apple Foundation Models CLI, avoiding any network calls.
type FoundationModelProvider struct {
Model string
// Runner executes the fm command. Defaults to cmdrunner.Exec when nil;
// overridable in tests.
Runner cmdrunner.Runner
}

func (p *FoundationModelProvider) runner() cmdrunner.Runner {
if p.Runner != nil {
return p.Runner
}
return cmdrunner.Exec
}

// Generate implements Generator.
func (p *FoundationModelProvider) Generate(_ context.Context, prompt string) (string, error) {
args := []string{"respond", "--no-stream"}
if p.Model != "" {
args = append(args, "--model", p.Model)
}
args = append(args, prompt)

out, err := p.runner()("fm", args)
if err != nil {
return "", fmt.Errorf("local generation with fm failed: %w", err)
}

return strings.TrimSpace(string(out)), nil
}
Loading