ffc-dev
ffc Development Guide
Build and extend the ffc CLI — a Go tool for interacting with Frappe ERP sites via the REST API.
Tech Stack
| Component | Library | Import |
|---|---|---|
| CLI framework | cobra | github.com/spf13/cobra |
| Config | viper | github.com/spf13/viper |
| HTTP client | resty | github.com/go-resty/resty/v2 |
| Tables & styling | lipgloss v2 | charm.land/lipgloss/v2 + charm.land/lipgloss/v2/table |
| Forms & prompts | huh v1.0.0 | github.com/charmbracelet/huh |
| Spinner | huh/spinner | github.com/charmbracelet/huh/spinner |
Project Layout
cmd/ffc/main.go → calls cmd.Execute()
internal/cmd/root.go → root cobra command, global flags (--site, --config, --json)
internal/cmd/init.go → init subcommand (huh form wizard) + writeConfig() helper
internal/cmd/config_cmd.go → config subcommand: TUI (no args), config get, config set
internal/cmd/ping.go → ping subcommand
internal/cmd/get_doc.go → get-doc subcommand
internal/cmd/list_docs.go → list-docs subcommand + parseFields()
internal/cmd/create_doc.go → create-doc subcommand
internal/cmd/update_doc.go → update-doc subcommand
internal/cmd/delete_doc.go → delete-doc subcommand
internal/cmd/count_docs.go → count-docs subcommand
internal/cmd/get_schema.go → get-schema subcommand
internal/cmd/list_doctypes.go → list-doctypes subcommand
internal/cmd/list_reports.go → list-reports subcommand
internal/cmd/run_report.go → run-report subcommand
internal/cmd/call_method.go → call-method subcommand
internal/client/client.go → FrappeClient (resty), GetDoc, GetList, …
internal/config/config.go → viper config loading, number/date formatting, env var fallback
internal/output/output.go → PrintTable, PrintDocTable, PrintJSON, PrintError, PrintSuccess
internal/version/version.go → build-time ldflags (Version, Commit, Date)
Makefile → build, install, tidy, vet, fmt, clean
Adding a New Command
This is the most common task. Follow this exact pattern — it matches every existing command in the codebase.
1. Create the file
Create internal/cmd/<command_name>.go in package cmd. Use snake_case for filenames, kebab-case for the command's Use field.
2. Follow this structure
package cmd
import (
"fmt"
"github.com/nasroykh/foxmayn_frappe_cli/internal/client"
"github.com/nasroykh/foxmayn_frappe_cli/internal/config"
"github.com/nasroykh/foxmayn_frappe_cli/internal/output"
"github.com/charmbracelet/huh/spinner"
"github.com/spf13/cobra"
)
// <command>-specific flags — use a unique 2-letter prefix to avoid package-level collisions.
// Check existing files to pick an unused prefix.
var (
xxDoctype string
xxName string
)
var myCmd = &cobra.Command{
Use: "my-command",
Short: "One-line description",
Long: `Longer description with examples.
Examples:
ffc my-command --doctype "ToDo" --name "TD-001"
ffc my-command -d "User" -n "admin@example.com" --json
`,
RunE: func(cmd *cobra.Command, args []string) error {
// 1. Load config (uses global siteName, configPath)
cfg, err := config.Load(siteName, configPath)
if err != nil {
return fmt.Errorf("config: %w", err)
}
// 2. Parse/validate flags
// ...
// 3. API call wrapped in spinner
var result map[string]interface{}
var apiErr error
c := client.New(cfg)
_ = spinner.New().
Title("Doing something…").
Action(func() {
result, apiErr = c.GetDoc(xxDoctype, xxName)
}).
Run()
if apiErr != nil {
return apiErr
}
// 4. Output (respect --json global flag)
if jsonOutput {
output.PrintJSON(result)
} else {
output.PrintDocTable(result, nil)
}
return nil
},
}
func init() {
myCmd.Flags().StringVarP(&xxDoctype, "doctype", "d", "", "Frappe DocType (required)")
myCmd.Flags().StringVarP(&xxName, "name", "n", "", "Document name (required)")
_ = myCmd.MarkFlagRequired("doctype")
_ = myCmd.MarkFlagRequired("name")
rootCmd.AddCommand(myCmd)
}
Key patterns to follow
- Global flags
siteName,configPath,jsonOutputare package-level vars inroot.go— use them directly, don't redeclare. - Flag variable prefixes: Each command uses a unique 2-letter prefix for its flag vars to avoid collisions within the
cmdpackage. Check existing files before choosing one. - RunE, not Run: Return errors — cobra handles printing them to stderr and setting exit code 1.
- Spinner pattern: Wrap API calls in
spinner.New().Title("...").Action(func() { ... }).Run(). The error is captured in a closure variable (apiErr), checked after the spinner finishes. - Output routing: Data to stdout (
output.PrintTable,output.PrintJSON). Diagnostics/errors to stderr (output.PrintError,fmt.Fprintln(os.Stderr, ...)). - Register in init(): Call
rootCmd.AddCommand(yourCmd)inside the file'sinit()function — cobra picks it up automatically.
Adding Subcommands to an Existing Command
For nested commands (like config get / config set under config), register them against the parent command in init():
parentCmd.AddCommand(childCmd) // not rootCmd.AddCommand
The parent command can still have its own RunE (runs when called with no subcommand) alongside subcommands.
Extending the API Client
The client lives in internal/client/client.go. It wraps resty with Frappe-specific auth and error handling.
Adding a new API method
// Example: CreateDoc posts a new document.
func (c *FrappeClient) CreateDoc(doctype string, data map[string]interface{}) (map[string]interface{}, error) {
endpoint := fmt.Sprintf("/api/resource/%s", doctype)
resp, err := c.r.R().
SetBody(data).
Post(endpoint)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
// Handle HTTP errors — reuse the same switch pattern
switch resp.StatusCode() {
case http.StatusUnauthorized:
return nil, fmt.Errorf("authentication failed (401): check api_key and api_secret in your config")
case http.StatusForbidden:
return nil, fmt.Errorf("permission denied (403): your user may not have write access to %s", doctype)
case http.StatusNotFound:
return nil, fmt.Errorf("doctype %q not found on this site (404)", doctype)
}
if resp.StatusCode() >= 400 {
return nil, parseFrappeError(resp.StatusCode(), resp.Body())
}
var result struct {
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(resp.Body(), &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
return result.Data, nil
}
Frappe API essentials
- Base URL pattern:
/api/resource/{DocType}for lists,/api/resource/{DocType}/{name}for single docs. - Auth header:
Authorization: token api_key:api_secret(set automatically byclient.New). - Response envelope: v14+ wraps results in
"data", older versions use"message". Both are handled for list endpoints vialistResponse. - Error responses: Frappe returns nested JSON with Python tracebacks.
parseFrappeError()extracts the human-readable message from_server_messagesorexception. - Whitelisted methods: Frappe also exposes
api/method/<dotted.path>for server-side functions. These return results in"message".
Output Formatting
The output package provides three rendering functions. Choose based on what you're displaying:
| Function | Use for | Output |
|---|---|---|
PrintTable(rows, fields) |
Multi-row lists | Styled table with alternating row colors |
PrintDocTable(doc, fields) |
Single document | Two-column FIELD | VALUE table |
PrintJSON(data) |
Any data when --json |
Pretty-printed JSON to stdout |
Helper functions for stderr messages:
output.PrintError("message")— red bold with cross markoutput.PrintSuccess("message")— green with check mark
The color palette uses lipgloss v2 ANSI colors: purple (99), gray (245), lightGray (241), green (42), red (196), yellow (220), dim (238).
Config Loading
For commands that call the API, use config.Load(siteName, configPath) — returns a *SiteConfig for the selected site:
cfg, err := config.Load(siteName, configPath)
if err != nil {
return fmt.Errorf("config: %w", err)
}
c := client.New(cfg)
For commands that read/write config settings (not API calls), load the raw YAML directly with go.yaml.in/yaml/v3:
raw, err := os.ReadFile(cfgPath)
var vConfig config.Config
_ = yaml.Unmarshal(raw, &vConfig)
This works because Config and SiteConfig carry both mapstructure tags (for viper) and yaml tags (for direct yaml unmarshal). Always add both tags when extending these structs — missing yaml tags means fields unmarshal as zero values.
Precedence (highest wins): --site flag > FFC_* env vars > config file default_site.
When no config file exists, the client falls back to FFC_URL, FFC_API_KEY, FFC_API_SECRET env vars — useful for CI.
Interactive Forms (huh v1.0.0)
For commands that need user input (like init), use huh forms:
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().Title("Field").Validate(func(s string) error { ... }).Value(&variable),
),
)
if err := form.Run(); err != nil { return err }
For confirmations: huh.NewConfirm().Title("...").Value(&boolVar).Run()
Escape key in huh v1.0.0
Important: In huh v1.0.0, Escape is not mapped to Quit by default — only ctrl+c is. Calling .Run() directly on a standalone field wraps it in an implicit form you can't customize.
Whenever you need Escape to abort a form (especially in looped menus), create the form explicitly and attach a custom keymap:
import "github.com/charmbracelet/bubbles/key"
func escQuitKeyMap() *huh.KeyMap {
km := huh.NewDefaultKeyMap()
km.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc"))
return km
}
// Use WithKeyMap on every form where Escape should abort:
err = huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().Title("…").Options(opts...).Value(&chosen),
),
).WithKeyMap(escQuitKeyMap()).Run()
if errors.Is(err, huh.ErrUserAborted) {
// user pressed Escape or ctrl+c
}
escQuitKeyMap() already exists in config_cmd.go — reuse it or move it to a shared location if needed elsewhere.
Config File Helpers (config_cmd.go)
config_cmd.go has two helpers for reading and writing the config YAML node without losing comments or key order:
saveConfig(path, originalBytes, *yaml.Node) error— marshals the node back to disk, preserving any leading comment headerupdateYAMLValue(root *yaml.Node, key, value string)— updates a scalar value in a YAML mapping node (appends if key doesn't exist)
Note: init.go has its own writeConfig(path, siteName, url, apiKey, apiSecret string) error which generates a fresh config from scratch. The names are intentionally different to avoid a package-level collision — do not rename either.
Error Handling
- Wrap errors with context:
fmt.Errorf("loading config: %w", err)— preserves the error chain. - Never log and return. Return the error; let the caller (cobra's
RunE) decide. - HTTP errors: Use the status code switch pattern from existing client methods. Specific messages for 401, 403, 404;
parseFrappeErrorfor anything else >= 400.
Field Parsing
parseFields() in list_docs.go accepts two formats:
- JSON array:
'["name","email"]' - CSV:
name,email
Reuse this function in new commands that accept field lists. It's currently not exported — if you need it in another package, consider moving it to a shared location.
Build & Test
make build # → ./bin/ffc binary with version ldflags
make vet # → go vet ./...
make fmt # → gofmt -w .
make tidy # → go mod tidy
make install # → $GOPATH/bin + config setup
Version is injected at build time via ldflags into internal/version (Version, Commit, Date).
Checklist for New Features
- Create
internal/cmd/<name>.gowith the cobra command pattern - If it needs a new API call, add a method to
FrappeClientinclient.go - If it needs new output formatting, extend
output.go(or reuse existing functions) - Register the command via
rootCmd.AddCommand()(orparentCmd.AddCommand()for subcommands) ininit() - Run
make vet && make fmt && make buildto verify - Update README.md, CLAUDE.md, and the skill files under
skills/if adding user-facing commands