diff --git a/README.md b/README.md
index 2e99342..3b3b3ce 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
+ --provider
Provider to use: copilot, openai, apfel, fm (or use LAZYCOMMIT_PROVIDER)
--model Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url Override the API base URL (copilot/openai providers)
--api-key API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt 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
@@ -128,7 +130,7 @@ Prompt template:
A provider must be specified — either pass `--provider `/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
@@ -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
@@ -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 |
diff --git a/internal/app/args.go b/internal/app/args.go
index f1f5182..ee04329 100644
--- a/internal/app/args.go
+++ b/internal/app/args.go
@@ -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"
@@ -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 Provider to use: copilot, openai, apfel (or use LAZYCOMMIT_PROVIDER)
+ --provider
Provider to use: copilot, openai, apfel, fm (or use LAZYCOMMIT_PROVIDER)
--model Model name to use (provider-specific default if omitted; or use LAZYCOMMIT_MODEL)
--base-url Override the API base URL (copilot/openai providers)
--api-key API key/OAuth token to use (openai/copilot providers; or use OPENAI_API_KEY)
--prompt 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
diff --git a/internal/app/args_test.go b/internal/app/args_test.go
index 3e222ab..c118ee0 100644
--- a/internal/app/args_test.go
+++ b/internal/app/args_test.go
@@ -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"},
diff --git a/internal/app/fm_darwin.go b/internal/app/fm_darwin.go
new file mode 100644
index 0000000..07c0469
--- /dev/null
+++ b/internal/app/fm_darwin.go
@@ -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
+}
diff --git a/internal/app/fm_darwin_test.go b/internal/app/fm_darwin_test.go
new file mode 100644
index 0000000..90d17fa
--- /dev/null
+++ b/internal/app/fm_darwin_test.go
@@ -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)
+ }
+}
diff --git a/internal/app/fm_other.go b/internal/app/fm_other.go
new file mode 100644
index 0000000..a57896f
--- /dev/null
+++ b/internal/app/fm_other.go
@@ -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")
+}
diff --git a/internal/app/fm_other_test.go b/internal/app/fm_other_test.go
new file mode 100644
index 0000000..bf209dd
--- /dev/null
+++ b/internal/app/fm_other_test.go
@@ -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)
+ }
+}
diff --git a/internal/app/provider_factory.go b/internal/app/provider_factory.go
index b17e1ad..be74fdf 100644
--- a/internal/app/provider_factory.go
+++ b/internal/app/provider_factory.go
@@ -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)
}
}
diff --git a/internal/app/run.go b/internal/app/run.go
index a06d83a..4eaec99 100644
--- a/internal/app/run.go
+++ b/internal/app/run.go
@@ -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 , a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
+ fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider , a shortcut flag (--copilot, --apfel, --fm, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
return 1
}
model := ResolveModel(cfg.Model, sources)
@@ -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 , a shortcut flag (--copilot, --apfel, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
+ fmt.Fprintln(stderr, "\u274c No provider specified. Use --provider , a shortcut flag (--copilot, --apfel, --fm, --ollama, --lmstudio), or set LAZYCOMMIT_PROVIDER.")
return 1
}
model := ResolveModel(cfg.Model, sources)
diff --git a/internal/app/run_test.go b/internal/app/run_test.go
index 48ebf2f..ad510b5 100644
--- a/internal/app/run_test.go
+++ b/internal/app/run_test.go
@@ -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())
+ }
+ })
}
}
diff --git a/main.go b/main.go
index f1980c4..7b091a9 100644
--- a/main.go
+++ b/main.go
@@ -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
diff --git a/provider/fm.go b/provider/fm.go
new file mode 100644
index 0000000..b25e9fd
--- /dev/null
+++ b/provider/fm.go
@@ -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
+}
diff --git a/provider/fm_test.go b/provider/fm_test.go
new file mode 100644
index 0000000..2f7aa9a
--- /dev/null
+++ b/provider/fm_test.go
@@ -0,0 +1,73 @@
+//go:build darwin
+
+package provider
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestFoundationModelProviderGenerateSuccess(t *testing.T) {
+ var gotName string
+ var gotArgs []string
+ p := &FoundationModelProvider{
+ Model: "system",
+ Runner: func(name string, args []string) ([]byte, error) {
+ gotName = name
+ gotArgs = args
+ return []byte(" feat: fm says hi \n"), nil
+ },
+ }
+
+ got, err := p.Generate(context.Background(), "my prompt")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got != "feat: fm says hi" {
+ t.Errorf("got %q", got)
+ }
+ if gotName != "fm" {
+ t.Errorf("expected fm command, got %q", gotName)
+ }
+ if want := []string{"respond", "--no-stream", "--model", "system", "my prompt"}; !reflect.DeepEqual(gotArgs, want) {
+ t.Errorf("args = %v, want %v", gotArgs, want)
+ }
+}
+
+func TestFoundationModelProviderGenerateWithoutModel(t *testing.T) {
+ var gotArgs []string
+ p := &FoundationModelProvider{
+ Runner: func(name string, args []string) ([]byte, error) {
+ gotArgs = args
+ return []byte("ok"), nil
+ },
+ }
+
+ if _, err := p.Generate(context.Background(), "p"); err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if want := []string{"respond", "--no-stream", "p"}; !reflect.DeepEqual(gotArgs, want) {
+ t.Errorf("args = %v, want %v", gotArgs, want)
+ }
+}
+
+func TestFoundationModelProviderRunnerError(t *testing.T) {
+ p := &FoundationModelProvider{
+ Runner: func(name string, args []string) ([]byte, error) {
+ return nil, errors.New("fm unavailable")
+ },
+ }
+
+ if _, err := p.Generate(context.Background(), "p"); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestFoundationModelProviderDefaultRunner(t *testing.T) {
+ p := &FoundationModelProvider{}
+ if p.runner() == nil {
+ t.Error("expected non-nil default runner")
+ }
+}
diff --git a/provider/provider.go b/provider/provider.go
index 4751362..eafc6b8 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -1,6 +1,6 @@
// Package provider defines the interface used to generate commit messages
-// from a prompt, plus the concrete implementations: copilot, openai, and
-// apfel.
+// from a prompt, plus the concrete implementations: copilot, openai, apfel,
+// and fm.
package provider
import (
@@ -33,8 +33,8 @@ type Generator interface {
}
// ModelLister is implemented by providers that can list the models
-// available to them (currently copilot and openai; apfel does not
-// implement it, since it has no concept of selectable models).
+// available to them (currently copilot and openai; apfel and fm do not
+// implement it, since they have no concept of selectable models).
type ModelLister interface {
ListModels(ctx context.Context) ([]string, error)
}