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
// 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.
}// 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:
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 tree | cli/ |
| renders a Bubble Tea prompt | tui/ |
| serves an HTTP request | http/ |
| decides what to do based on inputs | service/ |
| reads/writes a project file | project/ (called from service/) |
| fetches the model recommendation manifest | manifest/ (called from service/) |
calls os.Exit | only 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.