Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,18 @@ selected host's MCP endpoint; any other MCP client registers
`http://127.0.0.1:7878/mcp` directly. The install commands are in the
[Quick start](#2-connect-your-agent).

Without the plugin, register the MCP endpoint yourself. `flats mcp-config` prints
the commands for Claude Code, Codex and Cursor with your host's address:

```sh
claude mcp add --transport http flats http://127.0.0.1:7878/mcp
```

Restart or reconnect the agent and list its tools to check the connection. In a
terminal, `flats status` checks that the CLI reaches the host. `flats connect`
chooses which host the CLI and the plugin talk to (loopback by default). It does
not register an agent.

[Connecting agents](docs/agents.md) covers the other clients, remote hosts
(`flats connect`), client updates and llms.txt. The agent starts with
read-only `check_client`, then reads the server's current `guide`.
Expand Down
11 changes: 7 additions & 4 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ over stdio, so the plugin never names a host.
connects to a host elsewhere, install just the binary with the installer's
`--no-service` option. The agent must find `flats` on its `PATH`.
2. Choose the host once per machine. Skip this on the host itself: loopback is
the default.
the default. This chooses which host the CLI and the plugin talk to. It does
not register an agent.

```sh
flats connect https://flats.example.ts.net
Expand Down Expand Up @@ -94,8 +95,8 @@ register a webhook or start automatic execution.

Agents connect to the Streamable HTTP endpoint at `http://127.0.0.1:7878/mcp`
on the host, or the console's Tailscale URL plus `/mcp` from another allowed
device. Run `flats mcp-config` for Claude Code, Codex and Cursor setup. For a
host on a custom management port, use
device. Run `flats mcp-config` for Claude Code, Codex and Cursor setup, including
the plugin install commands. For a host on a custom management port, use
`flats mcp-config --url http://127.0.0.1:17878`. For example:

```sh
Expand All @@ -109,7 +110,9 @@ Cursor project `.cursor/mcp.json`:
{"mcpServers":{"flats":{"url":"http://127.0.0.1:7878/mcp"}}}
```

Restart or reconnect your client and list tools.
Restart or reconnect your client and list tools. The agent's first call is
`check_client`. In a terminal, `flats status` checks that the CLI reaches the
host.

## Keeping clients current

Expand Down
15 changes: 12 additions & 3 deletions docs/surfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,16 @@ for server flats); `flats config` and
| `rename <slug> <new-slug>`, `delete <slug> [--reason r]` | Rename; request deletion |
| `env`, `network`, `secret` | Manage server settings; changing `network` origins or `secret` values works only from the host itself (loopback) |
| `approvals [--status s]` | List approvals (read-only) |
| `status`, `install`, `uninstall`, `connect`, `mcp`, `mcp-config` | Host and client setup ([Connecting agents](agents.md)) |
| `status`, `install`, `uninstall`, `connect`, `mcp`, `mcp-config` | Host and client setup ([Connecting agents](agents.md)). `connect` chooses the host the CLI and `mcp` use; `mcp-config` prints the agent setup commands |
| `client check`, `update`, `skill sync`, `version` | Client freshness ([Updating a client](release.md#updating-a-client)) |

`flats help` lists the commands, then names `status` and `mcp-config` before the
exit codes. `flats help <command>` prints the usage line, the synopsis and, for
`connect`, `mcp`, `mcp-config` and `client`, a longer description. `flats
<command> -h` prints the same for commands that take flags, then lists the
flags. Subcommand groups such as `board`, `config`, `env`, `key`, `network` and
`secret`, and `serve`, handle `-h` themselves.

Exit codes: 0 success, 1 error, 2 usage, 3 pending approval, 78 configuration
the operator must fix. Every request that waits for approval exits 3 in text
and JSON modes, with the approval URL, its status and how to poll it. A pending
Expand Down Expand Up @@ -406,8 +413,10 @@ they run once saved.
change, asks for confirmation and says that pending approvals become stale. Console: the console's addresses on
every network, the address for links, external reverse-proxy origins and the
read-only host values. Limits:
storage, history and traffic. Agents: MCP setup and what the agent tools
cannot do (the console itself is not authenticated yet). Fields stage changes.
storage, history and traffic. Agents: setup for Claude Code, Codex and Cursor
(the plugin, or the MCP address registered directly), the CLI, how to check the
connection, and what the agent tools cannot do (the console itself is not
authenticated yet). Fields stage changes.
Console has separate inline Save/Discard controls for Links and Reverse proxy,
always visible and disabled until that section changes; saving one preserves
edits in the other. Other settings pages use one floating save bar. Each
Expand Down
52 changes: 45 additions & 7 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ func init() {
{"status", "show server and service status", "status", (*app).status},
{"install", "run `flats serve` at login (launchd/systemd)", "install [--config path] [--data dir] [--executable path] [-- legacy serve flags...]", (*app).install},
{"uninstall", "remove the launchd agent", "uninstall", (*app).uninstall},
{"connect", "choose the Flats host this CLI and `flats mcp` use", "connect [<url>] [--clear]", (*app).connect},
{"mcp", "serve the host's MCP tools over stdio (for agent plugins)", "mcp [--url url]", (*app).mcpBridge},
{"mcp-config", "print MCP setup for agent tools", "mcp-config [--url url]", (*app).mcpConfig},
{"connect", "choose which host this CLI and `flats mcp` talk to (to connect an agent, see mcp-config)", "connect [<url>] [--clear]", (*app).connect},
{"mcp", "relay the host's MCP tools over stdio (what the agent plugins run)", "mcp [--url url]", (*app).mcpBridge},
{"mcp-config", "print setup commands for Claude Code, Codex and Cursor", "mcp-config [--url url]", (*app).mcpConfig},
{"config", "manage the host configuration (config.json)", "", (*app).config},
{"key", "manage the host's Tailscale, zrok and NetBird keys while it is stopped", "", (*app).key},
{"client", "check local client compatibility", "client check [--skill-file path]", (*app).clientCommand},
Expand All @@ -152,6 +152,43 @@ func (c *command) commandUsage() string {
return c.usage
}

// commandAbout holds the longer description that `flats help <command>` and
// `flats <command> -h` print after the synopsis. A command without an entry
// shows its synopsis only.
var commandAbout = map[string]string{
"connect": "Saves the address of the Flats host that this CLI and `flats mcp` use, after checking that it\n" +
"answers as a Flats host. Without arguments it shows the current host and where that choice came\n" +
"from. --clear returns to the loopback default.\n\n" +
"It does not register an agent. `flats mcp-config` prints the setup commands for Claude Code,\n" +
"Codex and Cursor.",
"mcp": "Serves the host's MCP tools over stdio and relays them to the selected host. The Flats plugin for\n" +
"Claude Code, Codex and Cursor runs this command, so the flats CLI must be on the agent's PATH.\n" +
"Choose the host with `flats connect`.",
"mcp-config": "Prints the commands that register this host's MCP endpoint with Claude Code, Codex and Cursor,\n" +
"and the commands that install the Flats plugin. Use --url for a host at another address. --json\n" +
"prints the endpoint and the registration commands as JSON, without the plugin commands.",
"client": "Checks whether this CLI is compatible with the host and, with --skill-file, whether the named\n" +
"standalone skill file is current. The check changes nothing. `flats update` replaces a CLI that\n" +
"the check reports as incompatible.",
}

// printCommandHelp prints the usage line, then the synopsis and the longer
// description when the command has one.
func printCommandHelp(w io.Writer, c *command) {
fmt.Fprintf(w, "usage: flats %s\n\n%s\n", c.commandUsage(), capitalize(c.synopsis))
if about := commandAbout[c.name]; about != "" {
fmt.Fprintf(w, "\n%s\n", about)
}
}

// capitalize upper-cases the first byte; every synopsis starts with ASCII.
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}

func findCommand(name string) *command {
for _, c := range commands {
if c.name == name {
Expand Down Expand Up @@ -224,7 +261,7 @@ func Run(ctx context.Context, args []string, env Env) int {
case "help", "-h", "--help":
if len(rest) > 0 {
if c := findCommand(rest[0]); c != nil {
fmt.Fprintf(a.out, "usage: flats %s\n", c.commandUsage())
printCommandHelp(a.out, c)
return ExitOK
}
}
Expand Down Expand Up @@ -332,7 +369,7 @@ func printProblems(w io.Writer, ps []problem) {
// globalFlags registers --url and --json. Run resolves the default URL once
// it knows the command (resolveURL).
func (a *app) globalFlags(fs *flag.FlagSet) {
fs.Var(urlFlag{a}, "url", "Flats server URL (env FLATS_URL, else the address saved by `flats connect`)")
fs.Var(urlFlag{a}, "url", "Flats server `URL` (env FLATS_URL, else the host saved by flats connect)")
fs.BoolVar(&a.jsonOut, "json", a.jsonOut, "machine-readable JSON output")
}

Expand All @@ -341,7 +378,7 @@ func (a *app) printUsage(w io.Writer) {
for _, c := range commands {
fmt.Fprintf(w, " %-11s %s\n", c.name, c.synopsis)
}
fmt.Fprintf(w, " %-11s %s\n %-11s %s\n\nRun `flats help <command>` for details. Exit codes: 0 ok, 1 error, 2 usage, 3 pending approval.\n",
fmt.Fprintf(w, " %-11s %s\n %-11s %s\n\n`flats status` checks the host. `flats mcp-config` shows how to connect an agent.\nRun `flats help <command>` for details. Exit codes: 0 ok, 1 error, 2 usage, 3 pending approval.\n",
"serve", "run the server", "worker", "internal: server-flat worker process")
}

Expand All @@ -361,7 +398,8 @@ func (a *app) parse(fs *flag.FlagSet, args []string, minPos, maxPos int) ([]stri
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
if c := findCommand(fs.Name()); c != nil {
fmt.Fprintf(a.out, "usage: flats %s\n", c.commandUsage())
printCommandHelp(a.out, c)
fmt.Fprintln(a.out)
}
fs.SetOutput(a.out)
fs.PrintDefaults()
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ func (a *app) reportConnection(path, note string) error {
}
if note != "" {
fmt.Fprintln(a.out, "The CLI and `flats mcp` use it from now on; restart or reconnect agent tools that already started `flats mcp`.")
} else {
fmt.Fprintln(a.out, "`flats connect` only chooses the host. The setup commands for an agent are in `flats mcp-config`.")
}
return nil
}
Expand Down
25 changes: 25 additions & 0 deletions internal/cli/connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"testing"
)

// connectAgentHint is the line that bare `flats connect` prints last.
const connectAgentHint = "`flats connect` only chooses the host. The setup commands for an agent are in `flats mcp-config`."

// runConn runs the CLI with the given FLATS_URL (empty: unset) and saved
// connection directory.
func runConn(t *testing.T, configDir, flatsURL string, args ...string) result {
Expand All @@ -36,6 +39,9 @@ func TestConnectSavesAndIsUsedByLaterCommands(t *testing.T) {
if r.code != 0 || !strings.Contains(r.stdout, "Saved "+srv.URL+".") {
t.Fatalf("connect: %+v", r)
}
if strings.Contains(r.stdout, connectAgentHint) {
t.Errorf("saving a host printed the agent hint: %s", r.stdout)
}
b, err := os.ReadFile(filepath.Join(dir, "flats-client", "connection.json"))
if err != nil {
t.Fatal(err)
Expand All @@ -59,20 +65,39 @@ func TestConnectSavesAndIsUsedByLaterCommands(t *testing.T) {
t.Fatalf("show: %+v %v", r, err)
}

// Only the bare text form says that connect does not register an agent.
r = runConn(t, dir, "", "connect")
if !strings.Contains(r.stdout, "Flats host: "+srv.URL+" (saved in ") || !strings.HasSuffix(r.stdout, connectAgentHint+"\n") {
t.Errorf("bare connect: %q", r.stdout)
}

// FLATS_URL and --url still win, in that order.
r = runConn(t, dir, "http://env.example:1", "connect")
if !strings.Contains(r.stdout, "http://env.example:1 (from FLATS_URL)") || !strings.Contains(r.stdout, "overrides the saved host "+srv.URL) {
t.Fatalf("env precedence: %+v", r)
}
if !strings.HasSuffix(r.stdout, connectAgentHint+"\n") {
t.Errorf("bare connect with FLATS_URL: %q", r.stdout)
}
r = runConn(t, dir, "http://env.example:1", "connect", "--url", "http://flag.example:2")
if !strings.Contains(r.stdout, "http://flag.example:2 (from --url)") {
t.Fatalf("flag precedence: %+v", r)
}
if !strings.HasSuffix(r.stdout, connectAgentHint+"\n") {
t.Errorf("bare connect with --url: %q", r.stdout)
}

r = runConn(t, dir, "", "connect", "--clear")
if r.code != 0 || !strings.Contains(r.stdout, DefaultURL+" (loopback default)") {
t.Fatalf("clear: %+v", r)
}
// With nothing saved, bare connect shows the loopback default and the hint.
if r := runConn(t, dir, "", "connect"); !strings.Contains(r.stdout, DefaultURL+" (loopback default)") || !strings.HasSuffix(r.stdout, connectAgentHint+"\n") {
t.Errorf("bare connect on the loopback default: %q", r.stdout)
}
if strings.Contains(r.stdout, connectAgentHint) {
t.Errorf("--clear printed the agent hint: %s", r.stdout)
}
if _, err := os.Stat(filepath.Join(dir, "flats-client", "connection.json")); !os.IsNotExist(err) {
t.Fatalf("clear left the file: %v", err)
}
Expand Down
117 changes: 117 additions & 0 deletions internal/cli/help_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package cli

import (
"encoding/json"
"maps"
"slices"
"strings"
"testing"
)

func TestCommandHelpDescribesTheCommand(t *testing.T) {
for _, tc := range []struct {
name string
usage string
detail string
}{
{"connect", "usage: flats connect [<url>] [--clear]", "It does not register an agent."},
{"mcp", "usage: flats mcp [--url url]", "Claude Code, Codex and Cursor runs this command"},
{"mcp-config", "usage: flats mcp-config [--url url]", "as JSON, without the plugin commands."},
{"client", "usage: flats client check [--skill-file path]", "The check changes nothing."},
} {
r := run(t, "", "", "help", tc.name)
if r.code != 0 || !strings.HasPrefix(r.stdout, tc.usage+"\n\n") || !strings.Contains(r.stdout, tc.detail) {
t.Errorf("help %s: %d\n%s", tc.name, r.code, r.stdout)
}
}

// A command without a longer description shows its synopsis.
r := run(t, "", "", "help", "list")
if r.code != 0 || r.stdout != "usage: flats list\n\nList flats\n" {
t.Errorf("help list: %d %q", r.code, r.stdout)
}
}

func TestCommandHelpFlagsFollowTheDescription(t *testing.T) {
r := run(t, "", "", "connect", "-h")
if r.code != 0 {
t.Fatalf("connect -h: %d %s", r.code, r.stderr)
}
usage := strings.Index(r.stdout, "usage: flats connect")
about := strings.Index(r.stdout, "It does not register an agent.")
flags := strings.Index(r.stdout, "\n -clear\n")
if usage != 0 || about < usage || flags < about {
t.Errorf("order usage=%d description=%d flags=%d\n%s", usage, about, flags, r.stdout)
}
// The argument is named URL, not by the backticked words of the usage text.
if !strings.Contains(r.stdout, "-url URL") || strings.Contains(r.stdout, "-url flats connect") {
t.Errorf("--url is not named URL:\n%s", r.stdout)
}
}

func TestUsagePointsToAgentSetup(t *testing.T) {
r := run(t, "", "", "help")
if r.code != 0 {
t.Fatalf("help: %d %s", r.code, r.stderr)
}
for _, want := range []string{
"(to connect an agent, see mcp-config)",
"`flats status` checks the host. `flats mcp-config` shows how to connect an agent.\nRun `flats help <command>` for details. Exit codes: 0 ok, 1 error, 2 usage, 3 pending approval.\n",
} {
if !strings.Contains(r.stdout, want) {
t.Errorf("help is missing %q:\n%s", want, r.stdout)
}
}
// The pointer comes after the command list and before the exit codes, and
// the exit codes stay last.
if !strings.HasSuffix(r.stdout, "Exit codes: 0 ok, 1 error, 2 usage, 3 pending approval.\n") || strings.Index(r.stdout, "`flats status` checks the host.") < strings.Index(r.stdout, "worker internal") {
t.Errorf("pointer is misplaced:\n%s", r.stdout)
}
}

func TestMCPConfigNamesPluginInstall(t *testing.T) {
r := run(t, "", "", "mcp-config")
if r.code != 0 {
t.Fatal(r.stderr)
}
for _, want := range []string{
"claude plugin marketplace add gosuda/flats && claude plugin install flats@flats",
"codex plugin marketplace add gosuda/flats && codex plugin add flats@flats",
"docs/agents.md#plugin",
} {
if !strings.Contains(r.stdout, want) {
t.Errorf("mcp-config is missing %q:\n%s", want, r.stdout)
}
}
// JSON output keeps its shape: the same keys as before the plugin text, and
// no plugin commands.
j := run(t, "", "", "mcp-config", "--json")
if j.code != 0 || strings.Contains(j.stdout, "plugin") {
t.Fatalf("mcp-config --json: %d\n%s", j.code, j.stdout)
}
var v map[string]json.RawMessage
if err := json.Unmarshal([]byte(j.stdout), &v); err != nil {
t.Fatalf("mcp-config --json: %v\n%s", err, j.stdout)
}
keys := func(raw []byte) []string {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("mcp-config --json: %v\n%s", err, raw)
}
return slices.Sorted(maps.Keys(m))
}
for name, want := range map[string][]string{
"": {"claude", "codex", "cursor", "url"},
"claude": {"command"},
"codex": {"command", "file", "toml"},
"cursor": {"file", "json"},
} {
raw := []byte(j.stdout)
if name != "" {
raw = v[name]
}
if got := keys(raw); !slices.Equal(got, want) {
t.Errorf("mcp-config --json %q keys = %v, want %v", name, got, want)
}
}
}
6 changes: 5 additions & 1 deletion internal/cli/ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,11 @@ Codex (~/.codex/config.toml):
Cursor (.cursor/mcp.json in a project, or ~/.cursor/mcp.json for all projects):
%s
`, endpoint, claude, indent(codexTOML, " "), codexCmd, indent(string(cursor)+"\n", " "))
fmt.Fprintln(a.out, "\nThe Flats plugin for these clients runs `flats mcp` instead; choose its host with `flats connect <url>`.")
fmt.Fprint(a.out, "\nThe Flats plugin for these clients runs `flats mcp` instead; choose its host with `flats connect <url>`.\n"+
"The plugin needs the flats CLI on the agent's PATH. The person who sets up the agent client installs it with:\n"+
" claude plugin marketplace add gosuda/flats && claude plugin install flats@flats\n"+
" codex plugin marketplace add gosuda/flats && codex plugin add flats@flats\n"+
"For Cursor, see https://github.com/gosuda/flats/blob/main/docs/agents.md#plugin\n")
if !isLoopbackURL(a.url) {
fmt.Fprintln(a.out, "\nNote: save_draft_from_dir only works for agents on the Flats host (loopback URL).")
}
Expand Down
Loading
Loading