Skip to content

The function-layer rule

The single most important rule in the codebase.

Business logic lives in apps/cli/internal/service/ and its peer foundation packages (project/, manifest/, wiki/, wikigen/, rag/, skills/, packzip/). These packages never import cobra, Bubble Tea, or HTTP-server packages.

cli/ and http/ are thin presentation layers that import service/, never the other way around. tui/ is the same shape: calls service/, never called by it.

service/ returns errors and values. Never os.Exit. Never fmt.Println. Output formatting is the presentation layer's problem.

What this looks like in practice

go
// service/init.go — function-layer code.
package service

import (
    "context"
    "github.com/deemwar-products/pocket-llm/apps/cli/internal/manifest"
    "github.com/deemwar-products/pocket-llm/apps/cli/internal/project"
)

type InitOptions struct {
    Name        string
    ModelID     string
    ChooseModel func(ctx context.Context, recommended []manifest.Model) (manifest.Model, error)
    Progress    manifest.Progress
    Stdout      io.Writer
}

type InitResult struct {
    ProjectDir string
    ModelID    string
    HomeDir    string
}

func Init(ctx context.Context, opts InitOptions) (InitResult, error) {
    // … fetch manifest, pick model, scaffold, download, return.
}
go
// cli/init.go — presentation layer.
package cli

import (
    "github.com/spf13/cobra"
    "github.com/deemwar-products/pocket-llm/apps/cli/internal/service"
    "github.com/deemwar-products/pocket-llm/apps/cli/internal/tui"
)

func newInitCmd() *cobra.Command {
    cmd := &cobra.Command{ /* … flags … */ }
    cmd.RunE = func(c *cobra.Command, args []string) error {
        opts := service.InitOptions{ /* from flags */ }
        if term.IsTerminal(int(os.Stdin.Fd())) {
            opts.ChooseModel = tui.PickModel
        }
        _, err := service.Init(c.Context(), opts)
        return err
    }
    return cmd
}

The picker UI (Bubble Tea) lives in tui/. Wiring + flags in cli/. Real work in service/. Three layers, one direction of dependency.

Why

Forward-compat: extract an API binary later

If we ever want a separate local-agents-api daemon, the seam to cut at is exactly this. internal/http/ (the embedded handlers used by serve today) imports the same service/ functions a future API binary would. No business logic moves; only the presentation layer changes.

Testability

Every command is testable from a *_test.go with no cobra machinery, no fake terminals, no HTTP fakery. You build an Options struct, call the function, assert on the result.

Replaceable presentation

If we ever ship a different UI shell (TUI-only app, plugin inside Claude Code, whatever), service/ is reusable as-is.

Enforcement

The rule is grep-able:

bash
grep -rEn 'cobra|bubbletea|net/http' apps/cli/internal/service/

Should print nothing. A CI lint can run this as a hard gate when it gets wired up.

What goes where

If your code……it goes in
reads cobra flags / builds the command treecli/
renders a Bubble Tea prompttui/
serves an HTTP requesthttp/
decides what to do based on inputsservice/
reads/writes a project fileproject/ (called from service/)
fetches the model recommendation manifestmanifest/ (called from service/)
calls os.Exitonly main.go — and only there

Don't ship the split today

We do not currently extract a separate apps/cli-api binary. v1 ships one binary, the embedded server is good enough for the dashboard. The function-layer rule exists so that decision is reversible without a rewrite — keep the seam clean, decide later.

pocket llm — local-first, offline, no telemetry. MIT licensed.