From fe63910e9d565963dc892617b223c432e72e62a5 Mon Sep 17 00:00:00 2001 From: Marty Zalega Date: Thu, 17 Sep 2026 12:22:09 +1000 Subject: [PATCH] feat: add support for Apple Foundation Models (fm) provider - Introduced `fm` provider for macOS 27+ using the built-in Apple Foundation Models CLI. - Added `--fm` shorthand and updated documentation to reflect new provider support. - Implemented platform-specific handling: `fm` is only available on macOS, with clear errors on unsupported platforms. --- README.md | 34 ++++++++++++--- internal/app/args.go | 6 ++- internal/app/args_test.go | 5 +++ internal/app/fm_darwin.go | 9 ++++ internal/app/fm_darwin_test.go | 37 ++++++++++++++++ internal/app/fm_other.go | 13 ++++++ internal/app/fm_other_test.go | 34 +++++++++++++++ internal/app/provider_factory.go | 4 +- internal/app/run.go | 4 +- internal/app/run_test.go | 29 +++++++------ main.go | 2 +- provider/fm.go | 43 +++++++++++++++++++ provider/fm_test.go | 73 ++++++++++++++++++++++++++++++++ provider/provider.go | 8 ++-- 14 files changed, 273 insertions(+), 28 deletions(-) create mode 100644 internal/app/fm_darwin.go create mode 100644 internal/app/fm_darwin_test.go create mode 100644 internal/app/fm_other.go create mode 100644 internal/app/fm_other_test.go create mode 100644 provider/fm.go create mode 100644 provider/fm_test.go 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) }